Repository navigation
security(secrets): two callers read secrets through the service directly, so they get the vault grant without the secret's own scope — and the wiring guard cannot see them #17773
Description
Activity
Verdict: there are seven direct callers, not two — and in four of them the grant check cannot fail
The sweep, and why the first one was wrong
My first attempt keyed on the receiver:
grep -rn "SecretsService\|secrets_service" --include=*.py autobot-backend/ autobot_shared/ \ | grep -vE "_test\.py|/tests/" | grep -E "\.read\(|\.list_for_vaults\(|\.list\("It returned four sites and missed both callers this issue names, because those assign
svc = EnvelopeSecretsService()on an earlier line and the receiver is then a local. Four hits, none of them the two known ones — a result that looks like an answer.The discriminator that works is the one the signature defines, not a name I chose.
SecretsService.read(session, *, secret_id, accessible_vaults=...)— only a direct caller passes that keyword:grep -rn "accessible_vaults=" --include=*.py autobot-backend/ autobot_shared/ | grep -vE "_test\.py|/tests/"11 sites: 4 inside
secrets_coordinator.py, 7 direct callers.The coordinator already has the service-principal path
secrets_coordinator.py:307service_readand:312service_listexist for exactly the case this issue asks about, and each calls_assert_system_vault(vault)first — so that path cannot be used to reach a user's or a company's vault. This turns "decide per caller" into a decision procedure rather than a judgement: a caller reading the SYSTEM vault has a guarded home to move to; a caller reading a USER or COMPANY vault is acting for a principal and needs the scopedread.Per-caller verdicts
# call site vault passed where that vault comes from is the grant a check? 1 llm_shared/provider_auth.py:177[VaultRef.parse(owner_vault_str)]the same owner_vault_strthe row wasSELECTed by (:170)No — tautological 2 services/provider_key_vault.py:275{_SYSTEM_VAULT}row selected by owner_vault == _SYSTEM_VAULT.to_str()(:173)No — tautological 3 services/json_secrets_read.py:77{VaultRef(SYSTEM)}a constant; row found by marker only Yes 4 services/llc_secrets_read.py:69{VaultRef(COMPANY, company_id)}caller parameter; row found by marker only Yes — the strongest of the seven 5 services/credential_read.py:61{VaultRef(USER, owner_id)}caller parameter, and the row was also filtered by owner_id(:50)Redundant, not tautological 6 services/github_service_credential.py:91[VaultRef(SYSTEM)]row selected by that same vault ( :85)No — tautological 7 services/credential_reconcile.py:101{VaultRef(USER, str(row.owner_id))}the row being read No — tautological The finding is narrower and worse than "they skip the secret's scope"
This issue framed the gap as missing the secret's own scope. That is true, but four of the seven also do not get the vault grant they appear to get: the set handed to
accessible_vaultsis derived from the row being read, so the comparison is a value against itself. It is the same shape as the org-argument hole caught on #17772 —resource.company_id == principal.company_idis tautological when the principal's company is taken from the resource — and here it is in production, four times, in the argument that is supposed to be the authorization.Caller 7 is the one to fix first. It is a USER-vault secret whose grant is
row.owner_id, so neither the grant nor the secret's scope constrains the read; whatever reconciles is trusted entirely by having reached the function. Callers 1 and 6 are tautological too, but 6 is pinned to a fixed name in the SYSTEM vault and 1 to a caller-suppliedowner_vault_str, so their exposure depends on an upstream question.What I did not determine, stated rather than assumed: for callers 1, 4 and 5 the grant is only as good as the parameter feeding it (
owner_vault_str,company_id,owner_id). I did not trace their call chains to establish whether each is the authenticated principal's value or an arbitrary argument. Until that is traced, 4 and 5 are "a real check of an unverified input", not "safe" — and that tracing is the actual remaining work on this issue.Revised fix
- Caller 7 (
credential_reconcile.py:101): derive the authorizing vault from the reconciler's principal, or route throughservice_readif the reconciler is a service identity — not fromrow.owner_id. - Callers 2, 3, 6: SYSTEM-vault reads by a service identity. Move to
SecretsCoordinator.service_read, which already asserts the vault kind, instead of passing a hand-built set. - Callers 1, 4, 5: trace who supplies
owner_vault_str/company_id/owner_id, and record the answer at the call site. A grant argument derived from the row it authorizes is not a check and should say so or stop being one. - The guard: freeze the allowed set of direct
accessible_vaults=call sites (11 today, 7 outside the coordinator) so a new one fails. Key it on the keyword argument, not on the receiver name — the receiver-based sweep above missed 2 of 7 and would have frozen a set with the two known offenders outside it. - A guard for the shape itself: a call whose
accessible_vaultsis derived from the same row as itssecret_idis a tautological grant. Four instances here and one caught on security(secrets): ask the secret's own scope, not just the vault grant (#16982) #17772 make it a class, not five incidents.
- Caller 7 (
Two additions to the seven-caller table, both verified on
origin/main— the class is wider than reads1. The tautology authorizes writes too, not only reads.
services/credential_reconcile.pybuildsvaults = {VaultRef(VaultKind.USER, str(row.owner_id))}from the row it is about to act on, then passes that same set torotate_value(..., actor_vaults=vaults)(:103) as well as toread(...)(:101).envelope_secrets_service.py:175-181takesactor_vaultsas its authorization for replacing a secret's value. So the shipped shape is not "a read that skips a check" — it is a value rotation authorized by a set derived from the row being rotated.2.
EnvelopeSecretsService.deletetakes no authorizing vault at all.envelope_secrets_service.py:221:async def delete(self, session: AsyncSession, *, secret_id: uuid.UUID) -> None: """Delete the secret; its grants cascade.""" await session.execute(delete(Secret).where(Secret.id == secret_id))
credential_reconcile.py:98calls it directly on a revoked row. There is a guardedSecretsCoordinator.deleteatsecrets_coordinator.py:263; the service-level one has no gate and cascades the grants. Whether that is acceptable for a reconciler is a decision — that it is undocumented at the call site is not.3. The code already states the coupling and stops one step short of its consequence. The comment above the line reads "the authorizing vault is the envelope row's own owner" — accurate, and it never draws the conclusion that the argument therefore authorizes nothing. Same shape as #17738's docstring, which described the
LIMITtrap without stating that the read grows forever. A warning that does not state what follows from it is half a finding, and both halves need to be written down for the next reader to act.Consequence for the fix
The guard criterion already proposed — fail a call whose
accessible_vaultsis derived from the same row as itssecret_id— must coveractor_vaultsonrotate_valueby the same rule, and the direct-caller freeze must includedelete, which has no vault parameter to key on and so would be invisible to a guard keyed onaccessible_vaults=alone. That is the same population-narrower-than-the-rule trap as #17769: the discriminator that finds the read callers does not find the delete callers.The three upstream traces — one sound, one latent, one known-and-owned
Tracing each grant parameter to an authenticated principal or to an arbitrary argument, as the previous comment said was the remaining work. The three answers are all different, which is why the sweep could not settle them.
Caller 4 —
services/llc_secrets_read.py:69(company_id): sound, and the grant is a real checkllc/api/secrets.py:79validates it as a route dependency, so it runs before any handler:company_id: str = Depends(_check_company_access) ... if str(company_id) != str(ctx.org_id) and not ctx.is_platform_admin: raise HTTPException(404, ...) # 404 not 403, deliberately, so existence does not leak
So
company_idis the authenticated tenant (or a platform admin). And the grant is not redundant here, for a reason worth stating:read_imported_llc_secret_in_sessionlocates the envelope row by marker in a different table than theLLCSecretrow that_fetch_activefetched by company. The two sides therefore have independent origins, and the check catches an imported envelope whose vault disagrees with the legacy row's company. This one upgrades to sound.Not traced: two other callers of
SecretService().get—llc/adapters/copilot_subscription_adapter.py:180andllc/services/routine_service.py:213— supplycompany_idfrom their own context and do not pass through_check_company_access. Stated, not assessed.Caller 1 —
llm_shared/provider_auth.py:177(owner_vault_str): tautological, and currently unreachableowner_vault_stris a constructor parameter defaulting to"system"onOAuthAuth(:299),DeviceCodeAuth(:415) andSessionAuth(:482).grep -rn "owner_vault_str\s*="over production, excluding that module: no hits. Nothing passes it.grep -rn "auth_strategy="over production: no hits.base_provider.py:120only ever buildsApiKeyAuthwhen no strategy is injected.
So the three vault-reading strategies are not constructed on any production path today, and if they were, the vault would be the default
"system". The tautology is real (the row isSELECTed byowner_vault_strat:170and the same value becomes the grant at:175) but presently unexploitable.It is a latent defect, not a non-defect.
org_vault_subject(org_id)at:66exists precisely to build a per-org vault subject, so org-scoped use is anticipated. The first caller to pass one converts a tautological grant into a cross-tenant read with no check, in a code path that reads as authorized. Fix it while it costs nothing.Caller 5 —
services/credential_read.py:61(owner_id): not the caller, deliberately, and already owned by #13702api/knowledge_connectors.py:293:owner_id = cfg.owner_id or "system"
That is the stored connector row's owner with a literal fallback — the authenticated caller is absent from the read path. Both sides of the ownership comparison are fields of the same stored object.
It is deliberate and documented at
:289-292: reads keep the fallback because rows created before the write path was fixed carry"system", and dropping it "would orphan them rather than protect anything. Migrating those rows to a real owner is the remaining half of #13702."The write path 180 lines later was fixed, and its comment records what this shape cost:
or "system"gave every connector created without an explicit owner the same owner_id, so_require_owner(#13628) compared "system" against "system" and two distinct admins passed each other's ownership check.The control that actually holds is not the owner comparison.
api/knowledge_connectors.py:66gates the whole router:router = APIRouter(tags=["knowledge-connectors"], dependencies=[Depends(check_admin_permission)])
So exposure is bounded to admins, and the residual risk is admin-to-admin on legacy rows — which is exactly what #13628 found and #13702 owns. No new issue for this one; it belongs to #13702's remaining half, and I have not re-filed it. Worth recording here only because "the read path's owner_id is the row's, not the caller's" is invisible from the call site.
Finding the gate needed following the dependency chain rather than reading the route: nothing at or near
:301mentions permission, and grepping the handler would have reported no enforcement.Correcting my own guard proposal
The freeze I proposed keyed on
accessible_vaults=. That is wrong for the same reason my first sweep was: it keys on an argument that only some of the calls have.EnvelopeSecretsService.deletetakes no authorizing vault at all, so a vault-keyed freeze is blind to precisely the call that needs no vault — the trap this issue is about, reproduced inside its own fix.Key on the callee: enumerate every direct call to an
EnvelopeSecretsService/SecretsServicemethod from production and freeze that set, whatever arguments each call happens to name. The callee is defined by the class; the argument list is a per-call accident, and the receiver spelling is a local variable's name.Revised per-caller dispositions
caller verdict action 4 llc_secrets_read.py:69sound — principal validated by route dependency, grant is a cross-store check record the reason at the call site; trace the two non-API callers 1 provider_auth.py:177tautological, unreachable today, latent fix now while no caller depends on it 5 credential_read.py:61owner is the row's, not the caller's — deliberate, admin-gated #13702's remaining half, not a new issue 7 credential_reconcile.py:101tautological, USER vault, and authorizes a write via actor_vaultshighest priority 2, 3, 6 SYSTEM-vault service reads move to the guarded SecretsCoordinator.service_readPR #17776 — caller 7 and the
delete, in one changeTakes the two calls in
services/credential_reconcile.pythat this issue's table marks worst: the tautological USER-vault grant onread/rotate_value, and thedeletewith no authorizing vault.What it does not do, and why: it does not give the reconciler a real authorization, because there is no principal for it to hold.
SecretsCoordinator.read/list/deleteneed auser_idandpermissions— a Celery-beat sweep has neither — andservice_read/service_listassert strictly the system vault while these rows are USER-vault. A cross-owner system reconciler is a third principal the coordinator does not model. That gap is the real remaining work on this caller and it stays open here;secrets_coordinator.pyis also already open in #17772, so the same-file rule keeps a coordinator change out of this PR.What it does fix is the assumption underneath the delete. The old comment justified itself with "(marker → one row → one owner) is 1:1". Nothing enforces that —
extra_datais a JSON column with no unique constraint or index on the marker, nothing in the import path prevents a duplicate, andcredential_read.pyresolves such a pair with.first(), silently picking one. For a sweep that hard-deletes, "revoked in canonical" would then remove every row claiming that id, including an owner's copy the revocation was never about. Collided markers are now detected per sweep, counted, reported, and excluded before the circuit breaker — never deleted.And it corrects the guard I proposed in this issue. My own recommendation was to freeze the direct-caller set keyed on
accessible_vaults=. That is wrong for exactly the reason this issue exists:EnvelopeSecretsService.deletetakes no vault, so an argument-keyed freeze leaves the only unscoped destructive call outside the frozen set — the narrow-population defect, reproduced inside its own fix. The guard keys on the imported class instead, and reads imports by AST rather than by name, because four modules only discuss these classes in prose and a text search would freeze documentation as if it were a call.The frozen set is 14 modules, each with its verdict from this issue, including
"no verdict yet (#17773)"for the six the sweep reached but has not classified. A listed unclassified bypass is a finding; an unlisted one is the defect.Remaining on this issue: per-caller dispositions for callers 2, 3 and 6 (move to the guarded
service_read), the untracedcompany_idchains atcopilot_subscription_adapter.py:180androutine_service.py:213, and the coordinator's missing third principal.Structural cause identified while giving caller 7 a home — filed as #17777. The coordinator models two principals (an authenticated user; a system-vault service principal via
service_read/service_listwith_assert_system_vault) and the fleet has three:credential_reconcileis a cross-owner system sweep with USER-vault rows and no user. Neither guarded path can take it, which is why it calls the service directly — the bypass is a consequence of a missing abstraction, not carelessness at the call site. Callers 2, 3 and 6 (SYSTEM-vault) still haveservice_readas a home today; caller 7 does not, and #17776 therefore makes its arguments honest rather than pretending to fix the authorization.Carried from #17776's review:
collided > 0is not a run statistic, it is a standing hole. A revoked credential behind a collided marker is never removed — not by that sweep, not by any future one — until the data is repaired. #17776 correctly counts and skips rather than refusing (refusing the whole sweep would leave every other revoked credential live for as long as one collision persists — a bounded known-safe skip traded for an unbounded exposure, and it would contradict the module's own "one poison row can't abort the sweep"). What is still owed, here rather than there:- Whatever consumes
ReconcileReporttreats non-zerocollidedas an alert, not a metric — it persists between runs and silently keeps a revoked credential live. - The marker gets a uniqueness constraint, or the resolution stops being
.first()(credential_read.py:55) — todaymodels/secret.py:180'sextra_datais a plain JSON column with no unique index, which is what makes the collision possible at all.
Also noted from the same review: the old comment described the parameter's name rather than its effect, which is exactly how a satisfied parameter gets read as a passed check — the general form of this issue's whole finding.
- Whatever consumes
A guard that penalises the remediation it asks for — found while planning callers 2/3/6, fixed in #17776 before it could bite.
SecretsCoordinator.__init__takes an injected service, so a caller migrating correctly off the direct path must still writeSecretsCoordinator(EnvelopeSecretsService(root_key=root_key))— it keeps importing the class while no longer calling it. An import-keyed freeze reads that as an unremediated bypass, so the guard enforcing this issue's fix would have failed on this issue's fix. A baseline that fails on the remediation teaches people to add an entry instead of changing code, which is the failure mode this issue is about, one level up. Latent today (the single construction site injects nothing) and live the moment the migration starts.Resolution in #17776: a module whose only use of the class is inside a
SecretsCoordinator(...)call is no longer listed; any other use still counts, including a bare annotation, since a module typing a service parameter is receiving one to call. The mixed case has its own fixture.Sequencing, recorded so nobody starts in the wrong order: the migration of callers 2, 3 and 6 must touch
repo_tests/direct_secrets_service_callers_17773_test.pyto remove their entries as they stop importing — that file is in #17776 and unmerged, so same file → ONE PR, ONE agent applies and the migration waits for #17776 to land.A guard that penalises the remediation it asks for
Recording this as its own finding, because it is one level up from the defect this issue tracks and it very nearly shipped inside the fix for it.
The freeze added in #17776 lists every production module that reaches a secrets service directly, so that a new bypass fails. The remediation this issue recommends for callers 2, 3 and 6 is to route them through
SecretsCoordinator.service_read. ButSecretsCoordinator.__init__takes an injected service, and the two callers holding aroot_keycan reach the coordinator no other way than:SecretsCoordinator(EnvelopeSecretsService(root_key=root_key))
A correctly migrated caller therefore keeps importing the service class while no longer calling it. An import-keyed freeze reads that as an unremediated bypass — so the guard fails on the very change it exists to require.
Why that is worse than a false positive. The path of least resistance when a baseline fails is to add an entry, not to revert the change. So a guard in this shape actively converts a correct fix into a new baseline row, and the row then reads as "a bypass someone decided was acceptable". The population grows while the code improves, and the next reader draws the opposite conclusion from the list. That is the same the-measurement-rewards-the-wrong-move failure as this issue's tautological grants, applied to the guard instead of the code.
It was latent, not live — there is exactly one
SecretsCoordinator(...)construction site today and it injects nothing, so nothing was flagged. It would have gone live on the first migration commit, which is the argument for fixing it before the migration rather than discovering it during one.The line drawn: a module whose only use of the class is inside a
SecretsCoordinator(...)call is not listed. Every other use still counts, including a bare annotation — a module typing a service parameter is receiving one in order to call it. The mixed case, injecting and also calling directly, is still flagged and has its own fixture, which is what makes this a rule rather than an exemption.Generalisable check for any shrink-only baseline: write out the diff the baseline is asking for, then ask whether the baseline still passes on it. If the remediation trips the guard, the guard is measuring a proxy for the property, not the property. Worth applying to the other frozen sets in this repository, and to the
accessible_vaults=-keyed freeze I originally proposed here — which had the same disease in a different organ, sincedeletetakes no vault and so could never be seen by it.Sequencing, unchanged: callers 2, 3 and 6 wait for #17776 to merge, because their migration must delete their entries from that guard's frozen map and same file → one PR, one agent outranks batching by scope.
- added 5 commits that reference this issue
on Sep 30, 2026 #17776 merged as
d430be7e98— one of seven callers, so this stays openVerified at head
56493bcd4e: Migration Gatemigration-tests success, all seven reconcile tests green, python-suite 12/12.Delivered
credential_reconcile.py's "authorizing vault" is named and documented for what it is. The vault handed to the service read is derived from the row being read, so it is a value compared against itself and can refuse nothing. Not dressed up as a fix: the docstring states that the destructive-safety circuit breaker, not a grant, is what constrains this sweep, and thatEnvelopeSecretsService.deletetakes no vault at all.- Circuit breaker arm 1 hoisted above the collision filter. The filter can empty the row set, and
rows and ...then short-circuited, so a sweep against a wiped canonical store reportedaborted=False. Harmless in effect, wrong in report: could not evaluate rendering as nothing to do. - Collided markers are reported and skipped, never deleted. Two envelope rows claiming one canonical id is reachable —
extra_datahas no unique constraint on the marker — and a hard delete would remove a copy the revocation was never about. - The freeze guard, recording all fourteen current direct callers with a per-caller verdict.
Guard defects found and fixed inside the guard itself
- The hand-rolled
_MIN_SCANNED = 2000is migrated torepo_tests._reach.declare(guards(primitive): declare() must refuse a floor below its own discovery — all 7 floors reviewed this week were set low #15928). The constant was not carried across: the real population is 2704, measured on the branch. The old floor left 704 files of slack, enough to loseautobot_sharedwhole and still read clean. - A
SyntaxErrorreturnedFalse, making an unparseable module indistinguishable from one that imports nothing — a skip wearing a clean verdict. It propagates now and a test makes it loud. - The injection exemption marked every name below
SecretsCoordinator(...)as injected, soSecretsCoordinator(EnvelopeSecretsService(k).read(...))escaped the freeze. Narrowed, with a contrast fixture. - Import aliases were not recognised as injection, so a remediated caller still read as a bypass.
- The sweep re-parsed 2,704 files per test: 40.7s down to 15.0s, memoised.
Remaining — six callers
provider_auth.py,credential_read.py,llc_secrets_read.py,json_secrets_read.py,provider_key_vault.py,github_service_credential.py. The three SYSTEM-vault reads should move toservice_read; the others carry recorded verdicts in the freeze.Merged with
Refs, notCloses, for that reason.
What is wrong
#17772 wired
Secret.is_accessible_byintoSecretsCoordinator.read/list, so the coordinator now asks the secret's own scope instead of trusting the vault grant alone (the owner's ruling on #16982: the grant is necessary, not sufficient).Two production callers bypass the coordinator and reach the service directly, so they still get grant-only access:
And the guard cannot see them.
is_accessible_by's wiring guard asserts the symbol is referenced from a production module; any single reference satisfies it, so a third direct caller could appear and the guard would stay green. Reference is not reachability — noted on #16982 and the same family as #17769/#17761.Not asserted to be a defect — decide it per caller
Both may be correct as they stand. A provider-auth path and a credential reconciler are plausibly service principals acting for nobody, and imposing a user's scope on them could break the very flows they exist to serve. The defect is that nobody has said so: after #17772, "the secret read path asks the secret's scope" is true of the coordinator and false of these two, and a reader of that change alone cannot tell.
Fix
SecretsService.read/listcaller and give each the same one-line verdict — state the sweep's command and its result, so nothing found is distinguishable from did not look.Refs #16982, #12684, #17772