Repository navigation
fix(config): resolve arming grants before the global-config transaction - #1519
Conversation
PUT /api/config and PUT /api/ri-exchange/config checked execute:purchases and execute:ri-exchange inside the UpdateGlobalConfigAtomic closure. That closure runs while the transaction holds a pooled connection, and the permission lookup reads the auth store through the same pool, so as many concurrent arming saves as the pool has connections each held one and waited forever for a second. Both permission outcomes are now resolved through requirePermission before the transaction opens. The gates inside the closure still decide escalation against the advisory-locked row and only read the snapshot; a lookup error is raised only when the write escalates, as before. Closes LeanerCloud#1517 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF10. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Root cause
UpdateGlobalConfigAtomicruns itsapplyclosure inside a transaction that holds one pooled connection for the whole closure, plus the global_config advisory lock. Two handlers ran a permission lookup from inside that closure when a save escalated:PUT /api/config(updateConfig):requireRIExchangeAutoModeGrant(execute:ri-exchange) andrequireSwitchGrants→requireAutoPurchaseGrant(execute:purchases, for both the auto-purchase and ladder-execution switches).PUT /api/ri-exchange/config(updateRIExchangeConfig):requireRIExchangeAutoModeGrant.Each of those called
requirePermission, which reads the auth store through the same pool and so needs a second connection. With N concurrent arming saves on a pool of N, the lock holder is waiting for a connection and the other N-1 hold connections while they wait on the advisory lock in Postgres. Every save hangs until its deadline. Fargate's default pool is 3 and Cloud Run's is 6. The issue names the global-config PUT. The dedicated RI-exchange PUT uses the same closure and gate, so it gets the same fix here.Fix
This follows #1509's approach: fetch before the transaction and check inside it against the snapshot.
internal/api/auto_purchase_grant.go: newarmingGrants/resolveArmingGrants/resolveGrant. They resolve therequirePermission(execute, purchases)andrequirePermission(execute, ri-exchange)outcomes (nil means granted, otherwise the exact error requirePermission returned) before the transaction opens.requireAutoPurchaseGrantis split so its credential half (refuseAutoPurchaseCredential: a nil session or the stateless admin API key is refused) can be reused in the closure throughprefetchedAutoPurchaseGrant. Plan and ladder-config callers ofrequireAutoPurchaseGrantdon't run inside a transaction and are unchanged.requireAutoPurchaseSwitchGrant,requireLadderExecutionSwitchGrant,requireSwitchGrantsandrequireRIExchangeAutoModeGrantare now plain functions that take the pre-resolved grant instead of(h, req). They have no way to reach the pool.updateConfigresolves both grants afterrequireGlobalConfigScope, so a caller refused by scope still costs no extra lookups.updateRIExchangeConfigresolves only execute:ri-exchange.The grant checks are exactly as strict as before.
requirePermissioncall on the same request, so a user API key is still held to the key's effective permissions and the admin API key is still refused for the purchase switches.Why the snapshot is safe (TOCTOU). The permission is now read a little earlier: before the transaction opens, rather than after the advisory lock is acquired. A revocation that commits in that gap is honored on the caller's next request, not this one. The old code gave no stronger guarantee:
update:configcheck and therequireGlobalConfigScopecheck that gate this same write have always run before the transaction.So the window grows only by the time spent waiting for the global_config advisory lock. That lock is held by short read-modify-write transactions, and no security property depended on that wait. A tx-local re-check isn't possible without a second pool connection, because the auth service has no transaction-scoped API, and that would recreate the bug. So I didn't add one.
Cost: every
PUT /api/confignow does two extraHasPermissionAPIlookups up front, even when it arms nothing. Previously they ran only when the save escalated. This is the price of not touching the pool inside the closure. The alternative was an abort-and-retry closure that resolves the grant lazily, which was more complex for a low-frequency settings endpoint.Closes #1517
Verification
All commands were run on
922b8e22withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true:go build ./...,go vet ./...,go vet -tags integration ./...: cleango test -count=1 ./internal/api/: ok.go test -count=1 ./internal/mocks/: okgolangci-lint run --allow-parallel-runners ./internal/api/, with and without--build-tags integration: 0 issuesgocyclo -over 10 -ignore "_test\.go"on the five changed non-test files: no outputReal-Postgres regression test (testcontainers), fail-before / pass-after. The test is
TestGlobalConfigArmingSavesDoNotExhaustPoolininternal/api/handler_config_arming_pool_integration_test.go. The config store, the realauth.Serviceand itsauth.PostgresStoreall share one pool of 3 connections (the Fargate default), wired the wayinternal/server/app.gowires them. The session user holds update:config, execute:purchases and execute:ri-exchange. Each subtest fires 12 concurrent saves (4× the pool) with a 10 s deadline. Arming and disarming bodies alternate so that many lock holders need the grant, not only the first.ec78d85c(before)context deadline exceeded(poolAcquiretimeouts while other saves sit inpg_advisory_xact_lock)The after run was also repeated with
-count=3: all pass.TestGlobalConfigArmingSaveGrantStaysStrictruns on the same real auth store. A session with only view/update:config is refused (403) on all four arming saves, through both routes. An ordinary save goes through, and nothing armed lands. It passes both before and after, which shows strictness didn't change. The existingTestConfigInfrastructureKeyHTTP(admin API key writes through both routes) also passes.Unit tests (mock evidence) are in
internal/api/global_config_arming_grants_test.go:TestGlobalConfigArmingGates_NoAuthCallInsideTransactionuses a store double whoseUpdateGlobalConfigAtomiccounts auth-service calls made whileapplyruns. Before the fix, all 8 arming cases (granted and refused, for auto-purchase, ladder execution, RI exchange via /api/config, and RI exchange via the dedicated route) fail with non-zero in-transaction auth calls. The ordinary-save case passes. After the fix, all pass with 0 calls and the same 200/403 outcomes.TestGlobalConfigArmingGates_LookupErrorOnlyMattersOnEscalation: an execute-verb lookup that errors doesn't fail an ordinary save. It does fail (fail closed, no write) every escalating save on both routes.auto_purchase_grant_test.go,ri_exchange_automode_gate_test.go,handler_config_test.go, etc.) pass unchanged, covering the grant matrix through the router with session, user API key and admin API key credentials.Combined-tree verification at local queue commit
ccab8379= origin/mainec78d85c+fix/issue-1488(#1509),fix/issue-316-next(#1516),fix/issue-1517andfix/issue-1518merged in that order. Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/api,internal/commitmentopts,internal/configandinternal/database/postgres/migrations; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Notes / out of scope
git merge-treeagainst its head). Both touchhandler_config.go, but in different functions.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p3,type/bug(the issue has notriagedlabel).🤖 Generated with Claude Code