Skip to content

feat(requests): patch-request lifecycle — submit / approve / reject / cancel#16

Merged
haydercyber merged 1 commit into
mainfrom
feat/request-approval-flow
May 28, 2026
Merged

feat(requests): patch-request lifecycle — submit / approve / reject / cancel#16
haydercyber merged 1 commit into
mainfrom
feat/request-approval-flow

Conversation

@haydercyber

Copy link
Copy Markdown
Contributor

Summary

Piece 3b of three. Lands the UI-driven secret-update vertical: a developer proposes new values for one or more keys inside an existing provider secret, the plaintext is envelope-encrypted on the way in, the request walks the dynamic workflow + policy engine from Piece 2 (#14), and approvers vote until the workflow threshold is crossed.

The wire shape carries only key NAMES — secret values never leave the wrap layer.

Endpoints (under /api/v1)

POST   /requests                  submit a patch request
GET    /requests                  list (filter: requester_id, status)
GET    /requests/:id              get one + its approvals inline
POST   /requests/:id/approve      cast approve vote
POST   /requests/:id/reject       cast reject vote with reason
POST   /requests/:id/cancel       requester withdraws

Lifecycle invariants

Invariant How enforced
Plaintext never lands in Postgres outside the wrap envelope WrapService.Wrap AES-256-GCM + canary test
Plaintext never appears in audit metadata service audits byte_length + SHA-256 hash only
Plaintext never appears in API responses RequestBody exposes only target_keys (names)
One vote per approver per request schema UNIQUE INDEX + service pre-check + 23505 mapping → ErrDuplicateVote → HTTP 409
Requester cannot self-approve unless workflow opts in wf.AllowSelfApproval per-workflow toggle (default false)
TTL refresh on state transitions approve → WrapTTLApproved (default 1h); reject/cancel → 5 min
Only the original requester can cancel service compares actorID to req.RequesterID
Every state change emits an audit event with CorrelationID = request.ID submit / approve / reject / cancel / refresh_ttl_failed
Workflow captured at submit time, not at approve time workflow_id set on the request row in Submit

Schema changes (migration 0006)

  • Extend access_requests with workflow_id, target_provider_type|config|secret_ref|keys|scope, job_id, reject_reason
  • Make secret_mapping_id nullable (patch flow doesn't need a mapping; legacy fields stay reserved)
  • Add 'patch' to type CHECK, 'cancelled' to status CHECK
  • CHECK (type='patch' ⇒ workflow_id + target_provider_type + target_secret_ref NOT NULL)
  • UNIQUE INDEX approvals_one_decision_per_approver on (request_id, approver_id)

Test plan

  • go build / vet ./... clean
  • 9 new integration tests pass (live Postgres)
  • Full suite green with -p 1
  • Live e2e curl: submit → self-approve 403 → approve → dup-vote 409 → reject with reason → cancel by stranger 403 → cancel by requester
  • Bytea position search across secret_wraps for canary plaintext returns 0 matches (envelope encryption confirmed)
  • CI green

Followups (out of scope)

  • Piece 3c: agent wrap retrieval endpoint (GET /agents/:id/wraps/:wrap_id under AgentAuth)
  • Piece 4: agent ProviderExecutor that consumes wraps + writes to providers
  • Pieces 5-6: UI scaffold + dev/approver pages

… cancel

The second of three Piece-3 sub-PRs. Lands the UI-driven secret-update
vertical: a developer proposes new values for one or more keys inside an
existing provider secret; the plaintext is envelope-encrypted on the way
in, the request walks the dynamic workflow + policy engine from Piece 2,
and approvers vote until the workflow threshold is crossed.

Schema (migration 0006):
- Extend access_requests with workflow_id, target_provider_type|config|
  secret_ref|keys|scope, job_id, reject_reason.
- Make secret_mapping_id nullable so the patch flow doesn't need a
  pre-existing mapping; legacy sync-mapping fields stay reserved.
- Add 'patch' to type CHECK, 'cancelled' to status CHECK, plus a CHECK
  enforcing that patch rows carry workflow_id + target_provider_type +
  target_secret_ref so a sloppy service-layer caller can't insert a
  half-formed row.
- UNIQUE INDEX on approvals(request_id, approver_id) makes duplicate-
  vote detection race-free under concurrent voters.

Storage:
- pkg/storage/access_requests.go — Create/Get/List(filter)/UpdateStatus/
  SetJobID/SetRejectReason.
- pkg/storage/approvals.go — Append/ListByRequest/Counts; Append maps
  unique-violation 23505 → ErrApprovalExists.
- pkg/storage/secret_wraps.go — ListIDsForRequest for bulk TTL refresh.

Services:
- internal/services/wraps.go — exposes ListIDsForRequest on WrapService.
- internal/services/requests.go — RequestService orchestrates:
  Submit  → PolicyEngine.Resolve → create row → WrapService.Wrap per key
            → audit (workflow id + key count, never values)
  Approve → separation-of-duties check → vote → on threshold crossing,
            UpdateStatus(approved) + refresh wraps to WrapTTLApproved
  Reject  → vote + SetRejectReason + UpdateStatus(rejected) + shrink
            wrap TTL to 5 min
  Cancel  → requester-only + shrink wrap TTL

Handlers (under /api/v1):
  POST   /requests
  GET    /requests       (filter: requester_id, status)
  GET    /requests/:id   (with approvals inline)
  POST   /requests/:id/approve
  POST   /requests/:id/reject
  POST   /requests/:id/cancel

Wiring: cmd/api/main.go now boots WrapService, PolicyEngine,
RequestService, and the requests handler. Adds keymgmt.NewLocalKMSFromEnv
to the bootstrap — fail-fast if SB_WRAP_MASTER_KEY is missing.

Tests: 9 new integration tests (live Postgres) cover happy path,
plaintext-not-in-DB canary, self-approval denial, duplicate vote,
multi-approver threshold, reject-with-reason, cancel by stranger vs.
requester, TTL refresh on approval, and input validation. Full suite
remains green with -p 1.

Live e2e curl walkthrough confirms:
- submit returns 201 with no values, workflow auto-resolved from policy
- self-approve gives 403
- second-vote-same-approver gives 409
- approve transitions pending → approved
- reject with reason persists, status → rejected
- cancel by non-requester gives 403
- canary plaintext bytea-position-search across all wrap rows = 0 matches
@haydercyber
haydercyber merged commit 3426829 into main May 28, 2026
4 checks passed
@haydercyber
haydercyber deleted the feat/request-approval-flow branch May 28, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant