Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
72948e8
feat(audit): add signed Go audit ledger
devXyi Oct 16, 2026
2b3b62c
test(audit): cover Go ledger integrity and concurrency
devXyi Oct 16, 2026
b97abc5
feat(gateway): require and initialize audit ledger in production
devXyi Oct 16, 2026
b0b2e00
feat(gateway): audit authenticated API requests
devXyi Oct 16, 2026
3282963
fix(gateway): import audit ledger package
devXyi Oct 16, 2026
2195242
fix(audit): match cross-language Unicode canonicalization
devXyi Oct 16, 2026
ddc395d
fix(audit): normalize Unicode separators for canonical JSON
devXyi Oct 16, 2026
056d3e7
test(gateway): verify audit middleware avoids sensitive request data
devXyi Oct 16, 2026
d2dc431
docs(audit): add gateway ledger operations runbook
devXyi Oct 16, 2026
99b9747
docs: update gateway audit ledger status
devXyi Oct 16, 2026
33e2c17
fix(audit): validate stored sequence before restoring head
devXyi Oct 16, 2026
ebc5355
fix(audit): fail closed on unsafe keys and record torn-tail recovery
devXyi Oct 16, 2026
adfeb74
test(audit): cover torn-tail recovery and signing-key permissions
devXyi Oct 16, 2026
80eccb5
security(audit): enforce private signing-key permissions
devXyi Oct 16, 2026
e3b510b
deploy(audit): provision durable gateway ledger storage
devXyi Oct 16, 2026
461e8bf
docs(audit): clarify Render signing-key provisioning
devXyi Oct 16, 2026
1964453
fix(test): close signing-key test before recovery cases
devXyi Oct 16, 2026
8a4a7a9
fix(test): correct torn-tail JSON fixture literal
devXyi Oct 16, 2026
88a6954
fix(auditledger): remove duplicate key permission check
devXyi Oct 16, 2026
2a2dfaf
fix(auditledger): open ledger read-write for verification
devXyi Oct 16, 2026
16ec588
test(gateway): configure required audit ledger settings
devXyi Oct 16, 2026
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ Errors: `400` validation · `401` missing/invalid token · `403` permission · `
| Authorisation | RBAC (`admin`/`user`/`viewer` → permissions). **ABAC** (clearance, compartments, TLP, purpose) lives in `packages/prexus_core` and is enforced by Raksha |
| Abuse control | per-IP rate limit (5 req/s, burst 10), strict `/apply` limiter, per-user daily AI quota, body-size caps |
| Service-to-service | engine is a private service and requires `ENGINE_SECRET` on every route; it refuses to boot without it |
| Audit | tamper-evident ledger (SHA-256 chain + Ed25519 signed head) in the air-gap control plane and Raksha; the Go gateway still logs to stdout only |
| Audit | tamper-evident ledger (SHA-256 chain + Ed25519 signed head) in air-gap, Raksha, and the Go gateway (production requires durable ledger storage and a private signing key) |
| Supply chain | CodeQL (4 languages), `govulncheck`, `pip-audit`, `cargo audit`, gitleaks, SLSA provenance for release artifacts |

Not yet implemented: token revocation / refresh tokens, per-organisation tenancy in the gateway, HSM-backed key management,
Expand Down Expand Up @@ -467,7 +467,7 @@ flowchart LR
style F fill:#102A43,stroke:#55D6FF,color:#EAFBFF
```

This is a **tested development implementation, not an air-gap certification or a claim that every deployment is physically disconnected**. The runbook still needs deployment-specific verification. SSO/PKI integration, high availability, hardware security module (HSM)-backed key management, and full gateway-to-ledger integration remain open; the Go gateway's audit output is still stdout-based. See [air-gap overview](./airgap/README.md), [runbook](./docs/AIRGAP_RUNBOOK.md), [ledger specification](./docs/LEDGER_SPEC.md), and [audit/fix status](./docs/AUDIT_FIXES.md).
This is a **tested development implementation, not an air-gap certification or a claim that every deployment is physically disconnected**. The runbook still needs deployment-specific verification. SSO/PKI integration, high availability, and hardware security module (HSM)-backed key management remain open. The Go gateway now has signed file-ledger middleware, but its ledger is not transactionally coupled to PostgreSQL mutations and requires deployment-specific durable storage and key provisioning. See [air-gap overview](./airgap/README.md), [runbook](./docs/AIRGAP_RUNBOOK.md), [gateway ledger runbook](./docs/GATEWAY_AUDIT_LEDGER.md), [ledger specification](./docs/LEDGER_SPEC.md), and [audit/fix status](./docs/AUDIT_FIXES.md).

