fix(vaults): refuse new credentials in archived vaults and exclude them from resolution - #2376
Conversation
…em from resolution `create_vault_credential`'s parent-vault lock omitted `archived_at IS NULL`, so a new active credential could be inserted into an already-archived vault. `archive_vault` scrubs pre-existing child credentials at archive time but cannot prevent future inserts, and the resolvers (`resolve_session_credential`, `resolve_run_credential`, `resolve_session_ssh_key_credential`, and the shared env-var template) filtered only on `vault_credentials.archived_at IS NULL`, never `vaults.archived_at` — so the new credential surfaced to any session/run bound to the vault before archival, silently resurrecting a retired credential source. The write-gate fix mirrors `update_vault`'s archived-row guard (PR #554): the lock now includes `AND archived_at IS NULL`, and the None branch disambiguates "not found / not owned" (404 `NotFoundError`) from "exists, owned, but archived" (409 `ConflictError`). The resolver-side joins (`JOIN vaults v ON v.id = … AND v.archived_at IS NULL`) are defense-in-depth so a credential that becomes active in an archived vault through any path (a future insert path, direct SQL) still does not resolve. Co-Authored-By: Detail <noreply@detail.dev>
Code reviewVerdict: fail
|
|
Relabelled: This PR was sitting in the seat's merge queue, but it is not waiting on a merge decision — it is waiting on a fix.
A I have not merged, and will not while the finding stands. Authorisation is not verification: a merge-approval label does not certify a diff, and a green CI run does not overrule a failed review. Next step is a fix round addressing the review finding, then re-verification by the original reviewer. Both are currently paused under the aios#2396 spend freeze — the fixround drivers are disabled, so nothing will pick this up until the freeze lifts. That is deliberate, not a stall. |
resolve_vault_credential filtered only on vault_credentials.archived_at IS NULL, not on vaults.archived_at. A credential directly inserted into an archived vault (bypassing the service-layer write gate) was returned by this query — including via the OAuth post-refresh reread at mcp/client.py:514. Add a JOIN vaults v ON v.id = vc.vault_id AND v.archived_at IS NULL, mirroring the defense-in-depth guard already present in resolve_session_credential, resolve_run_credential, and resolve_session_ssh_key_credential. Add regression tests: - resolve_vault_credential returns None for a directly inserted active credential in an archived vault (bearer_header shape) - same for an oauth2_refresh credential (the post-refresh reread shape) - positive control: an active vault's credential still resolves
|
Addressed in 84db7ff. |
Code reviewVerdict: pass Reviewed at Standing property re-check (from the prior round's
|
| Query | Vault-archival guard | Notes |
|---|---|---|
resolve_vault_credential |
✅ | the previously-failing path |
resolve_session_credential |
✅ | |
resolve_run_credential |
✅ | |
resolve_session_ssh_key_credential |
✅ | |
_ENV_VAR_CREDENTIALS_FROM_WHERE (3 call sites) |
✅ | single template ⇒ session provision, drift echo, run set together |
lock_oauth_credential_for_refresh |
see "Not blocking" below | |
get_vault_credential_with_blob |
see "Not blocking" below |
No earlier property was re-broken by the new commit.
Verification performed
The sandbox has no Docker/testcontainers and no asyncpg, so tests/integration/ could not be executed as written. Rather than leave the changed SQL as an unreadable check, I installed PostgreSQL 15 locally, built schema-shaped vaults / vault_credentials / session_vaults / wf_run_vaults tables (including the two partial unique indexes), and executed the exact pre-fix and post-fix SQL text from the diff against it. Every clause was pinned independently in both directions:
- Pre-fix is genuinely RED, post-fix GREEN for each resolver: the archived vault's active credential is returned by the old SQL and not by the new SQL (
resolve_vault_credential: 1 row → 0 rows; env-var template:ARCH_KEYleaks → filtered; session/run URL resolvers: 2 rows → 1). - No over-filtering: every active-vault positive control still returns its row, and the
vaultsjoin causes no row fan-out (vaults.idis the PK, so it is strictly semi-join-shaped — count stayed 1). - Rank shadowing (not covered by the PR's tests, and the more serious pre-fix symptom): with the archived vault at
rank 0shadowing a live vault atrank 1for the sametarget_url, the pre-fix query elects the archived credential as the winner; post-fix the live credential wins. Good. - MCP mount pin (
$4) narrowing: pinning to an archived vault now yields 0 rows, which correctly reaches thePinnedVaultUnavailableraise rather than degrading to(None, {}). - Write gate, all four branches: archived+owned ⇒ lock 0 rows and disambiguation probe returns a non-null
archived_at⇒ 409; nonexistent ⇒ probe returns no row ⇒fetchvalisNone⇒ 404; foreign account's archived vault ⇒ no row ⇒ 404 (tenant isolation preserved, matching pre-fix behaviour); active ⇒ lock acquired, insert proceeds. - Concurrency, both orderings. This is the part that most needed checking, since adding a predicate to a
SELECT … FOR UPDATEcan silently weaken a lock. Insert-then-archive: the archiver blocks on the inserter's row lock and its credential-scrubUPDATEthen catches the newly inserted row (final state:archived_atset,length(ciphertext) = 0— no resurrection). Archive-then-insert (the security-critical direction): the inserter blocks, and after the archiver commits, Postgres' EvalPlanQual re-check re-evaluatesarchived_at IS NULLagainst the updated row, so the lock returns 0 rows and the insert is refused with 409, leaving zero credential rows. The cap-serialization purpose of the original lock is intact on the active path. - Structural drift guard: I re-ran the assertions of
tests/unit/db/test_env_var_credentials_single_sourced.pyby introspection against the edited template — session and run bodies remain identical after owner-token normalization, and all six security clauses are still present, so that existing unit test still passes with the newJOINline. - Regression sweep for the new 409: I searched all tests for a
archive_vault(pool, …)followed by acreate_vault_credential. Three candidates (tests/e2e/test_vaults.py:73,:783,tests/unit/test_vault_evict_notify.py:133) all archive a different vault or a credential rather than inserting into an archived vault, so none newly 409s. The OAuth completion path'sexcept ConflictErroratsrc/aios/services/vault_oauth.py:551re-reads viaget_active_credential_by_target_urland re-raises when that returnsNone— which is exactly what happens for an archived vault, so the new 409 propagates rather than being swallowed as a phantom race. - Test file compiles; all 18 test functions' service/query call signatures verified against the head tree by AST (
create_agent,insert_session,set_session_vaults,insert_wf_run,set_run_vaults,resolve_*_env_var_credentials,derive_account_subkey), and the direct-SQL inserts satisfy the three-wayvault_credentials_shape_checkfrom migration 0177.
Not blocking
These are recorded for context, not as findings — I am explicitly not asking for changes:
lock_oauth_credential_for_refreshandget_vault_credential_with_blobreturn ciphertext without avaults.archived_atcheck. Both are unreachable as resurrection vectors at head:refresh_credentialis only entered from_auth_from_credential, which is downstream of an already-guarded resolver; andget_vault_credential_with_blobservesupdate_vault_credential, a mutation whose archived-vault sibling is now closed by the write gate. Additionallyarchive_vaultzeroes the blobs of pre-existing credentials, so a scrubbed row yields no usable secret. Widening the guard here would be defensible symmetry but is not required by the standing property, and adding it would changeupdate_vault_credential's error shape for archived vaults — a separable decision.- The 409 is not declared in
openapi.json. No route in the snapshot declares409(I checked: zero across the whole document); FastAPI renders only the declared201/422, and theConflictErrorenvelope is emitted by the sharedinstall_exception_handlers. So the PR's "OpenAPI snapshot unchanged" claim is consistent with repo convention, not an omission. list_session_vault_credentialsdeliberately has no vault-archival filter and returns no ciphertext — it is the metadata/diagnostic read model that intentionally surfaces archived rows (archived_atis part of its output). Correctly left alone.
Conventions look right: raw SQL confined to db/queries/vaults.py, the env-var predicate still single-sourced through one template, ConflictError/NotFoundError used as update_vault does, and the new test module follows the one-predicate-per-test style of test_vault_credential_account_scoping.py with positive controls beside each negative assertion.
…ntials-in-archived-vaul-0b550c
Code reviewVerdict: pass Reviewed at What actually changed since the last reviewThe three substantive PR files are byte-identical to the previously-passing So this round's job is not to re-litigate the fix but to check the merge interaction: master moved ~12.7k lines across 173 files, and the failure mode worth catching is master having re-broken a standing property or drifted a call signature out from under the new test. I confirmed master ( Standing property re-check
Still satisfied at this head, and re-verified against a live Postgres rather than by reading. I extracted the SQL strings from the head tree by AST (not retyped), stood up PostgreSQL 15 with schema-shaped
Every path is genuinely RED pre-fix and GREEN post-fix, and every active-vault positive control still resolves — the join does not over-filter. The rank-shadowing case (archived vault at Write gate, all four branches (lock + disambiguation probe, executed for real):
Concurrency, both orderings. This is what most needed checking, since adding a predicate to a
Merge-delta checks
Limits of this verificationStated plainly so nothing unreadable is scored as green: the sandbox has no Docker/testcontainers and no Not blockingRecorded for context, not as findings — I am not asking for changes:
Conventions look right: raw SQL confined to Structured findings: none — |
Detail bug report: View on Detail
Summary
create_vault_credentiallocked the parent vault withSELECT 1 FROM vaults WHERE id = $1 AND account_id = $2 FOR UPDATEbut omittedAND archived_at IS NULL, so a new active credential could be inserted into a vault that had already been archived.archive_vaultscrubs pre-existing child credentials at archive time but cannot prevent future inserts, and the resolvers (resolve_session_credential,resolve_run_credential,resolve_session_ssh_key_credential, and the shared env-var template_ENV_VAR_CREDENTIALS_FROM_WHERE) filtered only onvault_credentials.archived_at IS NULL, nevervaults.archived_at. The result: an archived vault — believed retired and with its pre-existing secrets scrubbed — was silently resurrected as a live credential source for any session/run bound to it before archival.Fix:
create_vault_credentialnow includesAND archived_at IS NULLin the lock and disambiguates the None branch — "not found / not owned" raisesNotFoundError(404), "exists, owned, but archived" raisesConflictError(409). This mirrorsupdate_vault's archived-row guard (PR fix(db/queries): reject update_vault on archived rows #554), which closed the update path but left the insert-side sibling unguarded.resolve_session_credential,resolve_run_credential,resolve_session_ssh_key_credential, and the shared env-var template all nowJOIN vaults v ON v.id = … AND v.archived_at IS NULL, so a credential that becomes active in an archived vault through any path (a future insert path, direct SQL) still does not resolve.Substrate state changes
None — code-only change. No env-var, schema, mount, or external-service contract changes. The
ConflictErrorreuses the existing{"error": {"type": "conflict", ...}}envelope shape; the OpenAPI snapshot is unchanged.Test plan
New integration test
tests/integration/test_repro_archived_vault_resurrection.py(15 tests) pins each SQL predicate independently — one test per query path, matching the repo'stest_vault_credential_account_scoping.pyconvention. Tests cover:ConflictError(409) with no row written; nonexistent vault raisesNotFoundError(404); foreign account's archived vault isNotFoundError(tenant isolation); active vault still accepts inserts (positive control).vaultsJOIN is pinned independently; active-vault positive controls confirm the JOIN does not over-filter.None; the full bound-before-archive timeline from the bug report is closed end-to-end.Verification results (all green):
mypy --strict(1066 files),ruff check+ruff format --check,scripts/pooled_connection_lint.py,scripts/check_migration_heads.py(single head: 0181), andscripts/run-checks.sh --fail-fast(6684 tests across all lanes): all clean.Risk / rollback
Low risk. The change tightens two gates: inserts into archived vaults now fail with 409 (previously succeeded silently), and resolvers now exclude archived-vault credentials (previously surfaced them). Both changes align with the operator's mental model that an archived vault is retired.
The only behavioral break is for a caller actively inserting credentials into archived vaults — the exact unsafe path this fix closes. A rollback via
git revertrestores the prior behavior with no schema or data migration needed.Automatic Fixes PRs can be configured here.