Repository navigation
Conversation
…is on The stateless admin API key has no user UUID, so resolveCreatorUserID stamped NULL onto every pending execution it created. Under four-eyes, requireDifferentApprover and checkDifferentApprover deny a NULL-creator row that is not created_by_system for every approver, so those rows could only expire (and a direct execute was refused after the row was written). Per the issue's option (b), executePurchase (both the approval-email and direct-execute paths) and createPlannedPurchases now refuse such a request with a 403 while four-eyes is on, before anything is written. The retry successor gets the same guard as defense in depth. Sessions with a user UUID, including per-user API keys, are unaffected and skip the config read. A config-load failure denies (fail closed). No approval check is relaxed. Closes LeanerCloud#677 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
Comment |
|
VERDICT: APPROVE 14f3dd7 Base: merge-base 8caed39; Findings
Entry points checked (merged tree)
Local results (merged tree 2a595802, macOS)
Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 14f3dd7 Findings (none blocking):
Against #677 option (b):
Attacked:
Verified locally, on
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
Summary
The stateless admin API key's session
UserIDis the sentineladmin-api-key, not a user UUID.resolveCreatorUserIDtherefore stampscreated_by_user_id = NULLonto every pending execution the key creates, andcreated_by_systemstays false. Under four-eyes (require_different_approver), both gates deny such a row for every approver:requireDifferentApproverin the handler;checkDifferentApproverin the manager, which runs on session approve, token approve, SQS approve, run-now and direct execute.So the row could only sit pending until it expired. On the direct-execute path the row was written first and then refused, which left a stuck pending row behind.
Merged PRs #989 and #1071 both reference this issue, but neither fixes it:
created_by_systemfor sweep rows. The owner decision on fix(purchase): scheduler-created executions have no creator and cannot pass four-eyes #233 explicitly kept API-key rows denied.Fix: option (b) from the issue
The issue offered (a) a stable non-UUID creator identity or (b) refusing creation. This PR takes (b), because it is fail-closed and changes no approval rule.
New guard:
requireAttributablePurchaseCreator(internal/api/handler_purchases.go). It refuses the request with a 403 when:The error tells the caller to sign in or use a per-user API key. Per-user API keys already carry the owner's UUID, so they are compared normally under four-eyes.
Fail-closed behavior:
requireDifferentApprover.Where the guard runs. Each call site refuses before anything is written:
executePurchase, throughvalidateExecutePurchaseRequest→requirePurchaseCreatingPermission. This covers both the approval-email path andexecute_mode: "direct".createPlannedPurchases(POST /api/plans/{id}/purchases), through the same wrapper.persistRetryExecution, where the retry successor's creator is stamped. This one is defense in depth: the retry route requires a bearer session, so the admin key gets a 401 there today. The issue's "retry re-drive" path is therefore not reachable with the admin key. The guard still covers any session type that would stamp NULL on the successor. A creator-less successor would also be bad for a second reason: the linkage written in the same transaction blocks retrying the original again.No relaxed checks. No approval check is relaxed, and the key is never stamped with a human creator, as the issue required.
openapi.yamldocuments the new 403 on both endpoints.Behavior change to note
With four-eyes on, the admin API key can no longer submit purchases or schedule planned purchases.
If you'd rather keep scheduling via the admin key for AutoPurchase plans, I can narrow that call site.
Concurrency
There is no schema or DB change, and the guard only reads. One race is possible: an admin turns four-eyes on between the guard and the insert. The row then carries a NULL creator and is denied like a legacy row. That fails closed, exactly as on main today, so no new exposure.
Verification
All Go commands ran on macOS with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's local AWS config profile is broken. The evidence is unit tests with the repo'sMockConfigStore/MockAuthService, driving the real handlers end to end from the HTTP request. No real cloud calls, purchases, email or deploys were made, and there is no deployed-environment or Postgres coverage. The change has no SQL.New tests in
internal/api/four_eyes_unattributed_creator_test.go:Refusal tests:
TestExecutePurchase_AdminAPIKey_FourEyesOn_RefusedBeforeAnyWritecovers the approval-email and direct bodies.TestCreatePlannedPurchases_AdminAPIKey_FourEyesOn_RefusedBeforeAnyWrite.TestRetryPurchase_UnattributedSession_FourEyesOn_RefusedBeforeAnyWrite.Each asserts the 403 and that
WithTx,SavePurchaseExecution(Tx)andLinkRetryExecutionAtomicwere not called.Mode-off controls (
*_FourEyesOff_ReachesPersistence): they assert that the request gets past the guard to the write, using a sentinelWithTxerror.TestRequireAttributablePurchaseCreator:Fail-before. I reverted only the three call sites and kept the helper so the tests compile. Each
*_FourEyesOn_*test then fails:SavePurchaseExecutionTxwithCreatedByUserID:nil. That is the bug, caught as a mock panic on the unexpected write."plan must be enabled..." does not contain "no attributable user").The mode-off controls pass on both versions.
Pass-after:
go test ./internal/api/ -run 'AdminAPIKey_FourEyes|UnattributedSession_FourEyes|TestRequireAttributablePurchaseCreator' -count=1gives ok.Mutation probes: 6 of 6 killed.
DirectExec_FourEyesOn_DeniesSelfExecute.Other checks:
go build ./...passes.go vet ./internal/api/andgo vet -tags integration ./internal/api/pass.go test ./internal/api/ ./internal/mocks/ ./internal/server/ -count=1gives all ok. That includesTestNoUnfailableMockAssertions.run --allow-parallel-runners ./internal/api/...reports 0 issues, with and without--build-tags integration.gocyclo -over 10 -ignore '_test\.go' internal/api/prints nothing.executePurchaseandvalidateExecutePurchaseRequestwere already at 10, which is why the guard sits in arequirePermissionwrapper.setup-git-secrets.shpatterns plus.gitallowed, in a freshgit initrepo with an isolated HOME) on the changed files: clean.scripts/test-git-secrets-allowlist.shreports 42 PASS, 0 FAIL.openapi.yamlparses, andgit diff --checkis clean.Out of scope
Closes #677
Maintainer: please mirror the issue's labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/m,type/bug,triaged.🤖 Generated with Claude Code