## 🧪 Test & Verification Snapshot

Expand Down
61 changes: 61 additions & 0 deletions backend/backend/apps/api-gateway/audit_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"log"
"os"
"time"

"github.com/gin-gonic/gin"
"github.com/devXyi/prexus-intelligence/backend/auditledger"
)

// gatewayAuditLedger is initialized once at startup and closed during graceful shutdown.
// The ledger serializes appends and fails closed after any persistence/integrity error.
var gatewayAuditLedger *auditledger.Ledger

// AuditMiddleware records an intent before the protected handler runs and records
// the outcome afterward. It deliberately excludes bodies, query strings, credentials,
// and provider payloads from audit metadata.
func AuditMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
ledger := gatewayAuditLedger
if ledger == nil {
if os.Getenv("ENV") == "production" {
c.AbortWithStatusJSON(503, gin.H{"error": "audit ledger unavailable"})
return
}
c.Next()
return
}

actor := c.GetString("user_id")
if actor == "" {
actor = "unknown"
}
resource := c.Request.URL.Path
method := c.Request.Method
requestID := c.GetString("request_id")
started := time.Now()
_, err := ledger.Append(actor, "http.request.started", resource, map[string]any{
"method": method, "request_id": requestID,
})
if err != nil {
log.Printf("audit ledger refused request: action=start resource=%s error=%v", resource, err)
c.AbortWithStatusJSON(503, gin.H{"error": "audit ledger unavailable; request not executed"})
return
}

c.Next()

_, err = ledger.Append(actor, "http.request.completed", resource, map[string]any{
"method": method, "request_id": requestID,
"status": c.Writer.Status(), "duration_ms": time.Since(started).Milliseconds(),
})
if err != nil {
// The start event remains durable. The ledger poisons itself after a failed
// append, so subsequent requests are blocked before their handlers execute.
log.Printf("audit ledger failed to persist request outcome: action=complete resource=%s status=%d error=%v",
resource, c.Writer.Status(), err)
}
}
}
55 changes: 55 additions & 0 deletions backend/backend/apps/api-gateway/audit_middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/gin-gonic/gin"
"github.com/devXyi/prexus-intelligence/backend/auditledger"
)

func TestAuditMiddlewareRecordsRequestWithoutQuerySecrets(t *testing.T) {
oldLedger, oldEnv := gatewayAuditLedger, os.Getenv("ENV")
t.Cleanup(func() {
if gatewayAuditLedger != nil && gatewayAuditLedger != oldLedger { _ = gatewayAuditLedger.Close() }
gatewayAuditLedger = oldLedger
_ = os.Setenv("ENV", oldEnv)
})
_ = os.Setenv("ENV", "test")

_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil { t.Fatal(err) }
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil { t.Fatal(err) }
dir := t.TempDir()
keyPath := filepath.Join(dir, "ledger-key.pem")
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type:"PRIVATE KEY",Bytes:der}), 0600); err != nil { t.Fatal(err) }
ledger, err := auditledger.Open(filepath.Join(dir,"ledger"), keyPath)
if err != nil { t.Fatal(err) }
gatewayAuditLedger = ledger

gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(RequestID(), func(c *gin.Context) { c.Set("user_id","operator-test"); c.Next() }, AuditMiddleware())
r.GET("/risk/asset", func(c *gin.Context) { c.Status(http.StatusNoContent) })
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/risk/asset?token=do-not-log", nil)
r.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) }

result := ledger.Verify()
if !result.Valid || result.HeadSequence != 2 { t.Fatalf("audit chain invalid: %+v", result) }
raw, err := os.ReadFile(filepath.Join(dir,"ledger","ledger.ndjson"))
if err != nil { t.Fatal(err) }
text := string(raw)
if strings.Contains(text,"do-not-log") || strings.Contains(text,"token") { t.Fatal("query secrets must not be written to audit metadata") }
if !strings.Contains(text,"http.request.started") || !strings.Contains(text,"http.request.completed") { t.Fatal("expected both start and completion events") }
}
27 changes: 26 additions & 1 deletion backend/backend/apps/api-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/devXyi/prexus-intelligence/backend/auditledger"
"golang.org/x/time/rate"
)

