feat(requests): patch-request lifecycle — submit / approve / reject / cancel#16
Merged
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)Lifecycle invariants
WrapService.WrapAES-256-GCM + canary testRequestBodyexposes onlytarget_keys(names)ErrDuplicateVote→ HTTP 409wf.AllowSelfApprovalper-workflow toggle (defaultfalse)WrapTTLApproved(default 1h); reject/cancel → 5 minactorIDtoreq.RequesterIDCorrelationID = request.IDworkflow_idset on the request row inSubmitSchema changes (migration 0006)
access_requestswithworkflow_id,target_provider_type|config|secret_ref|keys|scope,job_id,reject_reasonsecret_mapping_idnullable (patch flow doesn't need a mapping; legacy fields stay reserved)'patch'to type CHECK,'cancelled'to status CHECK(type='patch' ⇒ workflow_id + target_provider_type + target_secret_ref NOT NULL)UNIQUE INDEX approvals_one_decision_per_approveron(request_id, approver_id)Test plan
go build / vet ./...clean-p 1secret_wrapsfor canary plaintext returns 0 matches (envelope encryption confirmed)Followups (out of scope)
GET /agents/:id/wraps/:wrap_idunderAgentAuth)ProviderExecutorthat consumes wraps + writes to providers