From a15cc45cca7f571c88ed9abd11152ba38e7361e6 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Nutakki Date: Tue, 21 Jul 2026 15:11:16 -0500 Subject: [PATCH] feat(approvals): expire grants after ten minutes Mint one immutable database-clock expiry for every new approval, enforce the half-open lifetime in the single-use consume update, and bind expiry into versioned audit evidence. Retain legacy rows fail closed, preserve mixed-format offline verification, and document the transactional migration and rolling-upgrade contract. GSTACK-Checkpoint: 2026-07-21/approval-grant-expiry#1 Signed-off-by: Gnani Rahul Nutakki --- README.md | 18 +- docs/EPICS.md | 12 +- docs/SITH-NOTION.md | 9 + docs/adr/0005-ai-mcp-ardur-pdp.md | 12 +- internal/auditrecord/export.go | 44 +-- internal/auditrecord/export_test.go | 25 +- internal/hubdb/approvals.go | 62 ++-- internal/hubdb/approvals_test.go | 38 ++- .../migrations/0013_approval_grant_expiry.sql | 48 ++++ internal/hubdb/policy_audit.go | 37 ++- internal/hubdb/policy_audit_test.go | 72 +++++ internal/hubdb/postgres_integration_test.go | 264 +++++++++++++++--- sessions/2026-07-21-approval-grant-expiry.md | 65 +++++ 13 files changed, 596 insertions(+), 110 deletions(-) create mode 100644 internal/hubdb/migrations/0013_approval_grant_expiry.sql create mode 100644 sessions/2026-07-21-approval-grant-expiry.md diff --git a/README.md b/README.md index 03d10dc..dce7253 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,11 @@ Approval creation and consumption append distinct format-versioned lifecycle ent tenant chain in the exact transaction that mutates the single-use grant. An audit failure therefore rolls back the approval mutation. Each lifecycle pair carries only a one-way, domain-separated digest of the immutable grant binding—never raw targets, arguments, or justification content. +New grants use format 3: PostgreSQL mints one immutable absolute expiry exactly 10 minutes after +approval, checks `approved_at <= statement_timestamp() < expires_at` in the same conditional +consumption update, and binds both timestamps into the lifecycle evidence digest. An expired, +legacy, missing, foreign, mismatched, or replayed grant returns the same unavailable result; an +expired refusal retains the row, leaves `consumed_at` unset, and appends no success event. Both audit routes use the dedicated `export-audit` action and `audit.export` PEP verb. Sith durably appends an authorization decision before every read. The complete route verifies the head and all retained history in one forced-RLS Repeatable Read snapshot and remains limited to 512 entries. For @@ -515,15 +520,22 @@ migration ledger, creates the tenant-scoped policy-audit chain and exact single- store, narrows the application role's audit and approval-table privileges, audits forced RLS plus both immutable-entry contracts, attempts to close its one owner connection, and exits. Approval rows contain only opaque identifiers, proposer/approver identity, the resolved proposal digest, -and lifecycle timestamps. The application role may insert them and update only `consumed_at`; it -cannot rewrite or delete the approved identity or digest. The migration process never opens the hub -listener, creates a Kubernetes client, or starts collection. +an evidence version, and lifecycle timestamps. The application role may insert them and update only +`consumed_at`; it cannot rewrite or delete the approved identity, digest, approval time, expiry, or +evidence version. The migration process never opens the hub listener, creates a Kubernetes client, +or starts collection. Migration 0011 preserves defaults for older format-1 audit writers, but older verifiers do not understand format-2 approval lifecycle entries. During a rolling upgrade, run the migration, upgrade all verifier-capable hub instances, and only then enable traffic that creates or consumes approvals. Migration 0012 only extends the retained action constraint with the closed `export-audit` value; deploy it before exposing the audit-export route so its authorizing decision can be appended. +Migration 0013 backfills legacy approval rows under one transactional access-exclusive owner lock, +immediately restores forced RLS, and marks those rows as legacy so they cannot be consumed by the +new evidence contract. It also enables audit format 3. Run the migration before deploying format-3 +writers and upgrade every verifier before enabling approval traffic; older writers fail closed +because the new immutable fields have no permissive defaults. Sith currently exposes no runtime +approval/dispatch path, so this ordering does not interrupt a supported write API. The normal hub process continues to use only `SITH_HUB_DATABASE_URL` for the non-owner application role. Do not reuse the migration-owner credential in the hub Deployment or place either database diff --git a/docs/EPICS.md b/docs/EPICS.md index 5733de7..045398e 100644 --- a/docs/EPICS.md +++ b/docs/EPICS.md @@ -1699,9 +1699,15 @@ same-workspace, distinct-approver grant bound to the existing immutable resolved The non-owner application role can insert the forced-RLS row and atomically set only `consumed_at`; it cannot rewrite or delete the intent, identities, or digest. Missing, foreign, mismatched, and replayed grants share one fail-closed refusal, and a real PostgreSQL concurrency -test proves exactly one consumer wins. MCP elicitation transport, Ardur PDP policy, approval expiry, -multi-approver counting, credential minting, and dispatch remain later slices; this status does not -claim F5.9 complete. +test proves exactly one consumer wins. + +**Implementation status (F5.9b, 2026-07-21).** Every new grant has one immutable 10-minute +absolute lifetime minted from PostgreSQL statement time. Consumption checks the half-open +`approved_at <= consumed_at < expires_at` interval in the same conditional update that spends the +grant. Expiry is bound into versioned lifecycle evidence; legacy grants are retained but fail +closed, and format-1/2 audit records remain independently verifiable beside format 3. MCP +elicitation transport, Ardur PDP policy, multi-approver counting, credential minting, and dispatch +remain later slices; this status does not claim F5.9 complete. **Key risk / guardrail.** Approve-then-swap (approve a benign action, then change args) is the classic agent bypass. Guardrail: the approval is bound to an arg-hash re-checked at dispatch, so a diff --git a/docs/SITH-NOTION.md b/docs/SITH-NOTION.md index d39fece..89f0184 100644 --- a/docs/SITH-NOTION.md +++ b/docs/SITH-NOTION.md @@ -1756,6 +1756,15 @@ sequenceDiagram - Approvals are per-action, single-use, and bound to the resolved-args hash. - Changing the args after approval invalidates it (approve-then-swap is blocked). +**Implementation status (F5.9a/F5.9b, 2026-07-21).** The durable server-side core persists a +same-workspace, distinct-approver grant bound to the immutable resolved proposal digest and spends +it with one conditional PostgreSQL update. Every new grant has one immutable 10-minute absolute +lifetime minted from PostgreSQL statement time; consumption enforces the half-open +`approved_at <= consumed_at < expires_at` interval in that same update. Expiry is bound into +versioned lifecycle evidence, legacy grants are retained but fail closed, and historical audit +formats remain independently verifiable. MCP transport, Ardur PDP policy, multi-approver counting, +credential minting, and dispatch remain later slices; this does not claim F5.9 complete. + **Key risk / guardrail.** Approve-then-swap (approve a benign action, then change args) is the classic agent bypass. Guardrail: the approval is bound to an arg-hash re-checked at dispatch, so a valid signature and a valid approval are both necessary but neither is sufficient if the args diff --git a/docs/adr/0005-ai-mcp-ardur-pdp.md b/docs/adr/0005-ai-mcp-ardur-pdp.md index 8041c8c..bae7f82 100644 --- a/docs/adr/0005-ai-mcp-ardur-pdp.md +++ b/docs/adr/0005-ai-mcp-ardur-pdp.md @@ -13,9 +13,12 @@ governance, not the product.** Verified MCP facts (July 2026): - Tool annotations (`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`) shipped in the **2025-03-26** spec and are **hints, not guarantees — enforce server-side**. -- **Elicitation** (a server requesting structured user input mid-flow via `elicitation/create` - + JSON schema) shipped in the **2025-06-18** spec — the native primitive for - human-in-the-loop approval. +- **Elicitation** (a server requesting structured user input mid-flow via `elicitation/create`) + shipped in the **2025-06-18** spec. The latest published + [**2025-11-25** contract](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + adds URL mode; + Sith uses form mode with a constrained schema for non-secret human approval. MCP does not define + the lifetime of the resulting server-side grant, so Sith enforces that boundary independently. Ardur (ArdurAI's runtime-governance runtime) is purpose-built to be a policy decision point, identity broker, and decision-ledger for agent actions. @@ -29,6 +32,9 @@ identity broker, and decision-ledger for agent actions. carry `destructiveHint: true` (+ correct `idempotentHint`), and require **Elicitation-based approval bound to a hash of the resolved args** (the agent cannot approve-then-swap). `intent.gitops-open-pr` ships first. +- The durable approval is single-use and valid for one immutable absolute 10-minute window. + PostgreSQL statement time mints and consumes it; the same atomic update requires + `approved_at <= consumed_at < expires_at`, and the lifecycle evidence digest binds the expiry. - **Annotations are hints ⇒ enforcement is server-side.** The MCP layer is a **thin adapter over the same PEP** the UI uses. There is no privileged agent path: an external agent (Claude Code, Codex, kagent) gets **exactly** the governance a human does. diff --git a/internal/auditrecord/export.go b/internal/auditrecord/export.go index 75934e7..44d0422 100644 --- a/internal/auditrecord/export.go +++ b/internal/auditrecord/export.go @@ -29,8 +29,9 @@ const ( // MaxDocumentBytes bounds one portable JSON document before offline parsing. MaxDocumentBytes = 1 << 20 - policyAuditHashDomain = "sith-policy-audit-chain/v1" - approvalAuditHashDomain = "sith-approval-audit-chain/v2" + policyAuditHashDomain = "sith-policy-audit-chain/v1" + approvalAuditHashDomain = "sith-approval-audit-chain/v2" + approvalExpiryAuditHashDomain = "sith-approval-audit-chain/v3" ) // Export is one complete, verified workspace snapshot. It is constructed only after the backing @@ -52,20 +53,22 @@ type Chain struct { // Entry is the privacy-minimized, independently rehashable projection of one retained event. The // workspace is carried once by Export and is nevertheless bound into each entry hash. type Entry struct { - Sequence int64 `json:"sequence"` - FormatVersion int16 `json:"format_version"` - RecordedAt time.Time `json:"recorded_at"` - TraceID string `json:"trace_id"` - Actor string `json:"actor"` - Role string `json:"role"` - Action string `json:"action"` - Verb string `json:"verb"` - Verdict string `json:"verdict"` - ReasonCode string `json:"reason_code"` - EventKind string `json:"event_kind"` - EvidenceDigest string `json:"evidence_digest"` - PreviousHash string `json:"previous_hash"` - EntryHash string `json:"entry_hash"` + Sequence int64 `json:"sequence"` + FormatVersion int16 `json:"format_version"` + RecordedAt time.Time `json:"recorded_at"` + TraceID string `json:"trace_id"` + Actor string `json:"actor"` + Role string `json:"role"` + Action string `json:"action"` + Verb string `json:"verb"` + Verdict string `json:"verdict"` + ReasonCode string `json:"reason_code"` + EventKind string `json:"event_kind"` + // EvidenceDigest is opaque at this privacy-minimized boundary. Offline verification binds it + // into the versioned chain; the database writer proves its grant-field semantics before append. + EvidenceDigest string `json:"evidence_digest"` + PreviousHash string `json:"previous_hash"` + EntryHash string `json:"entry_hash"` } // ValidateForWorkspace rechecks the portable disclosure boundary independently of the backing @@ -157,8 +160,11 @@ func RecomputeEntryHash(workspaceID tenancy.WorkspaceID, entry Entry) (string, e } domain := policyAuditHashDomain - if entry.FormatVersion == 2 { + switch entry.FormatVersion { + case 2: domain = approvalAuditHashDomain + case 3: + domain = approvalExpiryAuditHashDomain } canonical := make([]byte, 0, 512) canonical = appendCanonicalString(canonical, domain) @@ -172,7 +178,7 @@ func RecomputeEntryHash(workspaceID tenancy.WorkspaceID, entry Entry) (string, e } { canonical = appendCanonicalString(canonical, value) } - if entry.FormatVersion == 2 { + if entry.FormatVersion == 2 || entry.FormatVersion == 3 { canonical = appendCanonicalString(canonical, entry.EventKind) canonical = appendCanonicalString(canonical, entry.EvidenceDigest) } @@ -222,7 +228,7 @@ func validEntryShape(entry Entry) bool { return false } return true - case 2: + case 2, 3: if !validHash(entry.EvidenceDigest) || entry.Verb != "approval.grant" || verdict != pep.VerdictAllow || entry.ReasonCode != entry.EventKind { return false diff --git a/internal/auditrecord/export_test.go b/internal/auditrecord/export_test.go index 7b7c0cc..e8b0314 100644 --- a/internal/auditrecord/export_test.go +++ b/internal/auditrecord/export_test.go @@ -12,6 +12,8 @@ import ( "github.com/ArdurAI/sith/internal/tenancy" ) +const expiringApprovalEvidenceFixture = "sha256:25edbb61ecc55494ed155e14b30733b08ab090469a789b79eed3bf871ddbd1b4" + func TestExportValidateForWorkspaceAcceptsClosedPortableChain(t *testing.T) { t.Parallel() @@ -31,6 +33,11 @@ func TestExportValidateForWorkspaceAcceptsClosedPortableChain(t *testing.T) { if err := approval.ValidateForWorkspace("workspace-a"); err != nil { t.Fatalf("approval ValidateForWorkspace() error = %v", err) } + approval.Entries[0].FormatVersion = 3 + approval.Entries[0].EvidenceDigest = expiringApprovalEvidenceFixture + if err := approval.ValidateForWorkspace("workspace-a"); err != nil { + t.Fatalf("expiring approval ValidateForWorkspace() error = %v", err) + } } func TestExportValidateForWorkspaceRejectsForeignAndMalformedDocuments(t *testing.T) { @@ -95,6 +102,7 @@ func TestRecomputeEntryHashGoldenFormats(t *testing.T) { }{ {name: "format 1 policy decision", entry: mixed.Entries[0], want: "sha256:67544ba8ac180f834bc221aa136c7d121c0e63228b02bc7c7dce2508de26c4ea"}, {name: "format 2 approval lifecycle", entry: mixed.Entries[1], want: "sha256:dfbfb98dda5768b259314faa5ed57f40ae4575c466e899ad79c23cea06277ece"}, + {name: "format 3 expiring approval lifecycle", entry: mixed.Entries[2], want: "sha256:3cd45877bc466f0a61700a8f13b91e1f5b31c2b9ac382032be2410131d4da338"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -233,8 +241,21 @@ func validMixedTestExport() Export { } second.EntryHash = secondHash exported.Entries = append(exported.Entries, second) - exported.Chain.HeadSequence = 2 - exported.Chain.HeadHash = secondHash + third := Entry{ + Sequence: 3, FormatVersion: 3, + RecordedAt: time.Date(2026, time.July, 18, 9, 32, 0, 123456000, time.UTC), + TraceID: strings.Repeat("3", 32), Actor: "user:alice", Role: "operator", Action: "propose-intent", + Verb: "approval.grant", Verdict: "allow", ReasonCode: "approval-consumed", + EventKind: "approval-consumed", EvidenceDigest: expiringApprovalEvidenceFixture, PreviousHash: secondHash, + } + thirdHash, err := RecomputeEntryHash("workspace-a", third) + if err != nil { + panic(err) + } + third.EntryHash = thirdHash + exported.Entries = append(exported.Entries, third) + exported.Chain.HeadSequence = 3 + exported.Chain.HeadHash = thirdHash return exported } diff --git a/internal/hubdb/approvals.go b/internal/hubdb/approvals.go index 24b58be..eb06cf6 100644 --- a/internal/hubdb/approvals.go +++ b/internal/hubdb/approvals.go @@ -20,13 +20,16 @@ import ( "github.com/ArdurAI/sith/internal/tracing" ) -const approvalGrantIDBytes = 16 +const ( + approvalGrantIDBytes = 16 + approvalGrantEvidenceVersion = 2 +) var ( approvalGrantIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) // ErrApprovalGrantUnavailable deliberately covers missing, foreign, mismatched, unauthorized, - // and already-consumed grants so callers cannot use the approval boundary as an oracle. + // legacy, expired, and already-consumed grants so callers cannot use this boundary as an oracle. ErrApprovalGrantUnavailable = errors.New("approval grant unavailable") ) @@ -38,27 +41,26 @@ type ApprovalGrantID string func (identifier ApprovalGrantID) String() string { return string(identifier) } // CreateApprovalGrant persists one same-workspace, separation-of-duty approval for an exact -// validated proposal. It stores no raw target, argument, justification, token, or elicitation data. +// validated proposal. PostgreSQL mints its immutable ten-minute lifetime; no caller controls the +// clock. The row stores no raw target, argument, justification, token, or elicitation data. func (database *AppDB) CreateApprovalGrant( ctx context.Context, approver tenancy.Scope, binding pep.ApprovalBinding, - approvedAt time.Time, ) (ApprovalGrantID, error) { - return database.createApprovalGrant(ctx, approver, binding, approvedAt, rand.Reader) + return database.createApprovalGrant(ctx, approver, binding, rand.Reader) } func (database *AppDB) createApprovalGrant( ctx context.Context, approver tenancy.Scope, binding pep.ApprovalBinding, - approvedAt time.Time, random io.Reader, ) (ApprovalGrantID, error) { if database == nil || database.pool == nil || ctx == nil { return "", fmt.Errorf("create approval grant: database and context are required") } - if binding.Validate() != nil || approvedAt.IsZero() || approvedAt.After(time.Now().Add(time.Minute)) || random == nil || + if binding.Validate() != nil || random == nil || approver.Authorize(tenancy.ActionApproveIntent) != nil || approver.RequireWorkspace(binding.WorkspaceID()) != nil || approver.Subject() == binding.Proposer() { return "", fmt.Errorf("create approval grant: %w", ErrApprovalGrantUnavailable) @@ -90,12 +92,16 @@ func (database *AppDB) createApprovalGrant( !currentApproverRole.Allows(tenancy.ActionApproveIntent) { return ErrApprovalGrantUnavailable } - _, err := tx.Exec(traceContext, ` + var approvedAt, expiresAt time.Time + err := tx.QueryRow(traceContext, ` INSERT INTO sith.approval_grants( - workspace_id, id, intent_id, proposer, approver, resolved_digest, approved_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7) + workspace_id, id, intent_id, proposer, approver, resolved_digest, + evidence_version, approved_at, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, statement_timestamp(), + statement_timestamp() + interval '10 minutes') + RETURNING approved_at, expires_at `, binding.WorkspaceID(), identifier, binding.IntentID(), binding.Proposer(), approver.Subject(), - binding.ResolvedDigest(), approvedAt.UTC()) + binding.ResolvedDigest(), approvalGrantEvidenceVersion).Scan(&approvedAt, &expiresAt) if err != nil { var postgresErr *pgconn.PgError if errors.As(err, &postgresErr) && postgresErr.Code == "23505" { @@ -103,12 +109,12 @@ func (database *AppDB) createApprovalGrant( } return fmt.Errorf("persist exact approval grant: %w", err) } - evidence := approvalGrantEvidenceDigest( + evidence := expiringApprovalGrantEvidenceDigest( binding.WorkspaceID(), identifier, binding.IntentID(), binding.Proposer(), - approver.Subject(), binding.ResolvedDigest(), approvedAt, + approver.Subject(), binding.ResolvedDigest(), approvedAt, expiresAt, ) return appendPolicyAuditEntryTx(traceContext, tx, policyAuditEntry{ - format: approvalAuditFormatVersion, recordedAt: approvedAt.UTC().Truncate(time.Microsecond), + format: approvalExpiryAuditFormatVersion, recordedAt: approvedAt.UTC().Truncate(time.Microsecond), traceID: traceID, workspaceID: binding.WorkspaceID(), actor: approver.Subject(), role: approver.Role(), action: tenancy.ActionApproveIntent, verb: approvalAuditVerb, verdict: pep.VerdictAllow, reasonCode: approvalCreatedEventKind, @@ -124,20 +130,19 @@ func (database *AppDB) createApprovalGrant( return identifier, nil } -// ConsumeApprovalGrant atomically spends one exact grant. A concurrent or replaying consumer sees -// the same stable refusal as a missing or mismatched grant; exactly one conditional update wins. +// ConsumeApprovalGrant atomically spends one exact, unexpired grant using PostgreSQL statement +// time. Concurrent, replaying, legacy, expired, missing, and mismatched consumers share one stable +// refusal; exactly one conditional update can win. func (database *AppDB) ConsumeApprovalGrant( ctx context.Context, proposer tenancy.Scope, binding pep.ApprovalBinding, identifier ApprovalGrantID, - consumedAt time.Time, ) error { if database == nil || database.pool == nil || ctx == nil { return fmt.Errorf("consume approval grant: database and context are required") } - if binding.Validate() != nil || !approvalGrantIDPattern.MatchString(identifier.String()) || consumedAt.IsZero() || - consumedAt.After(time.Now().Add(time.Minute)) || + if binding.Validate() != nil || !approvalGrantIDPattern.MatchString(identifier.String()) || proposer.Authorize(tenancy.ActionProposeIntent) != nil || proposer.RequireWorkspace(binding.WorkspaceID()) != nil || proposer.Subject() != binding.Proposer() { return fmt.Errorf("consume approval grant: %w", ErrApprovalGrantUnavailable) @@ -166,27 +171,32 @@ func (database *AppDB) ConsumeApprovalGrant( returned ApprovalGrantID returnedApprover string returnedApprovedAt time.Time + returnedExpiresAt time.Time + returnedConsumedAt time.Time ) err := tx.QueryRow(traceContext, ` UPDATE sith.approval_grants - SET consumed_at = $6 + SET consumed_at = statement_timestamp() WHERE workspace_id = $1 AND id = $2 AND intent_id = $3 AND proposer = $4 - AND resolved_digest = $5 AND consumed_at IS NULL AND approved_at <= $6 - RETURNING id, approver, approved_at + AND resolved_digest = $5 AND evidence_version = $6 AND consumed_at IS NULL + AND approved_at <= statement_timestamp() AND statement_timestamp() < expires_at + RETURNING id, approver, approved_at, expires_at, consumed_at `, binding.WorkspaceID(), identifier, binding.IntentID(), binding.Proposer(), binding.ResolvedDigest(), - consumedAt.UTC()).Scan(&returned, &returnedApprover, &returnedApprovedAt) + approvalGrantEvidenceVersion).Scan( + &returned, &returnedApprover, &returnedApprovedAt, &returnedExpiresAt, &returnedConsumedAt, + ) if errors.Is(err, pgx.ErrNoRows) || err == nil && returned != identifier { return ErrApprovalGrantUnavailable } if err != nil { return fmt.Errorf("atomically consume exact approval grant: %w", err) } - evidence := approvalGrantEvidenceDigest( + evidence := expiringApprovalGrantEvidenceDigest( binding.WorkspaceID(), returned, binding.IntentID(), binding.Proposer(), - returnedApprover, binding.ResolvedDigest(), returnedApprovedAt, + returnedApprover, binding.ResolvedDigest(), returnedApprovedAt, returnedExpiresAt, ) return appendPolicyAuditEntryTx(traceContext, tx, policyAuditEntry{ - format: approvalAuditFormatVersion, recordedAt: consumedAt.UTC().Truncate(time.Microsecond), + format: approvalExpiryAuditFormatVersion, recordedAt: returnedConsumedAt.UTC().Truncate(time.Microsecond), traceID: traceID, workspaceID: binding.WorkspaceID(), actor: proposer.Subject(), role: proposer.Role(), action: tenancy.ActionProposeIntent, verb: approvalAuditVerb, verdict: pep.VerdictAllow, reasonCode: approvalConsumedEventKind, diff --git a/internal/hubdb/approvals_test.go b/internal/hubdb/approvals_test.go index 9535065..e7bcd32 100644 --- a/internal/hubdb/approvals_test.go +++ b/internal/hubdb/approvals_test.go @@ -9,7 +9,6 @@ import ( "io/fs" "strings" "testing" - "time" "github.com/ArdurAI/sith/internal/pep" "github.com/ArdurAI/sith/internal/tenancy" @@ -36,18 +35,51 @@ func TestApprovalGrantIdentifierIsOpaqueAndCanonical(t *testing.T) { func TestApprovalGrantBoundaryClassifiesMissingDatabaseAsOperationalError(t *testing.T) { t.Parallel() - if _, err := (*AppDB)(nil).CreateApprovalGrant(context.Background(), tenancy.Scope{}, pep.ApprovalBinding{}, time.Now()); err == nil { + if _, err := (*AppDB)(nil).CreateApprovalGrant(context.Background(), tenancy.Scope{}, pep.ApprovalBinding{}); err == nil { t.Fatal("nil database created an approval grant") } else if errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("nil database error was misclassified as safe approval refusal: %v", err) } - if err := (*AppDB)(nil).ConsumeApprovalGrant(context.Background(), tenancy.Scope{}, pep.ApprovalBinding{}, "AAAAAAAAAAAAAAAAAAAAAA", time.Now()); err == nil { + if err := (*AppDB)(nil).ConsumeApprovalGrant(context.Background(), tenancy.Scope{}, pep.ApprovalBinding{}, "AAAAAAAAAAAAAAAAAAAAAA"); err == nil { t.Fatal("nil database consumed an approval grant") } else if errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("nil database error was misclassified as safe approval refusal: %v", err) } } +func TestApprovalGrantExpiryMigrationIsFixedVersionedAndFailClosed(t *testing.T) { + t.Parallel() + + migration, err := fs.ReadFile(migrationFiles, "migrations/0013_approval_grant_expiry.sql") + if err != nil { + t.Fatal(err) + } + text := string(migration) + for _, required := range []string{ + "ADD COLUMN expires_at timestamptz", "ADD COLUMN evidence_version smallint", + "expires_at = approved_at + interval '10 minutes'", "evidence_version IN (1, 2)", + "evidence_version = 1 OR consumed_at IS NULL OR consumed_at < expires_at", + "NO FORCE ROW LEVEL SECURITY", "FORCE ROW LEVEL SECURITY", "format_version IN (1, 2, 3)", + } { + if !strings.Contains(text, required) { + t.Fatalf("approval expiry migration is missing %q", required) + } + } + for _, forbidden := range []string{ + "DELETE FROM sith.approval_grants", "SET consumed_at", "DROP TABLE", "DISABLE ROW LEVEL SECURITY", + } { + if strings.Contains(text, forbidden) { + t.Fatalf("approval expiry migration contains destructive legacy handling %q", forbidden) + } + } + noForce := strings.Index(text, "NO FORCE ROW LEVEL SECURITY") + backfill := strings.Index(text, "UPDATE sith.approval_grants") + restoreForce := strings.LastIndex(text, "FORCE ROW LEVEL SECURITY") + if noForce < 0 || backfill <= noForce || restoreForce <= backfill { + t.Fatal("approval expiry backfill does not restore FORCE RLS around its transactional owner window") + } +} + func TestApprovalGrantMigrationIsPrivacyMinimizedAndForcedRLS(t *testing.T) { t.Parallel() diff --git a/internal/hubdb/migrations/0013_approval_grant_expiry.sql b/internal/hubdb/migrations/0013_approval_grant_expiry.sql new file mode 100644 index 0000000..f7da293 --- /dev/null +++ b/internal/hubdb/migrations/0013_approval_grant_expiry.sql @@ -0,0 +1,48 @@ +-- SPDX-License-Identifier: Apache-2.0 + +ALTER TABLE sith.approval_grants + ADD COLUMN expires_at timestamptz, + ADD COLUMN evidence_version smallint; + +-- Existing rows retain their historical format-2 evidence. They receive a forensic expiry value, +-- but the format marker keeps every legacy unconsumed grant outside the new consumption predicate. +-- ApplyMigrations holds one serializable transaction; ALTER takes an access-exclusive lock, and a +-- failure rolls this owner-only FORCE-RLS relaxation back before any application can observe it. +ALTER TABLE sith.approval_grants NO FORCE ROW LEVEL SECURITY; +UPDATE sith.approval_grants +SET expires_at = approved_at + interval '10 minutes', evidence_version = 1; +ALTER TABLE sith.approval_grants FORCE ROW LEVEL SECURITY; + +ALTER TABLE sith.approval_grants + ALTER COLUMN expires_at SET NOT NULL, + ALTER COLUMN evidence_version SET NOT NULL, + ADD CONSTRAINT approval_grants_expiry_valid CHECK ( + expires_at = approved_at + interval '10 minutes' + ), + ADD CONSTRAINT approval_grants_evidence_version_valid CHECK (evidence_version IN (1, 2)), + ADD CONSTRAINT approval_grants_v2_consumption_window_valid CHECK ( + evidence_version = 1 OR consumed_at IS NULL OR consumed_at < expires_at + ); + +ALTER TABLE sith.policy_audit_entries + DROP CONSTRAINT policy_audit_entries_format_valid, + DROP CONSTRAINT policy_audit_entries_evidence_valid, + DROP CONSTRAINT policy_audit_entries_lifecycle_shape_valid, + ADD CONSTRAINT policy_audit_entries_format_valid CHECK (format_version IN (1, 2, 3)), + ADD CONSTRAINT policy_audit_entries_evidence_valid CHECK ( + (format_version = 1 AND event_kind = 'policy-decision' AND evidence_digest = '') + OR + (format_version IN (2, 3) AND event_kind IN ('approval-created', 'approval-consumed') + AND evidence_digest ~ '^sha256:[0-9a-f]{64}$') + ), + ADD CONSTRAINT policy_audit_entries_lifecycle_shape_valid CHECK ( + format_version = 1 + OR + (format_version IN (2, 3) AND verb = 'approval.grant' AND verdict = 'allow' + AND reason_code = event_kind + AND ( + (event_kind = 'approval-created' AND role = 'approver' AND action = 'approve-intent') + OR + (event_kind = 'approval-consumed' AND role = 'operator' AND action = 'propose-intent') + )) + ); diff --git a/internal/hubdb/policy_audit.go b/internal/hubdb/policy_audit.go index 773edca..47f5e8c 100644 --- a/internal/hubdb/policy_audit.go +++ b/internal/hubdb/policy_audit.go @@ -23,13 +23,15 @@ import ( ) const ( - policyAuditFormatVersion int16 = 1 - approvalAuditFormatVersion int16 = 2 - approvalEvidenceHashDomain = "sith-approval-grant-evidence/v1" - policyDecisionEventKind = "policy-decision" - approvalCreatedEventKind = "approval-created" - approvalConsumedEventKind = "approval-consumed" - approvalAuditVerb pep.Verb = "approval.grant" + policyAuditFormatVersion int16 = 1 + approvalAuditFormatVersion int16 = 2 + approvalExpiryAuditFormatVersion int16 = 3 + approvalEvidenceHashDomain = "sith-approval-grant-evidence/v1" + approvalExpiryEvidenceHashDomain = "sith-approval-grant-evidence/v2" + policyDecisionEventKind = "policy-decision" + approvalCreatedEventKind = "approval-created" + approvalConsumedEventKind = "approval-consumed" + approvalAuditVerb pep.Verb = "approval.grant" ) var approvalEvidencePattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) @@ -530,7 +532,7 @@ func validatePolicyAuditEntry(entry policyAuditEntry) error { Verdict: entry.verdict, ReasonCode: entry.reasonCode, } return event.Validate() - case approvalAuditFormatVersion: + case approvalAuditFormatVersion, approvalExpiryAuditFormatVersion: if entry.recordedAt.IsZero() || entry.recordedAt.After(time.Now().Add(time.Minute)) || !entry.traceID.Valid() || !approvalEvidencePattern.MatchString(entry.evidence) || entry.verb != approvalAuditVerb || entry.verdict != pep.VerdictAllow || entry.reasonCode != entry.eventKind { @@ -580,6 +582,25 @@ func approvalGrantEvidenceDigest( return "sha256:" + hex.EncodeToString(digest[:]) } +func expiringApprovalGrantEvidenceDigest( + workspaceID tenancy.WorkspaceID, + identifier ApprovalGrantID, + intentID, proposer, approver, resolvedDigest string, + approvedAt, expiresAt time.Time, +) string { + canonical := make([]byte, 0, 512) + for _, value := range []string{ + approvalExpiryEvidenceHashDomain, string(workspaceID), identifier.String(), intentID, + proposer, approver, resolvedDigest, + approvedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano), + expiresAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano), + } { + canonical = appendCanonicalString(canonical, value) + } + digest := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(digest[:]) +} + func appendCanonicalString(target []byte, value string) []byte { target = strconv.AppendInt(target, int64(len(value)), 10) target = append(target, ':') diff --git a/internal/hubdb/policy_audit_test.go b/internal/hubdb/policy_audit_test.go index eed9fa7..24124e6 100644 --- a/internal/hubdb/policy_audit_test.go +++ b/internal/hubdb/policy_audit_test.go @@ -92,6 +92,26 @@ func TestApprovalAuditEntryHashBindsLifecycleMetadata(t *testing.T) { } } +func TestExpiringApprovalAuditEntryUsesDistinctFormatDomain(t *testing.T) { + t.Parallel() + + legacy := policyAuditTestEntry() + legacy.format = approvalAuditFormatVersion + legacy.role = tenancy.RoleApprover + legacy.action = tenancy.ActionApproveIntent + legacy.verb = approvalAuditVerb + legacy.reasonCode = approvalCreatedEventKind + legacy.eventKind = approvalCreatedEventKind + legacy.evidence = "sha256:" + strings.Repeat("a", 64) + + expiring := legacy + expiring.format = approvalExpiryAuditFormatVersion + legacyHash, expiringHash := policyAuditEntryHash(legacy), policyAuditEntryHash(expiring) + if len(legacyHash) != 32 || len(expiringHash) != 32 || bytes.Equal(legacyHash, expiringHash) { + t.Fatal("format 2 and format 3 lifecycle records did not use distinct valid hash domains") + } +} + func TestApprovalGrantEvidenceDigestBindsImmutableGrant(t *testing.T) { t.Parallel() @@ -119,6 +139,35 @@ func TestApprovalGrantEvidenceDigestBindsImmutableGrant(t *testing.T) { } } +func TestExpiringApprovalGrantEvidenceDigestBindsImmutableExpiry(t *testing.T) { + t.Parallel() + + approvedAt := time.Date(2026, time.July, 21, 12, 34, 56, 123456000, time.UTC) + expiresAt := approvedAt.Add(10 * time.Minute) + base := expiringApprovalGrantEvidenceDigest( + "workspace-a", "AAAAAAAAAAAAAAAAAAAAAA", "intent-a", "user:operator", + "user:approver", "sha256:"+strings.Repeat("a", 64), approvedAt, expiresAt, + ) + if !approvalEvidencePattern.MatchString(base) { + t.Fatalf("expiring evidence digest = %q, want canonical SHA-256", base) + } + if want := "sha256:25edbb61ecc55494ed155e14b30733b08ab090469a789b79eed3bf871ddbd1b4"; base != want { + t.Fatalf("expiring evidence digest = %q, want golden %q", base, want) + } + if base == approvalGrantEvidenceDigest( + "workspace-a", "AAAAAAAAAAAAAAAAAAAAAA", "intent-a", "user:operator", + "user:approver", "sha256:"+strings.Repeat("a", 64), approvedAt, + ) { + t.Fatal("expiring evidence reused the legacy evidence domain") + } + if changed := expiringApprovalGrantEvidenceDigest( + "workspace-a", "AAAAAAAAAAAAAAAAAAAAAA", "intent-a", "user:operator", + "user:approver", "sha256:"+strings.Repeat("a", 64), approvedAt, expiresAt.Add(time.Microsecond), + ); changed == base { + t.Fatal("expiring evidence digest did not bind expires_at") + } +} + func TestPolicyAuditBoundaryRejectsMissingDatabaseAndInvalidScope(t *testing.T) { t.Parallel() @@ -273,6 +322,29 @@ func FuzzApprovalGrantEvidenceDigestUsesLengthFraming(f *testing.F) { }) } +func FuzzExpiringApprovalGrantEvidenceDigestUsesLengthFraming(f *testing.F) { + f.Add("a", "bc") + f.Add("user:operator", "user:approver") + f.Fuzz(func(t *testing.T, left, right string) { + if left == "" || right == "" || len(left)+len(right) > 512 { + t.Skip() + } + approvedAt := time.Date(2026, time.July, 21, 12, 34, 56, 123456000, time.UTC) + expiresAt := approvedAt.Add(10 * time.Minute) + first := expiringApprovalGrantEvidenceDigest( + "workspace-a", "AAAAAAAAAAAAAAAAAAAAAA", left, right, "user:approver", + "sha256:"+strings.Repeat("a", 64), approvedAt, expiresAt, + ) + second := expiringApprovalGrantEvidenceDigest( + "workspace-a", "AAAAAAAAAAAAAAAAAAAAAA", left+right, "user:operator", "user:approver", + "sha256:"+strings.Repeat("a", 64), approvedAt, expiresAt, + ) + if first == second { + t.Fatal("length-delimited expiring approval evidence fields produced an ambiguous digest") + } + }) +} + func policyAuditTestEntry() policyAuditEntry { return policyAuditEntry{ sequence: 1, format: policyAuditFormatVersion, diff --git a/internal/hubdb/postgres_integration_test.go b/internal/hubdb/postgres_integration_test.go index 4cb9bad..f72766d 100644 --- a/internal/hubdb/postgres_integration_test.go +++ b/internal/hubdb/postgres_integration_test.go @@ -14,6 +14,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "net/url" "os" "os/exec" @@ -65,9 +66,49 @@ func TestPostgresRLSBackstop(t *testing.T) { ownerURL := databaseURL(adminURL, ownerRole, ownerPassword) owner := connectPostgres(t, ctx, ownerURL) defer owner.Close(context.Background()) + applyLegacyMigrations(t, ctx, owner, "0013_approval_grant_expiry.sql") + if err := pgx.BeginTxFunc(ctx, owner, pgx.TxOptions{}, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SELECT set_config('sith.workspace_id', 'workspace-legacy', true)`); err != nil { + return err + } + _, err := tx.Exec(ctx, ` + INSERT INTO sith.workspaces(id, name, tenant_key) + VALUES ('workspace-legacy', 'Legacy Workspace', 'legacy-display-key'); + INSERT INTO sith.approval_grants( + workspace_id, id, intent_id, proposer, approver, resolved_digest, approved_at + ) VALUES ( + 'workspace-legacy', 'LLLLLLLLLLLLLLLLLLLLLL', 'intent-legacy-upgrade', + 'user:legacy-operator', 'user:legacy-approver', + 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + statement_timestamp() - interval '1 hour' + ) + `) + return err + }); err != nil { + t.Fatalf("seed pre-0013 approval grant: %v", err) + } if err := Migrate(ctx, MigrationConfig{OwnerURL: ownerURL, ApplicationRole: appRole, AllowInsecureLocal: true}); err != nil { t.Fatalf("Migrate() error = %v", err) } + var legacyVersioned, legacyLifetimeFixed, legacyUnconsumed bool + if err := pgx.BeginTxFunc(ctx, owner, pgx.TxOptions{}, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `SELECT set_config('sith.workspace_id', 'workspace-legacy', true)`); err != nil { + return err + } + return tx.QueryRow(ctx, ` + SELECT evidence_version = 1, + expires_at = approved_at + interval '10 minutes', + consumed_at IS NULL + FROM sith.approval_grants + WHERE workspace_id = 'workspace-legacy' AND id = 'LLLLLLLLLLLLLLLLLLLLLL' + `).Scan(&legacyVersioned, &legacyLifetimeFixed, &legacyUnconsumed) + }); err != nil { + t.Fatalf("inspect upgraded legacy approval grant: %v", err) + } + if !legacyVersioned || !legacyLifetimeFixed || !legacyUnconsumed { + t.Fatalf("legacy approval upgrade = versioned:%t lifetime:%t unconsumed:%t", + legacyVersioned, legacyLifetimeFixed, legacyUnconsumed) + } if err := Migrate(ctx, MigrationConfig{OwnerURL: ownerURL, ApplicationRole: appRole, AllowInsecureLocal: true}); err != nil { t.Fatalf("idempotent Migrate() error = %v", err) } @@ -161,9 +202,11 @@ func TestPostgresRLSBackstop(t *testing.T) { VALUES ('workspace-b', 'https://idp.example', 'upstream:mallory', 'user:bob')`}, {name: "cloud identity binding", statement: "INSERT INTO sith.cloud_identity_bindings(workspace_id, provider, realm, upstream_subject, member_subject)\n\t\t\tVALUES ('workspace-b', 'aws', '222222222222', 'AROAX:mallory', 'user:bob')"}, {name: "approval grant", statement: `INSERT INTO sith.approval_grants( - workspace_id, id, intent_id, proposer, approver, resolved_digest, approved_at + workspace_id, id, intent_id, proposer, approver, resolved_digest, + evidence_version, approved_at, expires_at ) VALUES ('workspace-b', 'ZZZZZZZZZZZZZZZZZZZZZZ', 'intent-foreign', 'user:operator', - 'user:approver', 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', now())`}, + 'user:approver', 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 2, statement_timestamp(), statement_timestamp() + interval '10 minutes')`}, } for _, test := range foreignWrites { t.Run("foreign "+test.name+" write denied", func(t *testing.T) { @@ -630,7 +673,7 @@ func assertApprovalGrantIntegration( create := func(intentID, arguments string) (pep.ApprovalBinding, ApprovalGrantID) { t.Helper() binding := postgresApprovalBinding(t, intentID, "workspace-a", proposerA.Subject(), arguments) - identifier, err := database.CreateApprovalGrant(ctx, approverA, binding, now) + identifier, err := database.CreateApprovalGrant(ctx, approverA, binding) if err != nil { t.Fatalf("CreateApprovalGrant(%s) error = %v", intentID, err) } @@ -641,7 +684,20 @@ func assertApprovalGrantIntegration( } baseBinding, baseID := create("intent-250-base", "replicas=3") - if _, err := database.CreateApprovalGrant(ctx, approverA, baseBinding, now); !errors.Is(err, ErrApprovalGrantUnavailable) { + var baseApprovedAt, baseExpiresAt time.Time + var baseEvidenceVersion int16 + if err := admin.QueryRow(ctx, ` + SELECT approved_at, expires_at, evidence_version + FROM sith.approval_grants WHERE workspace_id = 'workspace-a' AND id = $1 + `, baseID).Scan(&baseApprovedAt, &baseExpiresAt, &baseEvidenceVersion); err != nil { + t.Fatalf("inspect expiring approval grant: %v", err) + } + if baseEvidenceVersion != approvalGrantEvidenceVersion || + baseExpiresAt.Sub(baseApprovedAt) != 10*time.Minute || + baseApprovedAt.Before(now.Add(-time.Minute)) || baseApprovedAt.After(time.Now().Add(time.Minute)) { + t.Fatalf("approval lifetime = approved %s expires %s evidence v%d", baseApprovedAt, baseExpiresAt, baseEvidenceVersion) + } + if _, err := database.CreateApprovalGrant(ctx, approverA, baseBinding); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("duplicate approval error = %v", err) } @@ -651,51 +707,109 @@ func assertApprovalGrantIntegration( "admin": testScope(t, "user:admin", "workspace-a", tenancy.RoleAdmin), } { if _, err := database.CreateApprovalGrant(ctx, scope, postgresApprovalBinding(t, - "intent-role-"+name, "workspace-a", proposerA.Subject(), name), now); !errors.Is(err, ErrApprovalGrantUnavailable) { + "intent-role-"+name, "workspace-a", proposerA.Subject(), name)); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("%s approval error = %v", name, err) } } selfScope := testScope(t, "user:self", "workspace-a", tenancy.RoleApprover) if _, err := database.CreateApprovalGrant(ctx, selfScope, postgresApprovalBinding( - t, "intent-self", "workspace-a", selfScope.Subject(), "self"), now); !errors.Is(err, ErrApprovalGrantUnavailable) { + t, "intent-self", "workspace-a", selfScope.Subject(), "self")); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("self approval error = %v", err) } staleScope := testScope(t, "user:stale-approver", "workspace-a", tenancy.RoleApprover) if _, err := database.CreateApprovalGrant(ctx, staleScope, postgresApprovalBinding( - t, "intent-stale-role", "workspace-a", proposerA.Subject(), "stale"), now); !errors.Is(err, ErrApprovalGrantUnavailable) { + t, "intent-stale-role", "workspace-a", proposerA.Subject(), "stale")); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("stale approver role error = %v", err) } if _, err := database.CreateApprovalGrant(ctx, approverA, postgresApprovalBinding( - t, "intent-missing-proposer", "workspace-a", "user:missing", "missing"), now); !errors.Is(err, ErrApprovalGrantUnavailable) { + t, "intent-missing-proposer", "workspace-a", "user:missing", "missing")); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("missing proposer membership error = %v", err) } - if _, err := database.CreateApprovalGrant(ctx, approverA, postgresApprovalBinding( - t, "intent-future-approval", "workspace-a", proposerA.Subject(), "future"), - now.Add(2*time.Minute)); !errors.Is(err, ErrApprovalGrantUnavailable) { - t.Fatalf("future approval time error = %v", err) - } wrongDigestBinding := postgresApprovalBinding(t, baseBinding.IntentID(), "workspace-a", proposerA.Subject(), "replicas=4") - if err := database.ConsumeApprovalGrant(ctx, proposerA, wrongDigestBinding, baseID, now.Add(time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { + if err := database.ConsumeApprovalGrant(ctx, proposerA, wrongDigestBinding, baseID); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("wrong digest consume error = %v", err) } wrongIntentBinding := postgresApprovalBinding(t, "intent-250-other", "workspace-a", proposerA.Subject(), "replicas=3") - if err := database.ConsumeApprovalGrant(ctx, proposerA, wrongIntentBinding, baseID, now.Add(time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { + if err := database.ConsumeApprovalGrant(ctx, proposerA, wrongIntentBinding, baseID); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("wrong intent consume error = %v", err) } - if err := database.ConsumeApprovalGrant(ctx, proposerB, baseBinding, baseID, now.Add(time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { + if err := database.ConsumeApprovalGrant(ctx, proposerB, baseBinding, baseID); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("foreign workspace consume error = %v", err) } - if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, "XXXXXXXXXXXXXXXXXXXXXX", now.Add(time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { + if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, "XXXXXXXXXXXXXXXXXXXXXX"); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("unknown approval consume error = %v", err) } - if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, baseID, now.Add(-time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { - t.Fatalf("pre-approval consume error = %v", err) + seedGrant := func(identifier ApprovalGrantID, binding pep.ApprovalBinding, evidenceVersion int16, approvedAt time.Time) { + t.Helper() + if _, err := admin.Exec(ctx, ` + INSERT INTO sith.approval_grants( + workspace_id, id, intent_id, proposer, approver, resolved_digest, + evidence_version, approved_at, expires_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, binding.WorkspaceID(), identifier, binding.IntentID(), binding.Proposer(), approverA.Subject(), + binding.ResolvedDigest(), evidenceVersion, approvedAt, approvedAt.Add(10*time.Minute)); err != nil { + t.Fatalf("seed approval grant %s: %v", identifier, err) + } + } + preApprovalBinding := postgresApprovalBinding( + t, "intent-299-pre-approval", "workspace-a", proposerA.Subject(), "time=before", + ) + expiredBinding := postgresApprovalBinding( + t, "intent-299-expired", "workspace-a", proposerA.Subject(), "time=expired", + ) + legacyBinding := postgresApprovalBinding( + t, "intent-299-legacy", "workspace-a", proposerA.Subject(), "evidence=legacy", + ) + preApprovalID := ApprovalGrantID("IIIIIIIIIIIIIIIIIIIIII") + expiredID := ApprovalGrantID("JJJJJJJJJJJJJJJJJJJJJJ") + legacyID := ApprovalGrantID("KKKKKKKKKKKKKKKKKKKKKK") + seedGrant(preApprovalID, preApprovalBinding, approvalGrantEvidenceVersion, time.Now().UTC().Add(time.Minute)) + seedGrant(expiredID, expiredBinding, approvalGrantEvidenceVersion, time.Now().UTC().Add(-10*time.Minute)) + seedGrant(legacyID, legacyBinding, 1, time.Now().UTC()) + + var refusalHeadBefore int64 + if err := admin.QueryRow(ctx, ` + SELECT last_sequence FROM sith.policy_audit_heads WHERE workspace_id = 'workspace-a' + `).Scan(&refusalHeadBefore); err != nil { + t.Fatalf("read approval audit head before temporal refusals: %v", err) + } + for name, attempt := range map[string]func() error{ + "before approval": func() error { + return database.ConsumeApprovalGrant(ctx, proposerA, preApprovalBinding, preApprovalID) + }, + "expired": func() error { + return database.ConsumeApprovalGrant(ctx, proposerA, expiredBinding, expiredID) + }, + "legacy evidence": func() error { + return database.ConsumeApprovalGrant(ctx, proposerA, legacyBinding, legacyID) + }, + } { + if err := attempt(); !errors.Is(err, ErrApprovalGrantUnavailable) { + t.Fatalf("%s approval consume error = %v", name, err) + } + } + var refusalHeadAfter int64 + var refusedConsumptions int + if err := admin.QueryRow(ctx, ` + SELECT last_sequence FROM sith.policy_audit_heads WHERE workspace_id = 'workspace-a' + `).Scan(&refusalHeadAfter); err != nil { + t.Fatalf("read approval audit head after temporal refusals: %v", err) + } + if err := admin.QueryRow(ctx, ` + SELECT count(*) FROM sith.approval_grants + WHERE workspace_id = 'workspace-a' AND id IN ($1, $2, $3) AND consumed_at IS NOT NULL + `, preApprovalID, expiredID, legacyID).Scan(&refusedConsumptions); err != nil { + t.Fatalf("inspect refused temporal approval rows: %v", err) + } + if refusalHeadAfter != refusalHeadBefore || refusedConsumptions != 0 { + t.Fatalf("temporal refusals changed audit head %d -> %d or consumed %d rows", + refusalHeadBefore, refusalHeadAfter, refusedConsumptions) } - if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, baseID, now.Add(time.Second)); err != nil { + if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, baseID); err != nil { t.Fatalf("exact approval consume error = %v", err) } - if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, baseID, now.Add(2*time.Second)); !errors.Is(err, ErrApprovalGrantUnavailable) { + if err := database.ConsumeApprovalGrant(ctx, proposerA, baseBinding, baseID); !errors.Is(err, ErrApprovalGrantUnavailable) { t.Fatalf("replayed approval consume error = %v", err) } @@ -706,7 +820,7 @@ func assertApprovalGrantIntegration( consumers.Add(1) go func() { defer consumers.Done() - results <- database.ConsumeApprovalGrant(ctx, proposerA, concurrentBinding, concurrentID, now.Add(time.Second)) + results <- database.ConsumeApprovalGrant(ctx, proposerA, concurrentBinding, concurrentID) }() } consumers.Wait() @@ -757,7 +871,7 @@ func assertApprovalGrantIntegration( t, "intent-252-create-rollback", "workspace-a", proposerA.Subject(), "rollback=create", ) readAndCorruptHead() - _, rollbackCreateErr := database.CreateApprovalGrant(ctx, approverA, rollbackCreateBinding, now) + _, rollbackCreateErr := database.CreateApprovalGrant(ctx, approverA, rollbackCreateBinding) restoreHead() if rollbackCreateErr == nil || errors.Is(rollbackCreateErr, ErrApprovalGrantUnavailable) { t.Fatalf("audit-failed create error = %v, want operational audit failure", rollbackCreateErr) @@ -774,14 +888,9 @@ func assertApprovalGrantIntegration( } rollbackConsumeBinding, rollbackConsumeID := create("intent-252-consume-rollback", "rollback=consume") - if err := database.ConsumeApprovalGrant( - ctx, proposerA, rollbackConsumeBinding, rollbackConsumeID, now.Add(2*time.Minute), - ); !errors.Is(err, ErrApprovalGrantUnavailable) { - t.Fatalf("future consume time error = %v", err) - } readAndCorruptHead() rollbackConsumeErr := database.ConsumeApprovalGrant( - ctx, proposerA, rollbackConsumeBinding, rollbackConsumeID, now.Add(time.Second), + ctx, proposerA, rollbackConsumeBinding, rollbackConsumeID, ) restoreHead() if rollbackConsumeErr == nil || errors.Is(rollbackConsumeErr, ErrApprovalGrantUnavailable) { @@ -798,13 +907,29 @@ func assertApprovalGrantIntegration( t.Fatalf("audit-failed approval consume retained timestamp %s", rollbackConsumedAt) } if err := database.ConsumeApprovalGrant( - ctx, proposerA, rollbackConsumeBinding, rollbackConsumeID, now.Add(time.Second), + ctx, proposerA, rollbackConsumeBinding, rollbackConsumeID, ); err != nil { t.Fatalf("consume after audit rollback error = %v", err) } + legacyTraceID, err := tracing.NewID() + if err != nil { + t.Fatalf("mint legacy approval audit trace: %v", err) + } + if err := database.InWorkspace(ctx, approverB, func(tx pgx.Tx) error { + return appendPolicyAuditEntryTx(ctx, tx, policyAuditEntry{ + format: approvalAuditFormatVersion, recordedAt: time.Now().UTC().Truncate(time.Microsecond), + traceID: legacyTraceID, workspaceID: "workspace-b", actor: approverB.Subject(), + role: approverB.Role(), action: tenancy.ActionApproveIntent, verb: approvalAuditVerb, + verdict: pep.VerdictAllow, reasonCode: approvalCreatedEventKind, + eventKind: approvalCreatedEventKind, evidence: "sha256:" + strings.Repeat("c", 64), + }) + }); err != nil { + t.Fatalf("append legacy format-2 approval fixture: %v", err) + } + bindingB := postgresApprovalBinding(t, "intent-250-b", "workspace-b", proposerB.Subject(), "region=west") - identifierB, err := database.CreateApprovalGrant(ctx, approverB, bindingB, now) + identifierB, err := database.CreateApprovalGrant(ctx, approverB, bindingB) if err != nil { t.Fatalf("CreateApprovalGrant(workspace B) error = %v", err) } @@ -829,20 +954,27 @@ func assertApprovalGrantIntegration( t.Fatalf("approval RLS isolation: %v", err) } - var canConsume, canMutateProposer, canDelete bool + var canConsume, canMutateProposer, canMutateExpiry, canMutateEvidenceVersion, canDelete bool if err := admin.QueryRow(ctx, ` SELECT has_column_privilege($1, 'sith.approval_grants', 'consumed_at', 'UPDATE'), has_column_privilege($1, 'sith.approval_grants', 'proposer', 'UPDATE'), + has_column_privilege($1, 'sith.approval_grants', 'expires_at', 'UPDATE'), + has_column_privilege($1, 'sith.approval_grants', 'evidence_version', 'UPDATE'), has_table_privilege($1, 'sith.approval_grants', 'DELETE') - `, appRole).Scan(&canConsume, &canMutateProposer, &canDelete); err != nil { + `, appRole).Scan( + &canConsume, &canMutateProposer, &canMutateExpiry, &canMutateEvidenceVersion, &canDelete, + ); err != nil { t.Fatalf("inspect approval privileges: %v", err) } - if !canConsume || canMutateProposer || canDelete { - t.Fatalf("approval privileges = consume:%t mutate-proposer:%t delete:%t", canConsume, canMutateProposer, canDelete) + if !canConsume || canMutateProposer || canMutateExpiry || canMutateEvidenceVersion || canDelete { + t.Fatalf("approval privileges = consume:%t proposer:%t expiry:%t evidence-version:%t delete:%t", + canConsume, canMutateProposer, canMutateExpiry, canMutateEvidenceVersion, canDelete) } for name, statement := range map[string]string{ - "mutate proposer": `UPDATE sith.approval_grants SET proposer = 'user:mallory' WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, - "delete grant": `DELETE FROM sith.approval_grants WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, + "mutate proposer": `UPDATE sith.approval_grants SET proposer = 'user:mallory' WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, + "mutate expiry": `UPDATE sith.approval_grants SET expires_at = expires_at + interval '1 minute' WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, + "mutate evidence version": `UPDATE sith.approval_grants SET evidence_version = 1 WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, + "delete grant": `DELETE FROM sith.approval_grants WHERE id = 'GGGGGGGGGGGGGGGGGGGGGG'`, } { err := database.InWorkspace(ctx, proposerA, func(tx pgx.Tx) error { _, execErr := tx.Exec(ctx, statement) @@ -860,7 +992,7 @@ func assertApprovalGrantIntegration( rows, err := admin.Query(ctx, ` SELECT event_kind, evidence_digest, actor, role, action, verb, verdict, reason_code FROM sith.policy_audit_entries - WHERE workspace_id = 'workspace-a' AND format_version = 2 + WHERE workspace_id = 'workspace-a' AND format_version = 3 ORDER BY sequence `) if err != nil { @@ -919,8 +1051,9 @@ func assertApprovalGrantIntegration( if err != nil { t.Fatalf("mixed-version workspace B export error = %v", err) } - if len(mixedExport.Entries) != 2 || mixedExport.Entries[0].FormatVersion != policyAuditFormatVersion || - mixedExport.Entries[1].FormatVersion != approvalAuditFormatVersion { + if len(mixedExport.Entries) != 3 || mixedExport.Entries[0].FormatVersion != policyAuditFormatVersion || + mixedExport.Entries[1].FormatVersion != approvalAuditFormatVersion || + mixedExport.Entries[2].FormatVersion != approvalExpiryAuditFormatVersion { t.Fatalf("mixed-version workspace B export shape = %#v", mixedExport) } if err := mixedExport.Verify(); err != nil { @@ -983,6 +1116,48 @@ func newPolicyAuditEvent(t *testing.T, scope tenancy.Scope) pep.AuditEvent { } } +func applyLegacyMigrations( + t *testing.T, + ctx context.Context, + owner *pgx.Conn, + stopBefore string, +) { + t.Helper() + if _, err := owner.Exec(ctx, ` + CREATE SCHEMA sith_meta; + REVOKE ALL ON SCHEMA sith_meta FROM PUBLIC; + CREATE TABLE sith_meta.schema_migrations ( + version text PRIMARY KEY, + checksum bytea NOT NULL, + applied_at timestamptz NOT NULL DEFAULT transaction_timestamp() + ) + `); err != nil { + t.Fatalf("initialize legacy migration ledger: %v", err) + } + entries, err := fs.ReadDir(migrationFiles, "migrations") + if err != nil { + t.Fatalf("read legacy migration fixtures: %v", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") || entry.Name() >= stopBefore { + continue + } + migration, err := fs.ReadFile(migrationFiles, "migrations/"+entry.Name()) + if err != nil { + t.Fatalf("read legacy migration %s: %v", entry.Name(), err) + } + if _, err := owner.Exec(ctx, string(migration)); err != nil { + t.Fatalf("apply legacy migration %s: %v", entry.Name(), err) + } + checksum := sha256.Sum256(migration) + if _, err := owner.Exec(ctx, ` + INSERT INTO sith_meta.schema_migrations(version, checksum) VALUES ($1, $2) + `, entry.Name(), checksum[:]); err != nil { + t.Fatalf("record legacy migration %s: %v", entry.Name(), err) + } + } +} + func startPostgres(t *testing.T) string { t.Helper() docker := os.Getenv("DOCKER_BIN") @@ -1087,12 +1262,15 @@ func seedTenantRows(t *testing.T, ctx context.Context, admin *pgx.Conn) { "('workspace-a', 'aws', '111111111111', 'AROAX:alice', 'user:alice'),\n" + "('workspace-b', 'aws', '222222222222', 'AROAX:bob', 'user:bob')", `INSERT INTO sith.approval_grants( - workspace_id, id, intent_id, proposer, approver, resolved_digest, approved_at + workspace_id, id, intent_id, proposer, approver, resolved_digest, + evidence_version, approved_at, expires_at ) VALUES ('workspace-a', 'GGGGGGGGGGGGGGGGGGGGGG', 'intent-seed-a', 'user:seed-operator', - 'user:seed-approver', 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', now()), + 'user:seed-approver', 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 1, statement_timestamp(), statement_timestamp() + interval '10 minutes'), ('workspace-b', 'HHHHHHHHHHHHHHHHHHHHHH', 'intent-seed-b', 'user:seed-operator', - 'user:seed-approver', 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', now())`, + 'user:seed-approver', 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 1, statement_timestamp(), statement_timestamp() + interval '10 minutes')`, } for _, statement := range statements { if _, err := admin.Exec(ctx, statement); err != nil { diff --git a/sessions/2026-07-21-approval-grant-expiry.md b/sessions/2026-07-21-approval-grant-expiry.md new file mode 100644 index 0000000..1e3120a --- /dev/null +++ b/sessions/2026-07-21-approval-grant-expiry.md @@ -0,0 +1,65 @@ +# E5 F5.9b — immutable approval-grant expiry + +**Builder:** gnanirahulnutakki · **Effort:** deep · **Branch:** +`gnanirahulnutakki/approval-expiry-20260721` +**Slice(s):** E5 / F5.9b · #299 · **Status:** ready for review + +--- + +[G] Goal: give every new approval grant one immutable, server-enforced 10-minute absolute +lifetime while preserving exact single-use, tenant isolation, privacy-minimized evidence, and +offline audit verification. + +[D] Decision: PostgreSQL statement time is the only approval and consumption clock. The public +approval API accepts no timestamp, and the one conditional consumption update enforces the +half-open `approved_at <= consumed_at < expires_at` interval. Expired, legacy, unknown, foreign, +mismatched, and replayed grants share `ErrApprovalGrantUnavailable`. + +[D] Decision: new rows use evidence version 2 and audit format 3. The evidence digest uses a new +domain and binds `expires_at`; the audit entry hash also uses a distinct format-3 domain. Existing +format-1 policy records and format-2 approval records remain independently rehashable. + +[A] Action: added migration 0013 with immutable `expires_at = approved_at + interval '10 minutes'` +and evidence-version constraints. Legacy rows are retained, backfilled as evidence version 1, and +excluded from the new consume predicate. Their `consumed_at` value is not fabricated. + +[A] Action: the legacy backfill temporarily removes FORCE RLS only inside the serializable +migration transaction. PostgreSQL holds an access-exclusive table lock, FORCE RLS is restored +immediately after the update, and any error rolls back the entire relaxation. The application +role remains unable to update expiry/evidence fields or delete rows. + +[T] Test: pure Go unit tests and PostgreSQL-tag compilation pass. The real digest-pinned PostgreSQL +18.4 race suite proves an incremental 0012-to-0013 migration, fixed lifetime, legacy invalidation, +pre-approval/expiry refusal without row or audit mutation, one-winner concurrent consumption, +audit rollback, forced RLS, immutable columns, and mixed format-1/2/3 offline verification. + +[T] Test: focused race suites and two 50,000-execution fuzz campaigns cover expiry-evidence framing +and portable format-3 chain integrity. Full `make ci` passes formatting, vet, lint, reachable +vulnerability scanning, all repository race tests, operator policy checks, performance budget, +subprocess E2E, and build. The real isolation gate reaches 76.7% `hubdb` coverage and adds two +100,000-execution tenant-isolation fuzz campaigns. + +[T] Test: `make release-check` passes two reproducible builds, four platform archives, SPDX SBOMs, +Homebrew generation, and the two-platform distroless OCI layout. The pinned real two-cluster kind +suite passes in 242.456 seconds. CodeRabbit CLI 0.6.5 found one minor fixture-clarity issue; the +format-3 fixture now uses a real expiry-bound golden digest, and the second full review reports no +findings. + +[S] Scope boundary: no MCP transport, Ardur PDP, multi-approver policy, configurable lifetime, +renewal, credential minting, connector execution, dispatch, shell/filesystem access, generic apply, +or production mutation is introduced. + +[C] Cost: one timestamp and one small version field per grant, with no new service, queue, poller, +egress, or cloud resource. The consume path remains one indexed transactional update. + +[R] Primary references: +- https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation +- https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html +- https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2 +- https://www.postgresql.org/docs/current/sql-update.html +- https://www.postgresql.org/docs/current/functions-datetime.html +- https://www.postgresql.org/docs/current/ddl-rowsecurity.html + +--- + +**Session close:** ready for review · **Open questions touched:** renewal remains outside F5.9b