Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ Authentication: `Authorization: Bearer <JWT>` (24 h). Rate limit: 5 req/s per IP
| `GET /health` · `GET /ready` | none | liveness · readiness (database) |
| `POST /register` · `POST /login` | none | create account (role `user`) · obtain JWT |
| `POST /apply` | none | access / procurement application (stored in Postgres, emailed if SMTP configured) |
| `GET /admin/applications[/:ref]` | admin (`applications:read`) | read stored applications with bounded pagination and reference validation |
| `GET/POST/PUT/DELETE /assets[/:id]` | `assets:*` | the caller's assets |
| `GET /alerts` | `assets:read` | alerts derived from the caller's asset scores |
| `POST /risk/asset` · `/risk/portfolio` · `/risk/stress-test` | `risk:run` | Monte Carlo (Rust core) via the engine |
Expand Down
112 changes: 112 additions & 0 deletions backend/backend/apps/api-gateway/admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// backend/apps/api-gateway/admin.go
// Admin-only reads for access application records.

package main

import (
"context"
"database/sql"
"errors"
"log"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"
)

type applicationDTO struct {
Ref string `json:"ref"`
Module string `json:"module"`
Plan string `json:"plan"`
Deployment string `json:"deployment"`
Name string `json:"name"`
Title string `json:"title"`
Email string `json:"email"`
Org string `json:"org"`
Country string `json:"country"`
OrgType string `json:"org_type"`
UseCase string `json:"use_case"`
Notified bool `json:"notified"`
NotifyError string `json:"notify_error"`
CreatedAt time.Time `json:"created_at"`
}

const applicationCols = `ref,module,plan,COALESCE(deployment,''),name,COALESCE(title,''),email,org,country,org_type,COALESCE(use_case,''),notified,COALESCE(notify_error,''),created_at`

type rowScanner interface { Scan(dest ...any) error }

func scanApplication(sc rowScanner) (applicationDTO, error) {
var a applicationDTO
err := sc.Scan(&a.Ref, &a.Module, &a.Plan, &a.Deployment, &a.Name, &a.Title, &a.Email, &a.Org, &a.Country, &a.OrgType, &a.UseCase, &a.Notified, &a.NotifyError, &a.CreatedAt)
return a, err
}

func clampQueryInt(raw string, def, min, max int) int {
n, err := strconv.Atoi(raw)
if err != nil { return def }
if n < min { return min }
if n > max { return max }
return n
}

