Skip to content

security(secrets): Secret.is_accessible_by has no production caller -- the secrets rules it models decide nothing #16982

Description

@mrveiss

Problem

Secret.is_accessible_by() (models/secret.py) is a security check with no production caller. Its only callers are models/secret_test.py. It models secret access as is_visible() over owner, org, team and scope, plus _grant_lookup (session binding, shared_with).

Production secret access does not go through it:

  • Envelope secrets: access is holding a SecretGrant for one of the caller's vaults (EnvelopeSecretsService.read / list_for_vaults).
  • Legacy stores: they read through the same envelope path (credential_read, json_secrets_read, llc_secrets_read).

A reader of is_accessible_by concludes the secrets rules live there. They do not, and a change to it changes nothing that ships.

Found on #16927: the owner ruling's first draft named it "the real access check" for secrets. #16927 fixed its str(self.owner_id) stringification (a null would have read as the truthy "None"), but the method still decides nothing.

Decide

  • (a) Wire it into the secret read paths as a second gate beside the vault check. That needs a precise statement of what it adds that the vault grant does not, or it is just a second copy of the policy.
  • (b) Retire it in favour of the vault check, with its tests moved to test the vault semantics they were really about.

Acceptance criteria

  • (a) or (b) is decided and recorded here
  • Afterwards, no secret-access rule exists only in code that no production path calls. A guard pins that is_accessible_by is either called from a read path or absent