Expand Down Expand Up @@ -149,6 +150,11 @@ func validateConfig(env string) (fatal, warn []string) {
fatal = append(fatal, k+" is still the placeholder from .env.example")
}
}
for _, k := range []string{"AUDIT_LEDGER_DIR", "AUDIT_LEDGER_PRIVATE_KEY"} {
if strings.TrimSpace(os.Getenv(k)) == "" {
fatal = append(fatal, k+" is required in production for tamper-evident gateway auditing")
}
}
}
if s := os.Getenv("JWT_SECRET"); s == "" {
fatal = append(fatal, "JWT_SECRET is not set")
Expand Down Expand Up @@ -222,7 +228,7 @@ func buildRouter() *gin.Engine {

quota := AIQuotaMiddleware(newQuotaStore(aiDailyQuota()))

auth := r.Group("/", AuthMiddleware())
auth := r.Group("/", AuthMiddleware(), AuditMiddleware())
{
auth.GET("/assets", RequirePermission("assets:read"), handleGetAssets)
auth.POST("/assets", RequirePermission("assets:create"), handleCreateAsset)
Expand Down Expand Up @@ -276,6 +282,25 @@ func main() {
log.Fatal("refusing to start: invalid configuration")
}

auditDir, auditKey := strings.TrimSpace(os.Getenv("AUDIT_LEDGER_DIR")), strings.TrimSpace(os.Getenv("AUDIT_LEDGER_PRIVATE_KEY"))
if auditDir != "" || auditKey != "" {
ledger, err := auditledger.Open(auditDir, auditKey)
if err != nil {
log.Fatalf("audit ledger initialization failed: %v", err)
}
gatewayAuditLedger = ledger
defer func() {
if err := ledger.Close(); err != nil {
log.Printf("audit ledger close failed: %v", err)
}
}()
log.Printf("✓ Tamper-evident gateway audit ledger enabled")
} else if env == "production" {
log.Fatal("AUDIT_LEDGER_DIR and AUDIT_LEDGER_PRIVATE_KEY are required in production")
} else {
log.Printf("⚠ audit ledger disabled in non-production environment")
}

dataEngineURL := getDataEngineURL()
if dataEngineURL == "" {
log.Fatal("DATA_ENGINE_URL is not set — cannot start")
Expand Down
4 changes: 3 additions & 1 deletion backend/backend/apps/api-gateway/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

func TestValidateConfig_ProductionFailsClosed(t *testing.T) {
for _, k := range []string{"JWT_SECRET", "DATABASE_URL", "DB_HOST", "ENGINE_SECRET", "DATA_ENGINE_URL", "ALLOWED_ORIGINS", "TRUSTED_PROXIES"} {
for _, k := range []string{"JWT_SECRET", "DATABASE_URL", "DB_HOST", "ENGINE_SECRET", "DATA_ENGINE_URL", "ALLOWED_ORIGINS", "TRUSTED_PROXIES", "AUDIT_LEDGER_DIR", "AUDIT_LEDGER_PRIVATE_KEY"} {
t.Setenv(k, "")
}
fatal, _ := validateConfig("production")
Expand All @@ -25,6 +25,8 @@ func TestValidateConfig_ProductionFailsClosed(t *testing.T) {
t.Setenv("ENGINE_SECRET", "e")
t.Setenv("DATA_ENGINE_URL", "http://engine.internal")
t.Setenv("ALLOWED_ORIGINS", "https://prexus.in")
t.Setenv("AUDIT_LEDGER_DIR", filepath.Join(t.TempDir(), "audit-ledger"))
t.Setenv("AUDIT_LEDGER_PRIVATE_KEY", filepath.Join(t.TempDir(), "audit-key.pem"))
if fatal, _ := validateConfig("production"); len(fatal) != 0 {
t.Errorf("complete config rejected: %v", fatal)
}
Expand Down
Loading
Loading