func handleListApplications(c *gin.Context) {
if DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error":"database unavailable"})
return
}
limit := clampQueryInt(c.Query("limit"), 50, 1, 200)
offset := clampQueryInt(c.Query("offset"), 0, 0, 1_000_000)
ctx, cancel := context.WithTimeout(c.Request.Context(), dbTimeout)
defer cancel()
rows, err := DB.QueryContext(ctx, `SELECT `+applicationCols+` FROM applications ORDER BY created_at DESC, ref LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
log.Printf("[admin] list applications: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error":"Database error"})
return
}
defer rows.Close()
out := make([]applicationDTO, 0, limit)
for rows.Next() {
a, err := scanApplication(rows)
if err != nil {
log.Printf("[admin] scan application: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error":"Database error"})
return
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
log.Printf("[admin] row iteration applications: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error":"Database error"})
return
}
log.Printf("[admin] user=%s listed applications limit=%d offset=%d returned=%d", c.GetString("user_id"), limit, offset, len(out))
c.JSON(http.StatusOK, gin.H{"applications":out, "limit":limit, "offset":offset})
}

func handleGetApplication(c *gin.Context) {
ref := c.Param("ref")
if !appRefRe.MatchString(ref) {
c.JSON(http.StatusBadRequest, gin.H{"error":"invalid application reference"})
return
}
if DB == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error":"database unavailable"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), dbTimeout)
defer cancel()
a, err := scanApplication(DB.QueryRowContext(ctx, `SELECT `+applicationCols+` FROM applications WHERE ref=$1`, ref))
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error":"application not found"})
return
}
if err != nil {
log.Printf("[admin] get application %s: %v", ref, err)
c.JSON(http.StatusInternalServerError, gin.H{"error":"Database error"})
return
}
log.Printf("[admin] user=%s read application %s", c.GetString("user_id"), ref)
c.JSON(http.StatusOK, a)
}
84 changes: 84 additions & 0 deletions backend/backend/apps/api-gateway/admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"database/sql/driver"
"encoding/json"
"testing"
"time"

"github.com/gin-gonic/gin"
)

func adminRouter(role string) *gin.Engine {
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("role", role); c.Set("user_id", "u-1"); c.Next() })
r.GET("/admin/applications", RequirePermission("applications:read"), handleListApplications)
r.GET("/admin/applications/:ref", RequirePermission("applications:read"), handleGetApplication)
return r
}

func appRow(ref string) []driver.Value {
return []driver.Value{ref, "meteorium", "Institutional", "cloud", "Asha Rao", "Director", "asha@example.org", "DST CoE", "India", "Government", "Flood pilot — ünïcode ✓", true, "", time.Now()}
}

func TestAdmin_OnlyAdminsCanReadApplications(t *testing.T) {
f := newFakeDB(t)
f.QueryCols = []string{"ref", "module", "plan", "deployment", "name", "title", "email", "org", "country", "org_type", "use_case", "notified", "notify_error", "created_at"}
f.QueryRows = [][]driver.Value{appRow("PRX-MET-0A1B2C3D")}
for role, want := range map[string]int{"admin": 200, "user": 403, "viewer": 403, "nonsense": 403} {
w := doJSON(adminRouter(role), "GET", "/admin/applications", nil, nil)
if w.Code != want { t.Errorf("role %q: want %d got %d", role, want, w.Code) }
}
var out struct { Applications []map[string]any `json:"applications"` }
w := doJSON(adminRouter("admin"), "GET", "/admin/applications", nil, nil)
_ = json.Unmarshal(w.Body.Bytes(), &out)
if len(out.Applications) != 1 || out.Applications[0]["ref"] != "PRX-MET-0A1B2C3D" || out.Applications[0]["use_case"] != "Flood pilot — ünïcode ✓" || out.Applications[0]["email"] != "asha@example.org" {
t.Fatalf("applicant details must be readable by admins: %v", out.Applications)
}
}

func TestAdmin_PaginationIsClamped(t *testing.T) {
f := newFakeDB(t)
f.QueryCols = []string{"ref", "module", "plan", "deployment", "name", "title", "email", "org", "country", "org_type", "use_case", "notified", "notify_error", "created_at"}
for q, want := range map[string][2]int64{"": {50, 0}, "?limit=99999&offset=-5": {200, 0}, "?limit=abc&offset=xyz": {50, 0}, "?limit=0": {1, 0}, "?limit=10&offset=30": {10, 30}} {
f.Queries = nil
w := doJSON(adminRouter("admin"), "GET", "/admin/applications"+q, nil, nil)
if w.Code != 200 { t.Fatalf("%q: %d", q, w.Code) }
if len(f.Queries) != 1 || f.Queries[0].args[0] != want[0] || f.Queries[0].args[1] != want[1] {
t.Errorf("%q: LIMIT/OFFSET args %v, want %v", q, f.Queries, want)
}
}
}

func TestAdmin_GetApplication_ValidatesRefBeforeTouchingTheDatabase(t *testing.T) {
f := newFakeDB(t)
for _, bad := range []string{"PRX-XXX-ZZZZZZZZ", "prx-met-0a1b2c3d", "PRX-MET-0A1B2C3", "PRX-MET-0A1B2C3D%27%20OR%201=1", "..%2Fetc%2Fpasswd", "PRX-ME-0A1B2C3D"} {
if w := doJSON(adminRouter("admin"), "GET", "/admin/applications/"+bad, nil, nil); w.Code != 400 && w.Code != 404 {
t.Errorf("%q → %d, want 400/404", bad, w.Code)
}
}
if len(f.Queries) != 0 { t.Fatalf("invalid references reached SQL: %v", f.Queries) }
if w := doJSON(adminRouter("admin"), "GET", "/admin/applications/PRX-MET-0A1B2C3D", nil, nil); w.Code != 404 {
t.Errorf("well-formed but unknown ref → %d", w.Code)
}
if w := doJSON(adminRouter("user"), "GET", "/admin/applications/PRX-MET-0A1B2C3D", nil, nil); w.Code != 403 {
t.Errorf("non-admin → %d", w.Code)
}
}

func TestAdmin_RoutesRegisteredOnTheRealRouter_AndRequireAuth(t *testing.T) {
r := newTestRouter(t)
have := map[string]bool{}
for _, rt := range r.Routes() { have[rt.Method+" "+rt.Path] = true }
for _, want := range []string{"GET /admin/applications", "GET /admin/applications/:ref"} {
if !have[want] { t.Errorf("route %s not registered", want) }
}
for _, p := range []string{"/admin/applications", "/admin/applications/PRX-MET-0A1B2C3D"} {
if w := doJSON(r, "GET", p, nil, nil); w.Code != 401 { t.Errorf("%s without a token → %d, want 401", p, w.Code) }
}
for _, role := range []string{"user", "viewer"} {
for _, perm := range rolePermissions[role] {
if perm == "applications:read" { t.Errorf("role %s must not hold applications:read", role) }
}
}
}
21 changes: 21 additions & 0 deletions backend/backend/apps/api-gateway/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,31 @@ func migrate() error {
if err != nil {
return fmt.Errorf("migration: %w", err)
}
// Best-effort defence in depth for API roles exposed through PostgREST.
// If the runtime role is not table owner, apply hardening using a privileged migration role.
if _, hardenErr := DB.ExecContext(ctx, hardenSQL); hardenErr != nil {
log.Printf("⚠ could not apply row-level-security hardening: %v", hardenErr)
}
log.Println("✓ Database schema ready")
return nil
}

const hardenSQL = `
DO $$
BEGIN
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE applications ENABLE ROW LEVEL SECURITY;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
REVOKE ALL ON TABLE users, assets, applications FROM anon;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
REVOKE ALL ON TABLE users, assets, applications FROM authenticated;
END IF;
EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE 'RLS hardening skipped (database role is not table owner)';
END $$;`

func handleGetAssets(c *gin.Context) {
userID := c.GetString("user_id")
ctx, cancel := context.WithTimeout(c.Request.Context(), dbTimeout)
Expand Down
2 changes: 2 additions & 0 deletions backend/backend/apps/api-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ func buildRouter() *gin.Engine {
auth.POST("/gemini", RateLimitMiddleware(), RequirePermission("risk:run"), quota, handleLegacyAI("gemini"))

auth.GET("/conduit/tools", RequirePermission("conduit:read"), handleConduitTools)
auth.GET("/admin/applications", RequirePermission("applications:read"), handleListApplications)
auth.GET("/admin/applications/:ref", RequirePermission("applications:read"), handleGetApplication)
auth.GET("/me", handleGetMe)
auth.PUT("/me", handleUpdateMe)
}
Expand Down
58 changes: 58 additions & 0 deletions backend/backend/apps/api-gateway/pg_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,61 @@ func TestPG_InitDBWithRetry_RecoversWhenDatabaseComesUp(t *testing.T) {
}
CloseDB()
}

func TestPG_Admin_ReadsStoredApplications_AndRBAC(t *testing.T) {
pgSetup(t)
t.Setenv("SMTP_HOST", "")
r := newTestRouter(t)
for _, email := range []string{"admin-pg@example.org", "user-pg@example.org"} {
w := doJSON(r, "POST", "/register", map[string]any{"email": email, "password": "correct-horse-1", "full_name": "Test"}, nil)
if w.Code >= 300 { t.Fatalf("register %s: %d %s", email, w.Code, w.Body.String()) }
}
if _, err := DB.Exec(`UPDATE users SET role='admin' WHERE email='admin-pg@example.org'`); err != nil { t.Fatal(err) }
login := func(email string) string {
w := doJSON(r, "POST", "/login", map[string]any{"email": email, "password": "correct-horse-1"}, nil)
var out AuthResponse
_ = json.Unmarshal(w.Body.Bytes(), &out)
if w.Code != 200 || out.Token == "" { t.Fatalf("login %s: %d %s", email, w.Code, w.Body.String()) }
return out.Token
}
adminToken, userToken := login("admin-pg@example.org"), login("user-pg@example.org")
body := validApply()
body["use_case"] = "admin query integration — naïve ✓"
w := doJSON(r, "POST", "/apply", body, nil)
var applied map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &applied)
if w.Code != 200 || applied["ref"] == "" { t.Fatalf("apply: %d %s", w.Code, w.Body.String()) }
authed := func(token string) map[string]string { return map[string]string{"Authorization": "Bearer " + token} }

w = doJSON(r, "GET", "/admin/applications?limit=1&offset=0", nil, authed(adminToken))
var list struct { Applications []map[string]any `json:"applications"` }
_ = json.Unmarshal(w.Body.Bytes(), &list)
if w.Code != 200 || len(list.Applications) != 1 || list.Applications[0]["ref"] != applied["ref"] || list.Applications[0]["use_case"] != body["use_case"] {
t.Fatalf("admin listing failed: %d %s", w.Code, w.Body.String())
}
w = doJSON(r, "GET", "/admin/applications/" + applied["ref"], nil, authed(adminToken))
if w.Code != 200 || !strings.Contains(w.Body.String(), "asha@example.org") { t.Fatalf("admin read failed: %d %s", w.Code, w.Body.String()) }
w = doJSON(r, "GET", "/admin/applications", nil, authed(userToken))
if w.Code != http.StatusForbidden { t.Fatalf("normal user admin listing: %d %s", w.Code, w.Body.String()) }
w = doJSON(r, "GET", "/admin/applications", nil, nil)
if w.Code != http.StatusUnauthorized { t.Fatalf("anonymous admin listing: %d", w.Code) }
}

func TestPG_Migration_EnablesRLSAndRevokesAPIGrants(t *testing.T) {
pgSetup(t)
var secured int
if err := DB.QueryRow(`SELECT COUNT(*) FROM pg_class WHERE relname IN ('users','assets','applications') AND relkind='r' AND relrowsecurity`).Scan(&secured); err != nil || secured != 3 {
t.Fatalf("expected RLS on users/assets/applications; got %d, err=%v", secured, err)
}
// Only test revocation where this connection is permitted to create roles and grant privileges.
_, _ = DB.Exec(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='anon') THEN CREATE ROLE anon NOLOGIN; END IF; IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='authenticated') THEN CREATE ROLE authenticated NOLOGIN; END IF; END $$`)
if _, err := DB.Exec(`GRANT ALL ON TABLE users,assets,applications TO anon,authenticated`); err != nil {
t.Skipf("role-grant check requires privileged test database: %v", err)
}
CloseDB()
if err := InitDB(); err != nil { t.Fatal(err) }
var grants int
if err := DB.QueryRow(`SELECT COUNT(*) FROM information_schema.role_table_grants WHERE table_schema='public' AND table_name IN ('users','assets','applications') AND grantee IN ('anon','authenticated')`).Scan(&grants); err != nil || grants != 0 {
t.Fatalf("API role grants remain after migration: %d, err=%v", grants, err)
}
}
2 changes: 1 addition & 1 deletion backend/backend/apps/api-gateway/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import "github.com/gin-gonic/gin"
var rolePermissions = map[string][]string{
"admin": {
"assets:read", "assets:create", "assets:update", "assets:delete",
"risk:run", "user:read", "conduit:read",
"risk:run", "user:read", "conduit:read", "applications:read",
},
"user": {
"assets:read", "assets:create", "assets:update", "risk:run", "conduit:read",
Expand Down
4 changes: 2 additions & 2 deletions backend/backend/go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/devXyi/prexus-intelligence/backend

go 1.22
go 1.26.9

require (
github.com/gin-contrib/cors v1.7.2
Expand All @@ -9,7 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.24.0
golang.org/x/net v0.55.0
golang.org/x/net v0.60.0
golang.org/x/text v0.39.0
golang.org/x/time v0.5.0
)
Loading