diff --git a/README.md b/README.md index 7a7b9ad..ff8ff67 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,7 @@ Authentication: `Authorization: Bearer ` (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 | diff --git a/backend/backend/apps/api-gateway/admin.go b/backend/backend/apps/api-gateway/admin.go new file mode 100644 index 0000000..4dcd721 --- /dev/null +++ b/backend/backend/apps/api-gateway/admin.go @@ -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) +} diff --git a/backend/backend/apps/api-gateway/admin_test.go b/backend/backend/apps/api-gateway/admin_test.go new file mode 100644 index 0000000..ee16250 --- /dev/null +++ b/backend/backend/apps/api-gateway/admin_test.go @@ -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) } + } + } +} diff --git a/backend/backend/apps/api-gateway/db.go b/backend/backend/apps/api-gateway/db.go index 0537bf0..7668752 100644 --- a/backend/backend/apps/api-gateway/db.go +++ b/backend/backend/apps/api-gateway/db.go @@ -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) diff --git a/backend/backend/apps/api-gateway/main.go b/backend/backend/apps/api-gateway/main.go index 09e7329..9344802 100644 --- a/backend/backend/apps/api-gateway/main.go +++ b/backend/backend/apps/api-gateway/main.go @@ -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) } diff --git a/backend/backend/apps/api-gateway/pg_integration_test.go b/backend/backend/apps/api-gateway/pg_integration_test.go index 4646013..fa5644f 100644 --- a/backend/backend/apps/api-gateway/pg_integration_test.go +++ b/backend/backend/apps/api-gateway/pg_integration_test.go @@ -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) + } +} diff --git a/backend/backend/apps/api-gateway/rbac.go b/backend/backend/apps/api-gateway/rbac.go index 3b12c33..33d6e33 100644 --- a/backend/backend/apps/api-gateway/rbac.go +++ b/backend/backend/apps/api-gateway/rbac.go @@ -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", diff --git a/backend/backend/go.mod b/backend/backend/go.mod index c2d86f3..466519f 100644 --- a/backend/backend/go.mod +++ b/backend/backend/go.mod @@ -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 @@ -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 )