Activity

  1. added this to the v0.9.0 milestone on Sep 18, 2026
  2. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Chunking triage — a proposal, not an assignment

    Nothing was relabelled, moved or closed by this pass.

  3. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Investigation: the claim holds, but the policy is not dead — and that changes what (a) and (b) mean

    "No production caller" is true, checked as a claim to disprove

    is_accessible_by appears at models/secret.py:211 (definition) and in models/secret_test.py. Nothing else. I also followed its delegates rather than stopping at the symbol, since a check reached through a helper looks uncalled to a grep of its own name.

    But the rule it models ships — just not for secrets

    is_accessible_by delegates to autobot_shared.scoping.is_visible. That primitive is production-enforced: knowledge/ownership.py:214 calls it for KB ownership, with the same owner / scope / shared-with shape and its own grant lookup.

    So the situation is not "a security rule exists nowhere in production". It is: one shared policy primitive, enforced for knowledge and unwired for secrets. That distinction matters for (b) — retiring is_accessible_by does not retire the policy, and the tests that look like they test secret policy are largely testing is_visible through a secrets-shaped fixture.

    Why (a) needs the statement the issue asks for — the two checks are different kinds

    rule kind
    EnvelopeSecretsService.read (:120-132) decrypt via any grant whose grantee is in accessible_vaults, else SecretAccessError cryptographic capability
    is_accessible_by owner / org / team / scope + session binding + shared_with metadata policy

    The envelope rule is not a policy evaluation. It is possession of a wrapped DEK the caller's KEK can unwrap — access is denied by being unable to decrypt, not by a predicate returning False.

    Consequence for (a): a metadata gate placed beside a cryptographic one can only subtract. It cannot grant access the grant denies, so "second gate" means "additional denial path", and it introduces a second place the access policy lives. That is worth saying plainly because it is the failure mode this repo keeps finding — two expressions of one rule, and drift between them is invisible until they disagree.

    One argument I was about to make, and it is wrong — recording it because it nearly shipped

    I was going to argue that is_accessible_by evaluates fields that are never persisted, citing my #16450 finding that org_id / team_ids / shared_with are dropped before storage.

    That finding is about a different class. #16450 concerns api/schemas_system.py's SecretModel and SecretCreateRequest.to_secret_model(). This method belongs to models/secret.py's Secret, where owner_id, org_id, team_ids, session_id and scope are all real mapped_columns. The fields are persisted here.

    Two classes named for the same concept, with different storage, and a finding that is true of one and false of the other. Checking the columns is what stopped it.

    What I am not deciding

    I am not choosing (a) or (b). Two reasons:

    1. (b) collides with the standing never-delete rule. Unwired code is unfinished work, and the disposition of a security-modelling method is an owner call rather than an implementer's.
    2. The strongest technical case for (b) — that its inputs are never populated — is the argument I just retracted. Without it, (b) rests on "the enforced rule is cryptographic and a metadata copy adds only denial paths", which is a real argument but a judgement about defence-in-depth, not a fact.

    What would settle it: whether a deny that the vault grant would allow is ever wanted. If yes, (a) with the rule stated once and called from the read path. If no, (b).

    What I am building now, which does not depend on the ruling

    AC2 — "A guard pins that is_accessible_by is either called from a read path or absent" — is implementable either way. The invariant is: the symbol is referenced from at least one non-test production module, or it does not exist. That holds under (a) and under (b), and fails exactly in today's state.

    That is the piece that stops this recurring, and it is the half I can deliver without the decision.

  4. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Owner ruling 2026-09-28: wire is_accessible_by into the secret read path

    Asked and answered directly by the repository owner, relayed by the coordinating session, bundled with #12684's store ruling because the two questions collapse into one.

    The vault grant is necessary, not sufficient. The canonical secret store enforces the metadata policy — visibility / organization / team / shared-with — in addition to the vault grant. There are reads the grant allows that the policy must still deny.

    So Secret.is_accessible_by is wired, not retired. It already delegates to autobot_shared.scoping.is_visible, which is enforced for KB ownership at knowledge/ownership.py:214; after this, one shared primitive has one enforcement state across both stores. The AC2 guard already written on issue-16982-unwired-access-check — failing on today's state with an actionable message — ships with the wiring.

    This also settles #16450's scope: the four controls are enforced, not persisted-as-metadata. #17099's UI path lands against the ratified canonical store per #12684.

  5. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Guard artefact location (relayed from the session that wrote it; unpushed by design).

    • Branch issue-16982-wire-access-check, commit 9d4c0b59df — the repo-test guard asserting is_accessible_by is referenced from production or absent (disjunction on purpose: it fails in exactly today's state, and never has to be edited to permit the fix).
    • Must not be pushed alone — it fails on main by construction; it lands with the wiring.
    • Non-vacuity supports: >500 module-count floor on the sweep, and test_the_sweep_would_notice_a_reference pointed at is_visible (production-wired at knowledge/ownership.py). repo_tests/ excluded from swept roots so the guard cannot satisfy itself.
    • Deliberately weak in the permissive direction: any production reference passes; correct enforcement is the wiring's read-path test, not this guard's job.

    No PR is open for this issue yet (#17732 is #16985's PR — its branch name is misleading). Whoever builds the wiring starts from issue-16982-wire-access-check, not from secrets-v093 — the duplicate there was removed.

  6. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Wiring analysis for this issue's half of the bundled ruling — posted in full on #12684, with the blocker summarised here

    Read-only at origin/main ab05ba3aef. Analysis only; no branch, no code.

    The vocabulary question has no open answer: is_accessible_by already speaks the canonical scope. models/secret.py:24-29 — SecretScope = ScopeLevel, a deprecated alias since #11290 collapsed the three enums — so the self.scope comparisons at :204, :206, :265 are ScopeLevel comparisons, and :222-226 documents the method as a thin adapter over autobot_shared.scoping.is_visible. No new type, no adapter layer, no vocabulary migration.

    One blocker to settle before the first commit, from services/secrets_coordinator.py:103-111 and services/secrets_authz.py:52-61:

    read and list hold user_id and facts.team_ids, and neither they nor PrincipalFacts holds a session_id. _grant_lookup:204-205 answers a SESSION-scoped secret with self.session_id == session_id, so wiring the check with session_id=None makes every session-scoped secret unreadable through the coordinator — as a clean refusal, with no error and the cause two frames away. Thread it from the request, or exclude session-scoped secrets from this path deliberately and say so at the call site. Either is defensible; the default is what breaks.

    Second, smaller: is_accessible_by takes a single user_org_id while facts.company_roles is a Mapping[str, MembershipRole]. Given the 16:02Z ruling ratifies a company-scoped store, the company most likely comes from the secret's vault rather than from the principal — one value derived from the object, not a cardinality to resolve in the coordinator.

    The good news for the shape of the PR: list already returns list[Secret] (:109), i.e. objects that carry the method, so the wiring is a post-authorize filter plus the same check on read. The owner's "necessary, not sufficient" composes exactly: facts.accessible_vaults() is the vault grant, is_accessible_by is the per-secret scope.

    The guard at 9d4c0b59df pins referenced-or-absent, which is the right pin — it fails on main by construction and passes once the call site exists.

    Not taken by this session. My scope here is analysis and issues; the PR is re-routed. Recording this so whoever takes it starts from a decided question rather than re-deriving it.

  7. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Decision needed before the first commit: session-scoped secrets on the coordinator path

    Surfaced by the read-only analysis above (a4). services/secrets_coordinator.py:103-111 — read/list take user_id + permissions, build PrincipalFacts (services/secrets_authz.py:52-61: user_id, is_admin, team_ids, role_names, company_roles, granted_permissions, active), and pass facts.accessible_vaults(). Nothing on that path carries a session_id. is_accessible_by's _grant_lookup (models/secret.py:204-205) answers a SESSION-scoped secret with self.session_id == session_id, so wiring the check with session_id=None makes every session-scoped secret unreadable through the coordinator — a clean refusal, no error, cause two frames away.

    Options

    option consequence
    A Thread session_id from the request into read/list (signature change on the coordinator, the callers pass what the request already knows) session-scoped secrets keep working through the coordinator; the check is complete; one more argument on a two-call surface
    B Exclude session-scoped secrets from this path deliberately, recorded at the call site smaller diff; a whole scope class silently bypasses the new enforcement, which is the failure shape this issue exists to remove

    Recommendation: A. B re-creates the "check exists but the path does not run it" state for one scope. If the request path genuinely has no session concept at the coordinator's callers, that is a finding to record here, not a reason to pick B by default.

    Second, smaller: the check takes one user_org_id while facts.company_roles is a mapping — derive the company from the secret's vault (one value from the object), not a cardinality resolved inside the coordinator.

    Owner: answer here or the implementing session (63) proceeds with A when this PR starts (it is behind #15907 and the caretaker fixes in the queue). Provenance: posted by the coordinating session; the analysis is a4's, on #12684.

  8. added
    needs-decisionBlocked on an owner decision; options and a recommendation are on the issue
    on Sep 28, 2026
  9. mrveiss commented on Sep 29, 2026

    @mrveiss
    OwnerAuthor

    The wiring guard is weaker than its name — recorded before anyone reads it as satisfied. 9d4c0b59df's guard asserts is_accessible_by is referenced from a production module. A reference is not a reachable call: mutating SecretsCoordinator to return True above the call site leaves the guard green. It still does its job for the case it was written for (the symbol absent from production entirely), and it was deliberately weak in the permissive direction — but this issue's criterion is that no access rule lives only in uncalled code, and referenced does not establish called. #17772 adds three tests that drive read/list with fakes and assert the value is never decrypted when the scope refuses, which is the reachability half. Same family as #17769/#17761: a guard whose mechanism is narrower than the claim its name makes.

    And one correction to the org analysis above, verified at the primitive. autobot_shared/scoping/visibility.py:80 is resource.company_id is not None and resource.company_id == principal.company_id — so supplying the secret's own company as the principal's company makes the ORGANIZATION comparison tautological and every org-scoped secret readable by anyone who reaches the vault. Passing None is the opposite failure: every org-scoped secret unreadable. The implementation therefore takes the candidate company from the secret and supplies it only when the principal actually holds it — one value, cardinality resolved, rule intact. The framing of the problem was right and is what made this findable; the recommendation needed the extra step.

  10. mrveiss commented on Sep 29, 2026

    @mrveiss
    OwnerAuthor

    Remaining scope after #17772, so the criterion is not read as met: the coordinator's read path now asks the secret's scope, but two production callers reach SecretsService.read directly and still get the vault grant alone — llm_shared/provider_auth.py:177 and services/credential_reconcile.py:101 (verified on origin/main). Both may be legitimate service principals; the defect is that nobody has said so, and the wiring guard cannot tell — any single production reference satisfies it, so a third direct caller would appear silently. Filed as #17773 with a per-caller verdict, a stated sweep, and the guard strengthened from referenced to the coordinator is the only user-facing entry. This issue's criterion — no access rule living only in uncalled code — is met for the coordinator and open for those two.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendbugSomething isn't workingneeds-decisionBlocked on an owner decision; options and a recommendation are on the issuepriority: lowsecurity

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions