Skip to content

fix(identity)!: enforce tenant and credential isolation - #1001

Open
bokelley wants to merge 1 commit into
mainfrom
codex/security-identity-isolation
Open

fix(identity)!: enforce tenant and credential isolation#1001
bokelley wants to merge 1 commit into
mainfrom
codex/security-identity-isolation

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

  • enforce tenant/account ownership across registries, sessions, proposals, and reference-seller state
  • redact notification credentials from typed and generic response paths
  • reject ambiguous credential ownership and add a database uniqueness migration
  • require explicit roster authorization instead of silently allowing access

Why

The audit found cross-tenant lookup paths, response shapes that could expose credentials, and race-prone credential ownership without a database uniqueness guarantee.

Validation

  • 261 core identity tests passed (1 skipped)
  • 59 reference-seller and migration tests passed
  • independently reviewed and approved against current origin/main

Compatibility

Roster stores now require an authorization callback at construction. The migration preflights duplicate credential hashes and fails safely before creating the unique index.

Comment thread examples/v3_reference_seller/tests/test_smoke.py Fixed
Comment thread examples/v3_reference_seller/tests/test_smoke_broadening.py Fixed
Comment thread examples/v3_reference_seller/tests/test_smoke_broadening.py Fixed

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid audit fix — the isolation work is correct and well-tested. One blocker, and it's the semver signal, not the code: a required-argument change to a public export is shipping under fix: without a breaking marker.

MUST FIX (blocking)

Breaking public-API change without the semver signal. create_roster_account_store is a public export (src/adcp/decisioning/__init__.py:455). This PR adds a required authorize argument (src/adcp/decisioning/roster_store.py:299), and your own test test_omitted_authorization_callback_fails_at_construction asserts the old call shape now raises TypeError. Failure mode in one sentence: any adopter calling create_roster_account_store(roster=...) — the documented signature until this PR — gets TypeError: missing 1 required keyword-only argument: 'authorize' after upgrading within what release-please will cut as a fix: patch.

The change itself is right — roster membership is not authorization, and failing closed at construction beats a silent fail-open. The block is purely the conventional-commit signal. release-please reads the commit/PR title (fix(identity): enforce tenant and credential isolation), not the PR body's Compatibility section. Retitle to fix(identity)!: or add a BREAKING CHANGE: footer naming the roster-store constructor change, so this cuts a major bump instead of a patch. The migration note in the PR body is already good — it just needs to be where release-please can see it.

Things I checked

  • code-reviewer: proposal_store composite-key migration ((account_id, proposal_id)) is consistent across every read/write/evict/index path including get_by_media_buy_id and _record_key's ambiguous-unscoped resolver. No bare-proposal_id lookup survives.
  • code-reviewer: registry_cache.py charges the aggregate bucket before the per-lookup bucket (_spend_locked), so rotating lookup keys can't bypass the tenant cap; prune-then-spend is behavior-preserving since a re-created bucket starts at full burst.
  • code-reviewer: bounded LRU client cache inserts-then-evicts (platform.py:621-624), never evicting the just-created client; _close_evicted_upstream_client handles the no-running-loop case.
  • security-reviewer: sound, no High. Credential redaction closes both the loose-dict and the Pydantic-model path in responses.py (_serialize now dumps then scrubs), confirmed by test_response_builder_scrubs_notification_credentials_from_pydantic_models. Idempotency replay cache dumps-then-scrubs at dispatch.py:1855.
  • security-reviewer: MCP session binding (server/auth.py) is enforced by a real ASGI integration test — a second valid bearer replaying alice's Mcp-Session-Id gets 404, alice's own bearer gets 200.
  • ad-tech-protocol-expert: sound-with-caveats. notification_config.json marks Authentication.credentials write-only (same class as BusinessEntity.bank), so stripping on the response path is spec-correct. schemes: min_length=1 max_length=1 matches the generated model exactly.
  • ad-tech-protocol-expert: cross-tenant sync_accountsACCOUNT_NOT_FOUND is the spec-correct existence-hiding code, aligning upsert with the pre-existing resolve behavior. Both codes are terminal, so conforming buyers' retry logic is unaffected — not a breaking wire change.
  • Migration 0003 preflights HAVING COUNT(*) > 1 and fails closed before touching indexes; creates the unique index before dropping the old one so a duplicate rolls back. Runtime LIMIT 2 + fetchall defense-in-depth agrees with the DB guarantee.
  • upstream.py: _project_status no longer takes body_text; the only other error path interpolates a JSONDecodeError position string, not the body. test_error_response_body_is_not_exposed covers it.

Follow-ups (non-blocking — file as issues)

  • Timing-oracle equalization for the new ACCOUNT_NOT_FOUND branch. ad-tech-protocol-expert flagged that cross-tenant rejection moved out of the PERMISSION_DENIED branch that _permission_denied_budget.py timing-clamps. The cross-tenant path does more work (resolve succeeds, then tenant compare fails) than the unknown-ref path (early None). Confirm the clamp covers the cross-tenant ACCOUNT_NOT_FOUND branch, or existence-hiding leaks through timing.
  • Raw bearer in scope['user']. server/auth.py sets AccessToken(token=bearer, ...) while the adjacent comment claims only derived context is stored. No round-trip today (the framework reads request.state, keeps credential=None), but a logging middleware serializing scope['user'] would surface the plaintext token. Pass a hash/opaque id since only the derived identity drives binding.
  • Unguarded mcp import on the authenticated path. The from mcp.server.auth... imports in server/auth.py run per-request outside any try/except; an mcp version drift turns every authenticated request into a 500. Import once at module load or guard it.
  • Discovery + stale bearer now 401s. The and not bearer narrowing means a client attaching an expired token to get_adcp_capabilities/initialize now gets 401 instead of the pre-auth discovery response. More correct, but a behavioral change worth a release note for buyers that token every request.
  • Reference-seller order ownership keyed on advertiser_id, creatives on _account_scope. Two different isolation identities in examples/.../platform.py. Safe only if (network_code, advertiser_id) is 1:1 with an account under the seeding contract — worth a one-line justification in the file.

Minor nits (non-blocking)

  1. Stale rationale comment. account_projection.py:457-467 still says strip_credentials_from_wire_result passes Pydantic models through because "response-side codegen shapes don't define authentication/bank." The base model now structurally carries notification_configs[i].authentication.credentials — that's why projections.py needed _NotificationConfigResponse. The load-bearing safety is the handler-layer model_dump, not schema structure; point the comment there.
  2. SchemaVariant on list drops max_length=16. Per ad-tech-protocol-expert, the marker is correctly required here (invariant list[...] would otherwise be a Liskov violation), but the runtime collapse silently drops the base field's length constraint on the response projection. Harmless on a read edge — worth a one-line comment.

Fix the commit prefix and this is a clean ship. Everything else is a follow-up.

@KonstantinMirin KonstantinMirin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #1001

Overview — Same Codex security-CLI shape as #999: the mechanisms hold in the direction they were written for, and the failures are all in the directions the generator did not model. Holding: a second principal cannot replay another's Mcp-Session-Id, the proposal composite key (account_id, proposal_id) has no surviving bare-proposal_id write path, and _serialize dumping before scrubbing genuinely closes the typed-model path at the response-builder boundary.

Three shapes, each reproduced at head:

  • The guard sits where the current request's credential is visible, not where the fact belongs. Session ownership is derived from whether this request carried a credential rather than from who created the session — so it 404s the session's own owner on the documented pre-auth handshake and simultaneously fails open for an anonymous caller on an unowned session. Both cells are 200 before this PR (1).
  • Isolation built on a process-global resource under a single budget. One tenant probing at exactly its permitted rate, with zero denials for itself, denies every other tenant's first-ever lookup with PERMISSION_DENIED / correctable — a seller-side capacity condition wearing an authorization code (2).
  • One spec fact hand-copied into three walkers, the new rule landed on one. The dispatcher's model arm returns typed models unchanged and its justifying comment is checkable and false — "bank" is in BusinessEntity.model_fields; responses.py passes nested models through and is missing the authorization rule, so sync_accounts_response echoes oauth.access_token (4).

As on #999, coverage is what let all three through: deleting the cross-buyer WHERE predicate, the creative owner gate, or the whole AUTH_REQUIRED block each leaves the example suite at 59 passed. Seven mutations survive in total. Fix that first and the rest stop being invisible.

Still open from 2026-07-29 — lead item

Breaking public-API change shipping as a plain fix:. Raised in the aao-ipr-bot CHANGES_REQUESTED review on 2026-07-29, not addressed on this push. git log -1 --format=%B at head 27a79980 is exactly fix(identity): enforce tenant and credential isolation — no !, no BREAKING CHANGE: footer. create_roster_account_store is a public export (src/adcp/decisioning/__init__.py:455), authorize is now a required keyword-only argument (src/adcp/decisioning/roster_store.py:296-300), and the diff's own test_omitted_authorization_callback_fails_at_construction asserts the old call shape raises TypeError. release-please reads the title, so this cuts a patch. Retitle to fix(identity)!: or add the footer naming the roster-store constructor change.

Should fix

1. Session ownership is derived from whether the request carried a credential, not from who created the session.

src/adcp/server/auth.py:509 mirrors the validated principal into request.scope["user"] inside the bearer-validated branch only. src/adcp/server/auth.py:441 returns early on the discovery bypass with scope["user"] unset, so mcp_sessions._handle_stateful_request computes requestor = None, and ownership is recorded only if requestor is not None (src/adcp/server/mcp_sessions.py:200-201). initialize, notifications/initialized and tools/list are all in DISCOVERY_METHODS (src/adcp/server/mcp_tools.py:1141-1143) and get_adcp_capabilities is in DISCOVERY_TOOLS.

Measured at head; every cell on the second row is 200 before this PR:

[authed-initialize]   tools/list as alice(owner) -> 200   as bob(other) -> 404   anonymous -> 404
[pre-auth-initialize] tools/list as alice(owner) -> 404   as bob(other) -> 404   anonymous -> 200

Two defects on one line. A buyer that follows the documented AdCP entry point (connect, read get_adcp_capabilities, then authenticate) is locked out of the session it just created, permanently and indistinguishably from an expired session. And the session created that way has no owner, so any anonymous caller who learns the session id can drive it — the binding fails open for exactly the sessions the middleware did not authenticate. allow_unauthenticated=True deployments land in the same bucket, since that branch also returns before line 509.

Root cause is the identity model, not the line: ownership is being read off the current request's credential when it is a property of the session. Establish the owner once where the session is created — ADCPStreamableHTTPSessionManager._handle_stateful_request, from the request state the framework already threads via _read_request_state_auth — and make an unowned session fail closed instead of matching any caller (requestor is None must not equal _session_owners.get(...) is None). The MCP-shaped AuthenticatedUser/AccessToken construction belongs in mcp_sessions.py, which already imports both at module level, rather than as a function-local import in the transport-agnostic bearer middleware whose A2ABearerAuthMiddleware sibling publishes a different scope["user"] shape (auth.py:1453). The coverage to add is the mixed-auth matrix above, not another replay case; the rejection half of the and not bearer narrowing is also untested (tests/test_auth_middleware.py:245, :270, :293 all send discovery requests with no Authorization header), and the 401 it now returns for discovery-plus-stale-token carries no code and no recovery where AdCP 3.1.8 names AUTH_INVALID/terminal.

2. The new rate-limit state is one table serving three different budgets.

self._buckets is a single OrderedDict holding both (tenant_id, lookup_key) and (tenant_id, "__tenant_aggregate__") entries, capped globally by max_buckets, and both tiers are built from the same self._rate/self._burst. Four consequences, all measured at head with shipped defaults:

  • Global cap, per-tenant budget. src/adcp/decisioning/registry_cache.py:589 returns False for any missing key once the table is full — including a brand-new tenant's aggregate bucket, created on that tenant's first lookup. A tenant probing fresh agent_urls at exactly its permitted 100 rps fills all 10,000 slots in 100 simulated seconds with zero denials for itself, after which an unrelated tenant's first-ever lookup raises PERMISSION_DENIED. _prune_idle cannot relieve it: it returns at the first bucket younger than the 300 s TTL, and cycling 9,999 keys at 100 rps keeps every one of them warm. No attacker needed either — the footprint is sum over tenants of (1 aggregate + live lookup keys), so 100 tenants × 100 buyer agents reaches the cap. Both recommended stacks wire the default (examples/v3_reference_seller/src/buyer_registry.py:173, PgBuyerAgentRegistry.with_hardening at src/adcp/decisioning/pg/buyer_agent_registry.py:470).
  • Shared fate inside a tenant. :566-572 charges the aggregate before the per-lookup bucket, and resolve_by_agent_url/resolve_by_credential are reachable before the caller is authorised — that is the premise of the class's credential-stuffing docstring. Measured: attacker denied after 100 junk probes, then legit buyer with untouched per-key bucket: DENIED PERMISSION_DENIED / correctable. On main, junk probes drained only their own buckets. This diff deletes test_rate_limit_isolates_distinct_lookup_keys, the test that pinned that property, and replaces it with one asserting the new shared-fate behavior.
  • The per-lookup tier can never bind. The aggregate is charged on every lookup and the per-key bucket on a subset, with identical rate and burst, so tokens_aggregate <= tokens_key always holds. Replacing exhausted = not self._spend_locked(key, now) with pass leaves 25 of 27 tests in tests/test_buyer_agent_registry_cache.py green; the two failures assert len(limiter._buckets) == N, not a rate-limit outcome.
  • _prune_idle restores full burst. The docstring's safety argument at :490-491 ("Discarding an idle bucket is safe because it would have refilled to its full burst") holds only when burst / rps_per_tenant <= bucket_idle_ttl_seconds. __init__ validates each of the three values in isolation (:517-526) and never the relation, while the class docstring invites the violating config ("Adopters with bursty real traffic raise this"). Measured with rps=1.0, burst=1000.0, ttl=300: drained after 1000 requests, then after 301s idle, allowed: 1000 where honest refill allows 301. It applies to the aggregate bucket too, so the whole tenant budget resets.

One root: enumeration control, per-tenant fairness and the memory bound are three different budgets sharing one table, one rate/burst pair and one cap. Split them — keep the aggregate bucket in a map the cap never touches so a new tenant can always allocate, cap lookup buckets per tenant and evict that tenant's own LRU rather than refusing allocation, give the per-lookup tier its own tighter rate/burst so it can bind (or drop the tier), and validate burst / rps_per_tenant <= bucket_idle_ttl_seconds at construction. Then decide the wire code: the victim currently gets PERMISSION_DENIED / recovery="correctable" — "you are not authorized, fix your request" — for a seller-side capacity condition that AdCP 3.1.8 classifies transient (RATE_LIMITED with retry_after, or SERVICE_UNAVAILABLE, per schemas/cache/3.1/enums/error-code.json@3.1.8). The wire-uniformity rationale at :122-130 covers a caller who was rate-limited; it does not cover one who never was. Tests must assert admitted-request counts, not len(_buckets), and both new tests use a single tenant.

3. The write-only credential strip is three hand-copied walkers, and each is missing rules the others have.

Four measured gaps, all at head:

strip_credentials_from_wire_result('list_accounts', Account(...)) model arm leaks: True
strip_credentials_from_wire_result('list_accounts', {'accounts': [Account(...)]}) leaks: True
sync_accounts_response([{... 'notification_configs': [NotificationConfig(...)]}]) leaks: True
builder path leaks authorization.oauth.access_token: True   internal_connection_id: True
  • src/adcp/decisioning/account_projection.py:469-485 returns Pydantic models unchanged, so _invoke_platform_method's return (src/adcp/decisioning/dispatch.py:1596), task_registry.complete (:472) and webhook_emit (:302, :479) hand the adopter's typed model straight to the transport, which dumps it verbatim (mcp_tools.py:2589, a2a_server.py:499, serve.py:2727). The justifying comment says the response-side codegen shapes "don't define authentication on GovernanceAgent or bank on the response-side BusinessEntity, so the schema enforces the strip structurally". That is false, and this diff is what proves it: 'bank' in BusinessEntity.model_fields is True, and projections.py needed a whole _NotificationConfigResponse precisely because the base model carries notification_configs[].authentication.credentials.
  • src/adcp/server/responses.py:158 appends any non-dict config untouched, so a loose dict holding typed sub-objects — the {**db_record, "notification_configs": [NotificationConfig(...)]} shape the module exists to defend against — still echoes the secret. _serialize dumps only the top-level item. Every rule in that walker has the same hole, because there is no model-normalisation step at recursion entry.
  • The responses.py walker is missing the authorization and error-details rules that account_projection._scrub_dict has, so sync_accounts_response() — the function this PR just declared a security boundary — echoes authorization.oauth.access_token and internal_connection_id.
  • Both walkers key on the literal parent field name. invoice_recipient.bank is the most frequently writeOnly: true-marked path in the bundled schemas (52 occurrences under schemas/cache/*/bundled/** at 3.1.8) and is stripped by neither.

Root: one spec fact is encoded as a hand-written literal-key walker in three modules (account_projection, server/responses, types/projections), derived from nobody's schema, so the next write-only field gets added to one or two of them. That is how notification_configs came in from an external audit rather than from this repo. Make account_projection the single owner — it already has the complete rule set — normalise models once at recursion entry (if hasattr(value, "model_dump"): value = value.model_dump(mode="json")) and delete the per-branch non-dict passthroughs, and have responses._serialize call the owner rather than keep a second copy. adcp.server already imports adcp.decisioning at a dozen sites, so the direction introduces no cycle. Then derive the strip set from writeOnly: true in the bundled schemas, or add a conformance test that walks schemas/cache/*/bundled/** for writeOnly paths and asserts both scrubbers remove each one — an empty-allowlist ratchet in the style of KNOWN_VERIFIER_GAPS, which turns this class of bug into a build failure. The prose half needs the same edit: account_projection.py:450-452 and docs/handler-authoring.md:906-909 still enumerate the old two-field list, and docs/handler-authoring.md:912-915 points adopters at create_roster_account_store without mentioning the now-required authorize argument.

4. The reference seller now has three isolation identities, and the media-buy gate uses the one the repo's own seed data defeats.

examples/v3_reference_seller/src/platform.py:887 compares order["advertiser_id"] against ctx.account.metadata["advertiser_id"]. examples/v3_reference_seller/seed.py:117-142 seeds a_acme_1 (buyer agent ba_acme_signed) and a_acme_2 (ba_acme_bearer) with the same ext = {"network_code": "net_premium_us", "advertiser_id": "adv_volta_motors"}. Two buyer agents, one advertiser id, so the check passes straight across the account boundary the rest of this PR spends 1400 lines enforcing. The same gate guards update_media_buy (:1394), get_media_buy_delivery (:1685 — reads another buyer's spend and impressions) and provide_performance_feedback (:1912 — writes conversions onto another buyer's campaign). test_update_media_buy_rejects_foreign_advertiser_order only exercises a different advertiser id.

Meanwhile the stronger identity exists in the same class: create_media_buy records _account_scope(ctx) onto _buy_state at :1110-1112, and _creative_id_map/_creative_id_reverse were correctly re-keyed to (account_scope, id). _buy_state was not — the scope lives in the value, checked at one of eight read sites (:1625; :1331, :1422, :1499, :1640, :1704, :1791, :1819 are unscoped). Counting AccountRow.buyer_agent_id, the column this same PR starts enforcing in resolve/upsert, there are three owner identities in one class and no accessor owns "who owns this".

Pick one identity — buyer_agent_id is what the DB enforces — and put it in the key, so no reader can forget the check. Derive it once from a typed metadata model instead of dict[str, Any] lookups; Account is generic in TMeta, so a TypedDict with required tenant_id / account_id / advertiser_id / network_code / buyer_agent_id turns roughly ten metadata["..."] / .get(...) or "" sites into checked attribute access and removes _account_scope's silent empty-tenant fallback at :871. Two more edges in the same helper: an absent advertiser_id on the upstream GET /orders/{id} is indistinguishable from a foreign order (order.get(...) != expected), so upstream schema drift becomes a silent total MEDIA_BUY_NOT_FOUND outage that looks spec-correct — an absent field should be an operator-visible SERVICE_UNAVAILABLE. And the comment at :888 claims foreign and nonexistent ids are indistinguishable, but the two envelopes differ:

FOREIGN: {'code':'MEDIA_BUY_NOT_FOUND','message':"Media buy 'ord_foreign' was not found.",'recovery':'terminal','field':'media_buy_id'}
MISSING: {'code':'MEDIA_BUY_NOT_FOUND','message':'upstream GET /v1/orders/ord_missing failed: 404','recovery':'terminal'}

AdCP 3.1.8's *_NOT_FOUND uniform-response rule requires the response shape itself not be the oracle (schemas/cache/3.1/enums/error-code.json@3.1.8, and PERMISSION_DENIED extends it to "every observable channel (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity)"). Route both arms through one constructor and assert foreign.to_wire() == missing.to_wire(); the three new _get_owned_order tests assert only excinfo.value.code, so the invariant is ungraded.

5. The cross-tenant guard raises from context_factory, a hook no transport projects — and the rule belongs in the framework.

examples/v3_reference_seller/src/app.py:113 raises AdcpError("PERMISSION_DENIED") from inside the context factory. src/adcp/server/serve.py:2656 calls context_factory(meta) above the try whose except ADCPError at :2682 builds the conformant envelope, and src/adcp/server/a2a_server.py:302 has the same shape. Measured on MCP:

{"result":{"content":[{"text":"Error executing tool get_products: AdcpError[PERMISSION_DENIED / terminal]: Bearer credential is not valid for this tenant.","type":"text"}],"isError":true}}

No adcp_error, no code, no recovery — a conforming buyer sees an untyped tool failure, and the exception repr is what lands in the tool text, for a condition any holder of a valid token can trigger by changing the request host. On A2A the same raise escapes execute() and surfaces as a JSON-RPC transport error rather than a failed task with an adcp_error DataPart: one rule, two non-conformant and mutually different envelopes.

Second defect on the same line: the guard is if ctx.tenant_id is not None and ctx.tenant_id != tenant.id. Principal.tenant_id defaults to None and src/adcp/server/auth.py:148-152 documents leaving it unset as a supported configuration, so those deployments still silently rebind the credential to whatever tenant the Host names — the thing the new comment says it prevents. Fail closed when a host tenant was resolved and the token carries none.

Root: the rule is framework-shaped and the framework neither enforces nor offers it. auth_context_factory sets ToolContext.tenant_id from the validated bearer; the documented multi-tenant composition then overwrites it with the Host-derived tenant and never compares the two. That recipe is printed twice in framework docstrings — src/adcp/server/tenant_router.py:55-61 and TenantRegistry.as_platform at src/adcp/server/tenant_registry.py:770-776 ("wire tenant_id in your context_factory like this: t = current_tenant(); return {"tenant_id": t.id if t else None}") — so every adopter who follows it lets a bearer issued for tenant A act as tenant B. Both identities are visible at one point: the auth ContextVar holds the token's tenant, tenant_router.current_tenant() holds the host-resolved one. Do the comparison there (in auth_context_factory, or a subdomain_auth_context_factory), update both docstring recipes, and delete the bespoke check from the example — which also gets it a conformant wire error for free. If the factory hook is meant to be allowed to reject, that is a framework gap: move the context_factory(meta) call inside the existing try in both serve.py and a2a_server.py, because the hook has no error-translation contract today. test_bearer_context_rejects_cross_tenant_rebinding calls _build_context_factory() directly and asserts only excinfo.value.code, so the wire shape is uncovered, as are both allow paths the same edit governs (ctx.tenant_id is None must still pin from the subdomain; ctx.tenant_id == tenant.id must pin, not reject).

6. Existence-hiding landed in upsert and not in sync_governance, whose docstring says "same rules".

src/adcp/decisioning/tenant_store.py:391 splits the combined branch into auth_tid is None → PERMISSION_DENIED / auth_tid != entry_tid → ACCOUNT_NOT_FOUND. sync_governance, twelve methods down at :494, still runs if auth_tid is None or auth_tid != entry_tid: PERMISSION_DENIED, and its message discloses the reason outright. Same store, same cross-tenant AccountReference, both arms:

upsert cross-tenant         : {'code': 'ACCOUNT_NOT_FOUND', 'message': 'Unknown operator: pinnacle.example'}
sync_governance cross-tenant: {'code': 'PERMISSION_DENIED', 'message': "Buyer agent has no authority over 'pinnacle.example' (tenant mismatch or auth principal not registered)."}

So the enumeration oracle upsert just closed is open one method down: a buyer probes {brand, operator} refs and reads PERMISSION_DENIED ("exists under another tenant") against ACCOUNT_NOT_FOUND ("no such account"). AdCP 3.1.8's sync-governance-response.json illustrates existence-hiding on exactly this surface, with the failed-row example using ACCOUNT_NOT_FOUND and the message "Account 'acct-unknown' does not exist or is not accessible to the authenticated agent."

Root: the auth-tenant/entry-tenant comparison and the failed-row construction are copy-pasted per method, so fixing one arm structurally cannot fix the other. Extract the ref → (entry_account, code, message) classification into one private method and have both upsert and sync_governance build their row shapes from it; cite schemas/cache/3.1/account/sync-governance-response.json@3.1.8 at the shared decision site. Four prose sites now assert the old rule — tenant_store.py:6-8, :20-21, sync_governance's own docstring at :445-447, and :10-14, which claims the security semantics mirror the JS createTenantStore unchanged. Since error-code strings are the normative wire surface, is the JS store moving to ACCOUNT_NOT_FOUND too? A one-sided change makes the two SDKs answer the same probe differently. On the test side, tests/test_tenant_store.py:1-8 still documents PERMISSION_DENIED, :307-310 still states in prose that ACCOUNT_NOT_FOUND is "distinct from PERMISSION_DENIED ('ref valid but you're not authorized')" — the distinction the change deletes — :361-364 says the entry surfaces "as PERMISSION_DENIED" while its assertion was flipped, and the changed test at :298-305 lost its docstring, so a reader cannot tell deliberate existence-hiding from a regression.

7. Every isolation guarantee that lives in a SQL WHERE clause or a composite dict key is asserted through a mock that answers the same either way.

The example suite stubs session.execute with AsyncMock(side_effect=[...]), so a query's predicate is unobservable. Verified by deletion, each against pytest examples/v3_reference_seller/tests/:

  • delete AccountRow.buyer_agent_id == buyer_agent.id (examples/v3_reference_seller/src/platform.py:321) → 59 passed, 2 deselected. The cross-buyer account read path reopens with a green suite. test_account_store_explicit_id_is_bound_to_authenticated_buyer proves only that resolve now issues two queries; the sibling predicate on the brand-shaped path at :347 has the same exposure.
  • replace the assignment resolver's state.get("account_scope") == self._account_scope(ctx) (:1626) with == "NEVER_MATCHES"59 passed. Same for pointing the list_creatives reverse lookup (:2013) at a literal "WRONG_SCOPE". test_creative_id_mapping_is_scoped_to_account asserts route.call_count == 2, so nothing checks that the read side reconstructs the key the write side stored — and a mismatch does not raise, it falls back to the upstream id, so the buyer silently receives upstream creative ids instead of their own and attach_creative fires with the wrong id.
  • delete the whole if not principal: raise AdcpError("AUTH_REQUIRED", ...) block (:293-298) → 59 passed. Both halves of that change are unasserted: that an unauthenticated resolve is refused at all, and that a buyer who previously got ACCOUNT_NOT_FOUND/correctable/field="account.account_id" (with a message telling them what to send) now gets a terminal code.

The shape to copy is already in the diff: test_account_store_upsert_cannot_overwrite_another_buyers_account uses a real AccountRow for the foreign row and asserts foreign.buyer_agent_id == "ba_other" afterwards, so it catches both the missing rejection and a partial write. Give the resolve path the same treatment — a real sqlite/asyncpg session with two AccountRows in tenant t_acme, one owned by ba_caller and one by ba_other — and the same fixture carries the AUTH_REQUIRED cases at no extra setup cost. Add one same-account creative round trip (sync_creatives with buyer id shared-id, then list_creatives asserting creatives[0]["creative_id"] == "shared-id") plus the cross-account negative.

Three more coverage items in the same diff. test_provide_performance_feedback_404_translates_to_media_buy_not_found (examples/v3_reference_seller/tests/test_smoke_broadening.py:1451) had its mocked 404 moved from POST /v1/orders/ord_missing/conversions to GET /v1/orders/ord_missing, so it now exercises _get_owned_order while its name and docstring still describe the POST-404 projection — which is covered by nothing, though the POST can still 404 in production. Removing the delivery fallback (examples/v3_reference_seller/src/platform.py:1683-1701) changed a wire shape with no test on either side: the old code emitted a well-formed active row when the delivery row existed but the order was gone, and the new code omits the row entirely. And migration 0003's load-bearing claim is an ordering claim ("Create first so a concurrent/legacy duplicate makes the migration fail while the old lookup index remains available") that no test asserts — all three tests in test_unique_api_key_migration.py patch op.create_index/op.drop_index and check create_index.call_args.kwargs["unique"] is True, leaving the index name, table, column list and partial-WHERE predicate unasserted.

8. The upstream-client cache became the owner of pools every caller borrows, and its drain method has no callers.

src/adcp/decisioning/platform.py:622-624 evicts and closes. upstream_for hands adopters a raw client with no lifetime contract, and the cache key is id(auth) "so different DynamicBearer closures for different tenants need distinct clients" (:571-574) — so a platform with more than 128 tenants and concurrent traffic closes a connection pool another coroutine is mid-request on, and round-robin over more than 128 identities also thrashes the cache into never reusing a pool, which inverts the stated purpose. test_bounded_cache_closes_evicted_client does not catch it because the failure needs a real socket; under respx the in-flight request survives.

Three defects in the same lifecycle gap:

  • loop.create_task(client.aclose()) at :638 discards the task. asyncio holds only a weak reference, so it can be collected mid-close and an exception in aclose is never retrieved. The repo's ruff select is ["E","F","I","N","W","UP"], so RUF006 will not flag it. Retain it in a set with add_done_callback(discard).
  • _upstream_clients_pending_close (:632-636) is drained only by aclose_upstream_clients, and grep -rn "aclose_upstream_clients" src/ tests/ docs/ examples/ returns its own definition and nothing else — no lifespan wiring, no docs, no test. On the no-running-loop branch the list grows and no pool is ever closed, so the net effect of the new bound in a sync context is that the cache stops growing and the sockets leak instead.
  • The public upstream_for docstring at :485-486 still says clients are "cached per-platform-instance keyed by (base_url, id(auth))", with no mention of the bound or that eviction closes the pool. Adopters read that one, not the private helper's.

Fix the ownership rather than the symptom: extract the keyed pool into an UpstreamClientPool in adcp/decisioning/upstream.py (which owns the pool) with get(...) and aclose(), hold it as one attribute instead of three ad-hoc ones on the platform mixin, and wire its aclose() into the framework shutdown path so the no-loop deferral has a real drain point instead of an adopter obligation nobody is told about. Coverage is short too: the deferral branch, aclose_upstream_clients, the upstream_client_cache_size < 1 ValueError (which raises at first-request time rather than at construction) and the cache.move_to_end(key) recency behavior are all untested.

9. New raise sites hardcode recovery against the pinned enumMetadata, and one emits a value that is not in the enum.

schemas/cache/3.1/enums/error-code.json#enumMetadata @ 3.1.8: MEDIA_BUY_NOT_FOUND: correctable, PERMISSION_DENIED: correctable, AUTH_REQUIRED: correctable, ACCOUNT_NOT_FOUND: terminal. schemas/cache/3.1/core/error.json calls recovery the normative carrier of recovery semantics and enumerates it as ["transient","correctable","terminal"]. Every new site hand-rolls AdcpError(code, ..., recovery="terminal"):

  • examples/v3_reference_seller/src/platform.py:295AUTH_REQUIRED/terminal. Two problems: the recovery contradicts enumMetadata, and 3.1.8 marks the code itself Deprecated ("use AUTH_MISSING (no credentials presented) or AUTH_INVALID (credentials presented and rejected)"). The condition here is exactly "no credentials presented", so emit AUTH_MISSING with recovery="correctable" and cite the schema at the raise site.
  • examples/v3_reference_seller/src/platform.py:892MEDIA_BUY_NOT_FOUND/terminal where the pinned enum says correctable.
  • examples/v3_reference_seller/src/app.py:117PERMISSION_DENIED/terminal. The enum classifies it correctable, and terminal applies only when details.reason is present, which this site does not emit. For a rejected credential the spec-consistent pair is AUTH_INVALID/terminal.
  • src/adcp/decisioning/upstream.py:146recovery="retry_with_changes" reaches to_wire() verbatim with no normalisation step, so the INVALID_REQUEST branch emits a value outside the pinned enum and a buyer dispatching on recovery falls off the switch. AdcpError's own docstring calls it a legacy alias for correctable. This line is not itself inside a hunk, but the enclosing _project_status signature and every returned message are rewritten here (:103-147), so it is a one-token fix in the same edit.
  • src/adcp/decisioning/tenant_store.py's _build_failed_sync_accounts_row emits errors=[{"code","message"}] with no recovery at all (src/adcp/decisioning/account_projection.py:299-300), so the flip in finding 6 moves a conforming buyer from retry-after-fix to escalate-to-human with the spec's authoritative carrier absent from the row.

Root: these sites reach for the base AdcpError when the repo already owns the abstraction. src/adcp/decisioning/errors.py binds each code to its enumMetadata recovery — verified: MediaBuyNotFoundError → correctable, PermissionDeniedError → correctable, AuthRequiredError → correctable, AccountNotFoundError → terminal — and its module docstring says "Recovery values are normative — they MUST match the enumMetadata block." Raise the typed subclasses, thread recovery through _build_failed_sync_accounts_row from enumMetadata, and fix the three _project_status branches in the same pass.

10. The credential-uniqueness ratchet landed at one of three lookup implementations.

src/adcp/decisioning/pg/buyer_agent_registry.py:245 and src/adcp/decisioning/pg/buyer_agent_registry.sql:64 both now emit CREATE UNIQUE INDEX IF NOT EXISTS ..._api_key_id_uidx. Against an existing deployment that aborts the whole create_schema() DDL batch with a raw UniqueViolation and none of the "rotate or remove duplicate bearer credentials, then rerun" guidance examples/v3_reference_seller/alembic/versions/0003_unique_buyer_api_key.py:46-50 carefully provides. It also orphans the legacy non-unique ..._api_key_id_idx forever, because the index name changed and nothing drops the old one — while the docstring two lines above still says "Idempotent via CREATE ... IF NOT EXISTS; safe to call on every app boot." Give create_schema() the same duplicate preflight, drop the superseded index, and fix the docstring together.

Same window, third implementation: the diff adds LIMIT 2 + fetchall + fail-closed-with-log to PgBuyerAgentRegistry._sync_lookup_by_api_key_id explicitly "for deployments that have not yet applied the unique-index migration", but the reference seller ships that same pre-migration window and its resolve_by_credential still does scalar_one_or_none() (examples/v3_reference_seller/src/buyer_registry.py:108), which raises SQLAlchemy MultipleResultsFound on a legacy duplicate rather than denying. Its where clause is tenant-scoped, so pre-migration a duplicated api_key_id resolves to whichever tenant's host the request arrived on — the exact ambiguity this PR closes. Apply the same posture there.

Related, same file: upsert now lets psycopg.errors.UniqueViolation escape the abstraction, and tests/conformance/decisioning/test_pg_buyer_agent_registry.py::test_upsert_rejects_duplicate_credential_identifier pins that raw driver exception as the contract. Translate it into a typed AdcpError ("credential already bound to another buyer agent") so admin callers are not pattern-matching psycopg types.

11. The roster authorization callback fails closed with no signal, and three of its branches have no test.

src/adcp/decisioning/roster_store.py:145 wraps the adopter callback in except Exception: return False and grep -c logg src/adcp/decisioning/roster_store.py is 0 — the module imports no logger. Failing closed is right; failing closed invisibly is not. A callback that raises on every call (bad attribute name, dead DB handle) turns resolve into a framework-projected ACCOUNT_NOT_FOUND and list into a permanently empty array, which is a spec-valid response either way, with nothing separating "policy denied" from "policy is broken". The convention is already set in two places, one of them in this PR: BearerTokenAuthMiddleware does logger.exception("token validator raised") before failing closed (src/adcp/server/auth.py:472), and pg/buyer_agent_registry.py:518-524 logs at error when it fails closed on an ambiguous credential. Add a module logger and logger.exception("roster authorize callback raised; denying").

Three documented behaviors have no test (grep finds no async def authorizer, no raising authorizer, and no other create_roster_account_store call site in tests/, src/ or examples/): except Exception: return False; inspect.isawaitable(result); and result is True strictness, which denies a truthy non-bool. The awaitable branch is the dangerous one — if it regresses, an async authorizer returns a coroutine, result is True is False, and every account is silently denied for every async adopter, a total outage that fails closed and so emits nothing.

12. AccountResponse widens the schema it projects, and the new auth model hand-copies codegen output.

Account       REJECTED 20 notification_configs
AccountResponse ACCEPTED 20   (max_length=16 dropped)

src/adcp/types/projections.py:208's SchemaVariant[list[_NotificationConfigResponse] | None] replaces the base field and loses its MaxLen(16), which mirrors schemas/cache/3.1/core/account.json's maxItems: 16 at 3.1.8. to_account_response is on the emit path, so a seller building through the class whose job is to be the safe response shape can emit a body that fails buyer-side validation. Carry the bound through: SchemaVariant[Annotated[list[_NotificationConfigResponse], Field(max_length=16)] | None], with a test asserting AccountResponse rejects 17. Raised on 2026-07-29, still open.

Second half at :169-174: _NotificationAuthenticationResponse subclasses AdCPBaseModel and re-declares schemes: list[AuthenticationScheme] = Field(min_length=1, max_length=1) by hand, while adcp.types.NotificationAuthentication is the exact generated model NotificationConfig.authentication uses (same MRO base, same extra="forbid", same MinLen(1)/MaxLen(1)). That is a hand-copy of codegen output whose constraints need manual re-sync on every schema re-vendor, and it is inconsistent with BusinessEntityResponse(BusinessEntity) twenty lines above. Subclass NotificationAuthentication and narrow only credentials.

13. A per-entry ownership rejection aborts the whole sync_accounts batch.

examples/v3_reference_seller/src/platform.py:444 raises inside the for incoming in refs loop, inside async with session.begin(), so a batch where any single entry names another buyer's account rolls back the entries that already succeeded:

OPERATION-LEVEL RAISE: {'code':'ACCOUNT_NOT_FOUND','message':"Account 'two.example::op.example' is not visible to the authenticated buyer agent.",'recovery':'terminal','field':'accounts'}
entry-1 rows lost; session.add had been called for: ['one.example::op.example']

schemas/cache/3.1/account/sync-accounts-response.json @ 3.1.8 reserves the operation-level error channel for "complete failure"; the per-entry channel is accounts[i].action="failed" + status="rejected" + errors[]. This is also inconsistent with what the same PR does one layer up — tenant_store.upsert emits a per-entry ACCOUNT_NOT_FOUND row for the identical cross-boundary condition. Append a SyncAccountsResultRow(action="failed", status="rejected", errors=[...]) and continue. While there, field="accounts" is unindexed where the spec's convention is an exact path (FIELD_NOT_PERMITTED: "error.field MUST identify the exact offending field path (e.g., packages[0].budget)") — point it at accounts[<i>]. test_account_store_upsert_cannot_overwrite_another_buyers_account uses a single-entry batch, so add the mixed-batch case asserting the clean entry still lands.

14. The plaintext bearer is written into request.scope["user"], and nothing reads it.

src/adcp/server/auth.py:511 sets AccessToken(token=bearer, ...), readable by every downstream ASGI middleware and by handlers:

plaintext bearer readable from scope: super-secret-bearer

The adjacent comment claims "The raw token remains request-scoped; the session manager stores only its derived (client_id, issuer, subject) authorization context." The second clause is accurate; the first is not — the scope dict travels with the request through the whole stack, and any error or logging middleware that serializes it surfaces the token. Nothing in the binding reads .token: session ownership is authorization_context(user)principal_components(token)(token.client_id, claims["iss"], token.subject) (mcp 2.0.0, mcp/server/auth/middleware/bearer_auth.py:22-38), so a derived opaque value is behaviourally identical. This is the rule the project fail-closes on elsewhere (_CREDENTIAL_SHAPED_KEY_SUFFIXES rejects credential-shaped keys in ctx_metadata; bearer flows deliberately set AuthInfo.credential=None at auth.py:737), and the _A2AAuthenticatedUser sibling twenty lines away in this same file carries only derived identity (:1453-1456). Pass hashlib.sha256(bearer.encode()).hexdigest() or an opaque per-request id, correct the comment, and assert in tests/test_mcp_stateful_session.py that the bearer string does not appear anywhere in scope["user"]. Raised as a follow-up on 2026-07-29; still open, and this diff is the code that introduces it.

15. InMemoryProposalStore.get kept its optional account scope, so the diff had to add a cross-account resolver.

src/adcp/decisioning/proposal_store.py:422-429 exists only to serve expected_account_id=None, and what _record_key does is scan every account's records and return another account's record whenever the proposal id happens to be globally unique — then return None once a second account uses the same id. One account's result depends on whether another account has registered that id, which the new test pins as intended (assert await store.get("shared") is None). Every sibling method on the Protocol already requires expected_account_id: str (:209, :231, :257, :273, :291, :310, :326); get is the lone str | None (:190), and all three framework call sites pass it (proposal_dispatch.py:197, :374, proposal_lifecycle.py:102). Make it required, delete _record_key, and both the O(n) scan under the lock and the cross-account observability channel disappear. The residual record.account_id != expected_account_id check in get (:496-500) becomes unreachable then — delete it so the invariant lives in one place.

Notes

  • The 2026-07-29 timing-oracle follow-up on the new ACCOUNT_NOT_FOUND branch is closed, not a finding: PermissionDeniedBudget is constructed only at src/adcp/decisioning/handler.py:572 and :734 (the buyer-agent identity gate) and never wrapped tenant_store.upsert, so nothing moved out of a clamp. Both ACCOUNT_NOT_FOUND branches in upsert pay the same resolve_by_ref call and format through the same _account_not_found_message(ref), leaving a dict comparison as the only delta.
  • The per-request from mcp.server.auth... import inside dispatch() is not a 500 risk: mcp>=2.0.0,<3.0 is a core dependency (pyproject.toml:73), not an extra, so ImportError is unreachable in a valid install. Its placement is covered by finding 1's call to action, not raised separately.
  • examples/v3_reference_seller/src/app.py:166-170 stores the live bearer under ctx.metadata["api_key_id"]row.api_key_id is the token_map key, and _CREDENTIAL_SHAPED_KEY_SUFFIXES misses it because it ends in _id. Out of scope as a fix item: the line is context, not a diff line, and I could not find a path that echoes ctx.metadata to the buyer (inject_context at src/adcp/server/helpers.py:378-383 echoes only the buyer's own request context, and src/adcp/server/idempotency/ does not persist it). Adding _id-suffixed shapes to src/adcp/decisioning/dispatch.py:434-443 is worth a separate change.
  • push_notification_config.authentication.credentials and reporting_webhook.authentication.credentials are not marked write-only in schemas/cache/3.1/core/*.json @ 3.1.8, so leaving them out of the scrubbers is correct as-is. Noted because the shapes are otherwise identical to notification_config and a future reader may assume the scrubber is incomplete. invoice_recipient.bank is a different case and is in finding 3.
  • registry_cache._current_tenant_id lazily imports adcp.server from the decisioning layer (src/adcp/decisioning/registry_cache.py:145), a lower layer reaching up to the transport layer. Pre-existing and untouched by this diff, so raising it would be an unrelated refactor.

Comment thread src/adcp/server/auth.py Outdated
Comment thread src/adcp/server/auth.py Outdated
Comment thread src/adcp/decisioning/registry_cache.py Outdated
Comment thread src/adcp/decisioning/registry_cache.py
Comment thread src/adcp/decisioning/registry_cache.py Outdated
Comment thread src/adcp/types/projections.py Outdated
Comment thread src/adcp/types/projections.py Outdated
Comment thread src/adcp/decisioning/pg/buyer_agent_registry.py Outdated
Comment thread src/adcp/decisioning/upstream.py
Comment thread src/adcp/decisioning/proposal_store.py Outdated
@bokelley
bokelley force-pushed the codex/security-identity-isolation branch from 27a7998 to 0c317c8 Compare August 5, 2026 02:02
@bokelley bokelley changed the title fix(identity): enforce tenant and credential isolation fix(identity)!: enforce tenant and credential isolation Aug 5, 2026
@aao-ipr-bot

aao-ipr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@bokelley
bokelley force-pushed the codex/security-identity-isolation branch from 0c317c8 to 66873af Compare August 5, 2026 02:06
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Aug 5, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the strength of the three-layer credential strip plus the account-keyed proposal store. Fail-closed isolation is the right principle everywhere this touches — a foreign id is now indistinguishable from a missing one on every observable channel.

Things I checked

  • Semver signal is correct. create_roster_account_store now requires an authorize callback — a breaking change to a public adcp.decisioning export. Shipped as fix(identity)!: with a BREAKING CHANGE: footer and a migration note in the PR body. Right shape.
  • Credential echo is closed at three layers. projections.py type-narrows notification_configs[].authentication.credentials to write-only, responses.py._strip_write_only_fields and account_projection._scrub_value now model_dump typed Pydantic models before scrubbing instead of trusting them through. ad-tech-protocol-expert confirmed against notification-config.json: authentication.credentials is writeOnly, schemes (the only required field) survives, and max_length=16 matches the schema's maxItems: 16 — no truncation of a valid response.
  • Proposal store rekey is consistent. (account_id, proposal_id) threads through every mutating method, _evict_expired_locked, _media_buy_index, and get_by_media_buy_id. The unscoped _record_key fallback returns None under collision (len(matches) == 1 only) — not a leak, confirmed by both reviewers and test_proposal_store.py:2619.
  • Ambiguous credential fails closed. buyer_agent_registry.py LIMIT 2 + len(rows) > 1 → None is the sole lookup path (resolve_by_credential routes through _sync_lookup_by_api_key_id), backed by a partial unique index whose migration (0003_unique_buyer_api_key.py) preflights duplicates and fails closed before touching indexes. code=57014-grade care on the create-before-drop ordering.
  • ACCOUNT_NOT_FOUND over PERMISSION_DENIED is spec-conformant. tenant_store.py collapses cross-tenant refs to ACCOUNT_NOT_FOUND, keeping PERMISSION_DENIED only for auth_tid is None. enums/error-code.json names the exact oracle this removes.
  • MCP session binding. auth.py pins scope["user"] to a one-way sha256(bearer) digest; test_mcp_stateful_session.py:2490 demonstrates the cross-principal hijack now 404s while the owner still 200s.
  • Reference-seller ownership gate. examples/v3_reference_seller/tests/test_smoke_broadening.py:660 (foreign-advertiser order) verifies _get_owned_order gates update_media_buy, get_media_buy_delivery, and provide_performance_feedback before any mutating upstream call, and the creative-id maps are keyed on (_account_scope, id).
  • Upstream error bodies no longer leak into AdcpError.message (upstream.py), tested at test_upstream_helpers.py:2991.

Follow-ups (non-blocking — file as issues)

  • Rate-limiter bucket table is shared across tenants (registry_cache.py). Both code-reviewer and security-reviewer landed on the same spot independently. Two edges: (1) the (tenant_id, \"__tenant_aggregate__\") aggregate collapses to a single global bucket whenever _current_tenant_id() is None — the pre-auth / resolve-tenant-from-credential path — so a deployment in that mode throttles all callers through one rps_per_tenant bucket; (2) the max_buckets=10_000 cap fails closed on a shared table, so one authenticated tenant cycling ~10k distinct agent_urls within its own budget can fill it and deny cold lookups platform-wide. This trades an unbounded-memory DoS for a bounded-but-shared one — net positive, and fail-closed beats fail-open — but the aggregate slot and the bucket cap should be per-tenant so one tenant can never starve another. Fast follow.
  • to_account_response doesn't model-strip governance_agents[].authentication (projections.py). security-reviewer flagged this PLAUSIBLE (couldn't execute to confirm the field survives the generated model). The framework builder paths are safe — responses.py and account_projection.strip_credentials_from_wire_result both strip governance authentication — so this only bites an adopter who calls to_account_response(acct).model_dump() directly. Add a _GovernanceAgentResponse override for symmetry with the notification/bank hardening, or a test asserting the drop.
  • app.py tenant rebind still fails open when the token has no home tenant. The new guard raises only when ctx.tenant_id is not None and != tenant.id; a tenant_id is None context is still rebound to the subdomain host. Not exploitable in the reference seller (every seeded bearer carries a tenant, and resolve now hard-requires a principal), but fail closed on None for posture.
  • Discovery requests carrying an invalid bearer are now rejected rather than served anonymously (auth.pyis_discovery_request(...) and not bearer). Intentional for session binding; worth a line in the PR body for clients that send stale tokens to capabilities endpoints.

Minor nits (non-blocking)

  1. Evicted upstream client close task is not retained (platform.py). _close_evicted_upstream_client does loop.create_task(client.aclose()) and discards the handle — the loop may GC the task before aclose() completes, leaking the httpx.AsyncClient pool. Hold the task in a set and add_done_callback(set.discard).
  2. _get_owned_order subscripts optional metadata (examples/.../platform.py). ctx.account.metadata[\"advertiser_id\"] raises KeyError (not AdcpError) for an account missing that key, so it escapes the except AdcpError and surfaces as a 500. Use .get(...) and fail closed with MEDIA_BUY_NOT_FOUND.
  3. _scrub_value recomputed 2–3× per element (account_projection.py). The governance_agents / notification_configs comprehensions call _scrub_value(a) in the isinstance test and again in each branch, re-running model_dump each time. Bind it to a local. Correct, just wasteful.

One observation: this rate limiter now defends against enumeration by trading an unbounded-memory DoS for a bounded, shared-fate one — worth closing that loop before an adopter finds the shared table for us.

Approved. Follow-ups noted.

@bokelley
bokelley force-pushed the codex/security-identity-isolation branch 3 times, most recently from 4bfe869 to c3d5714 Compare August 5, 2026 02:35
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Aug 5, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Closes six reachable isolation holes — cross-buyer account read/overwrite, cross-account media-buy access, credential round-trip through Pydantic response models, ambiguous-bearer identity resolution, and MCP session reuse — behind the right semver signal. fix(identity)!: + a BREAKING CHANGE: footer for the create_roster_account_store authorize requirement is the correct shape; fail-closed beats fail-open, and this consistently picks fail-closed.

Things I checked

  • Breaking-change signal. Public surface change is create_roster_account_store now requiring authorize. Landed under fix(identity)!: with a BREAKING CHANGE: footer and a compatibility note in the PR body. Correct. Other public deltas (DecisioningPlatform.upstream_client_cache_size, aclose_upstream_clients, AccountResponse.notification_configs) are additive.
  • Existence-hiding consistency. proposal_store.py rekeys every mutating method to (account_id, proposal_id) — no bare-proposal_id self._records.get survives, and _record_key returns None on ambiguous unscoped lookup rather than picking an arbitrary owner. tenant_store.py:384-397 collapses known-foreign and genuinely-missing to ACCOUNT_NOT_FOUND, leaving PERMISSION_DENIED only for auth_tid is None (tenant-independent, not an oracle). roster_store._is_authorized fails closed on None auth, callback exception, and any non-True return.
  • Credential uniqueness fails closed. pg/buyer_agent_registry.py LIMIT 2 + fetchall + reject-on-len(rows) > 1; unique partial index migration creates-before-drop so a legacy duplicate rolls the txn back inside the preflight; example _load_token_map rejects duplicates at boot. ad-tech-protocol-expert confirmed credentials/invoice_recipient.bank are genuinely writeOnly and max_length=16 mirrors the schema's maxItems: 16.
  • Session binding. server/auth.py — discovery-with-bearer now runs full validation; validated principal mirrored into request.scope["user"] with token=sha256(bearer). security-reviewer verified via test_mcp_stateful_session.py that a second valid bearer presenting another caller's Mcp-Session-Id gets 404 Session not found. sha256 of a high-entropy bearer is acceptable session identity.
  • mcp import safety. The lazy mcp.server.auth.* import in dispatch is safe — mcp>=2.0.0,<3.0 is a hard dependency (pyproject.toml:73), not an extra.

Follow-ups (non-blocking — file as issues)

  • sync_governance was left on the old code. ad-tech-protocol-expert flag: tenant_store.py upsert got the auth_tid is None (PERMISSION_DENIED) vs auth_tid != entry_tid (ACCOUNT_NOT_FOUND) split, but the sibling sync_governance gate (~L487) still returns PERMISSION_DENIED for both — so the cross-tenant enumeration oracle this PR set out to close stays open on the governance path. Not a regression from current behavior, so not a block, but the fix is asymmetric. Worth finishing.
  • Model re-validation round-trip proven only for create_media_buy. account_projection.strip_credentials_from_wire_result now does type(result).model_validate(_scrub_dict(result.model_dump(mode='python'))) for every method in CREDENTIAL_BEARING_METHODS, including sync_creatives/list_creatives/get_media_buys. test_smoke_broadening.py round-trips only create_media_buy. Per CLAUDE.md the creative response models carry the import-time-patched Format.assets open unions — the exact place a dump→validate would reshape a discriminated-union variant. No credential-leak risk (scrub happens on the dict before validate), but an availability/fidelity risk on the creative methods. Add round-trip assertions for the other three, or restrict the re-validate branch to the account-envelope methods.
  • Fire-and-forget eviction task can be GC'd before it closes the pool. decisioning/platform.py _close_evicted_upstream_client does loop.create_task(client.aclose()) without retaining a strong ref; the loop holds tasks weakly, so under cache churn past upstream_client_cache_size an evicted httpx pool may never close. Retain in an instance set with an add_done_callback(discard).

Minor nits (non-blocking)

  1. _get_owned_order can 500 instead of failing closed cleanly. examples/v3_reference_seller/src/platform.py reads ctx.account.metadata['advertiser_id'] with bracket access; an account legitimately lacking it raises KeyError → 500 rather than MEDIA_BUY_NOT_FOUND. No data served, but noisy — .get() and treat missing as not-owned.
  2. _scrub_value called two-to-three times per element. account_projection.py governance_agents/notification_configs comprehensions re-walk each sub-tree in the guard and again in the branch. Pure function, so correctness is fine — assign once.
  3. Three idioms for one invariant. projections.py now enforces the write-only strip via SchemaVariant+extra='forbid' nested projections, Field(exclude=True)+validator, and the recursive dict scrubbers. Three enforcement patterns for one contract is worth consolidating eventually.

Safe to merge.

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid isolation-hardening pass — fail-closed roster authz, account-scoped proposal keys, bounded rate-limiter and client-pool state, unique credential index with a fail-closed preflight. code-reviewer, security-reviewer, and ad-tech-protocol-expert all ran. One confirmed credential-leak arm holds me off approving; it's a strict subset of a pre-existing leak, not a regression, so I'm commenting rather than blocking — but I want your call on it before this merges.

Blocking-adjacent — please resolve or consciously defer

  1. strip_credentials_from_wire_result Pydantic branch leaks authorization (account_projection.py, new model arm ~L500-518). model_copy(update=updates) can only overwrite keys, never remove them. _scrub_dict omits the top-level authorization key whenever sanitize_account_authorization returns None — and that returns out or None (error_sanitization.py:62), i.e. None for an authorization block carrying only non-allowlisted downstream connection grants/credentials. That omission never enters updates, so model_copy returns the model with the original, unscrubbed authorization intact. An adopter returning a request-capable Account model (the exact case your test_response_builder_scrubs_notification_credentials_from_pydantic_models targets) from a CREDENTIAL_BEARING_METHODS handler, with an all-private authorization, echoes those connection credentials to the buyer. Both security-reviewer (M1, Medium) and code-reviewer (Major) landed here independently. The async terminal path (dispatch.py) dumps-then-scrubs and gets this right — the two paths diverge precisely on omitted keys. Fix is small: after building updates, force-remove keys _scrub_dict dropped (for k in dumped: if k not in scrubbed: updates[k] = None), or dump-scrub-revalidate for these methods.

    Why comment and not request-changes: before this PR the Pydantic arm passed through entirely unscrubbed, so this is a strict reduction of the leak surface, not a new hole; security-reviewer graded it Medium and not reachable in the framework's own deployed config. But it's a leak in the redaction function itself, and the docstring/tests now imply the Pydantic path is safe. What flips me to approve: fix it here, or confirm it's tracked as an immediate follow-up with the residual documented.

Things I checked

  • Breaking-change signal is correct. create_roster_account_store now requires authorizefix(identity)!: with a BREAKING CHANGE: footer and a Compatibility note in the body. Right semver shape for release-please.
  • Roster fail-closed (roster_store.py): auth_info is None → deny, callback exception → deny (logs type only), strict result is True, and list() filters every entry through _is_authorized. Required constructor arg means missing policy fails at boot, not at request time.
  • MCP session binding (auth.py:495-527): session token is sha256(bearer) — one-way, no bearer material in the session store; hashlib is imported (L77). test_stateful_session_is_bound_to_authenticated_principal proves hijack → 404, owner → 200. The discovery gate falling through to validation when a bearer is present is hardening, not a bypass.
  • Unique credential index (0003_unique_buyer_api_key.py, pg/buyer_agent_registry.py): create-before-drop ordering, duplicate preflight raises before touching indexes, offline mode skips the data query, and _sync_lookup_by_api_key_id fails closed (LIMIT 2 + len(rows) > 1 → None) so a legacy duplicate never selects an arbitrary identity.
  • Proposal store (proposal_store.py): every write/commit/consume/discard/media-buy path uses the strict (account_id, proposal_id) tuple; _media_buy_index re-composes the key correctly in get_by_media_buy_id. test_same_proposal_id_is_isolated_by_account confirms colliding ids across accounts stay isolated and an unscoped lookup returns None.
  • Upstream body redaction (upstream.py): untrusted response body no longer projected into AdcpError; test_error_response_body_is_not_exposed covers the echoed-bearer case. Remaining method/path in the message is internal routing, not attacker-supplied.
  • Protocol soundness (ad-tech-protocol-expert: sound): credentials and bank/invoice_recipient.bank are correctly spec-writeOnly, max_length=16 on notification_configs is the spec maxItems cap (not a truncation risk), extra='forbid' matches the already-closed generated Authentication base, and ACCOUNT_NOT_FOUND for cross-tenant rows is valid open-vocabulary existence-hiding.
  • Creative-id scoping (examples/.../platform.py, test_smoke_broadening.py:771): map rekeyed to (account_scope, creative_id) on every read/write site; test_creative_id_mapping_is_scoped_to_account confirms two accounts sharing a creative id no longer collide.

Follow-ups (non-blocking — file as issues)

  • model_copy without revalidation loses typed identity on changed fields (account_projection.py). Any top-level field scrubbing actually rewrites (e.g. governance_agents) comes back as plain dicts, so downstream typed attribute access breaks — contradicting the docstring's "preserve typed identity" goal, which only holds for unchanged fields. Serialization to wire is fine; typed consumers are not.
  • Registry max_buckets is one global budget across tenants (registry_cache.py). The aggregate bucket correctly stops within-tenant key rotation, but a single tenant filling the 10k-entry table (~100s at default rps) denies a brand-new tenant's first request, since _spend_locked fails closed on a new key and _prune_idle only reclaims idle>300s buckets. security-reviewer M2 / code-reviewer Minor. Consider a per-tenant bucket cap or a reserved aggregate slot.
  • Aggregate bucket is spent before the per-lookup bucket (registry_cache.py:570-573). A denied per-lookup request still drains the shared aggregate token, so a single-key flood starves other keys under the same tenant. Defensible as intended aggregate semantics — worth an explicit note in the PR body or spend per-lookup first.
  • Evicted upstream clients under async churn are never awaited (platform.py:638,646). loop.create_task(client.aclose()) is fire-and-forget (task not retained — GC-cancellable), and aclose_upstream_clients only drains the no-loop pending list, not loop-evicted pools. Past upstream_client_cache_size (128) while the loop runs, connection pools can leak FDs. Retain the task in a set with add_done_callback.
  • to_wire_account() omits notification_configs entirely (account_projection.py:219-252). The new AccountResponse path echoes them correctly, but the typed-dataclass emit path drops the field — and the spec says the seller MUST echo applied notification state on list_accounts. Confirm which path list_accounts actually uses. (ad-tech-protocol-expert open question.)
  • Unscoped _record_key single-match fallback (proposal_store.py:1281-1288). Not reachable from framework dispatch (every caller passes expected_account_id), but an adopter calling store.get(proposal_id) unscoped gets a foreign record when it's the only one. Defense-in-depth: return None unconditionally for the unscoped arm.

Minor nits (non-blocking)

  1. Stray comment in the MCP binding block. auth.py# (client_id, issuer, subject) authorization context. dangles after the assignment it used to describe. Drop it.

Fix or defer item 1 and I'll approve.

Comment thread examples/v3_reference_seller/tests/test_smoke.py Fixed
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes Aug 5, 2026

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the strength of the isolation coverage plus the test matrix. This is the right shape: tenant/account scope moves into the store key itself (proposal records keyed (account_id, proposal_id), seller shadow state keyed (account_scope, order_id)), so the record.account_id != expected guards become structurally unnecessary rather than one-more-check-to-forget. Fail-closed beats fail-open at every seam here.

Things I checked

  • Breaking-change signal is correct. fix(identity)!: plus a BREAKING CHANGE: footer for create_roster_account_store's required authorize callback. ProposalStore.get's expected_account_id also flips str | None = None → required str — same commit, covered by the ! (see follow-up on the migration note).
  • roster_store fail-closed. _is_authorized denies on auth_info is None, denies on callback exception (logged, not re-raised), requires result is True; resolve collapses foreign-but-existing and unknown to the same None; list returns [] on missing auth. authorize is a required factory arg, so missing policy fails at boot. security-reviewer: sound.
  • tenant_store existence-hiding. _classify_entry_access collapses unknown + cross-tenant to ACCOUNT_NOT_FOUND, keeps missing-auth/callback-fail at PERMISSION_DENIED. Closes the pre-PR oracle where an existing cross-tenant ref returned PERMISSION_DENIED. ad-tech-protocol-expert: no wire-row regression — error.code is an open string, action/status/errors unchanged.
  • registry_cache buckets. Tenant partitioning is the isolation boundary; max_buckets applies inside each _TenantBuckets, so no cross-tenant crowding. Rotating lookup keys still spend an aggregate token (_spend_lookup_locked reserves one slot via len(lookups) >= max_buckets - 1), and the constructor rejects bucket_idle_ttl_seconds < burst/rps so idle eviction can't reset a partially-refilled budget. Bucket math and the 16-bounded prune loops are correct.
  • buyer_agent_registry. LIMIT 2 + reject-on->1 fails closed for an ambiguous credential even pre-migration; unique partial index + Alembic 0003 preflight that raises before swapping indexes; app-side token-loader duplicate check. Defense in depth, three layers.
  • enforce_authenticated_tenant. Presence (not truthiness) of AUTHENTICATED_TENANT_METADATA_KEY is load-bearing — an authenticated principal with tenant_id=None is None != routed, so it can't be rebound to a foreign host. Both keys are written after the principal-metadata spread, so a hostile metadata payload can't forge them. test_bearer_context_rejects_cross_tenant_rebinding covers both the mismatch and the None arm.
  • mcp_sessions first-claim. _authorize_existing_session is atomic under _session_creation_lock; unknown session id fails closed; unbound sessions are reusable-anonymous only when not auth_middleware_ran or anonymous_discovery. Bindings cleaned on all three teardown paths (terminated / idle / error). No cross-principal hijack.
  • credential scrub, both paths. notification_configs[i].authentication.credentials stripped on the loose-dict path (_scrub_notification_config_dict) and the typed path (strip_credentials_from_wire_result dump → _scrub_dict → top-level diff → model_copy(update=...)), so nothing reaches the idempotency replay cache or webhook emit with the secret intact. types/projections.py _NotificationConfigResponse: ad-tech-protocol-expert confirms credentials is spec-write-only, max_length=16 matches the canonical Account.notification_configs bound, and extra='forbid' mirrors the base model — forward-compat safe.
  • account_projection model path. model_dump(mode='python') flattens nested models, so the value != dumped.get(key) diff only fires on genuinely-scrubbed top-level keys; for create_media_buy/update_media_buy there are no top-level credential fields, so updates is empty and model_copy(update={}) preserves packages/affected_packages model identity — asserted by the two new type(scrubbed[...]) is type(result[...]) tests. No caller mutation.
  • upstream body suppression. _project_status no longer interpolates the untrusted response body; no info-leak regression.
  • platform.py _get_owned_order (reference seller) fails closed on both foreign advertiser and missing shadow-state, keeping foreign and nonexistent order ids indistinguishable — the correct posture. Verified the get_media_buys filter guarantees buy_key presence before the direct index read.

Follow-ups (non-blocking — file as issues)

  • ACCOUNT_NOT_FOUND recovery regression in upstream.py. _project_status's non-default-404 branch now emits a blanket recovery="correctable". ACCOUNT_NOT_FOUND is terminal per the spec Recovery enum and per this SDK's own AccountNotFoundError (errors.py). The reference seller defaults not_found_code='ACCOUNT_NOT_FOUND' on upstream account fetches, so a 404 there now hands buyers a "correct and retry" hint on a terminal error — a change from main's terminal. Route known not-found codes through their canonical classification (as the default arm already does via MediaBuyNotFoundError) rather than one blanket value. ad-tech-protocol-expert flagged this as the one wire divergence; self-correcting, confined to the non-default path, doesn't block.
  • Evicted upstream pool can leak. _close_evicted_upstream_client's running-loop branch does loop.create_task(client.aclose()) and drops the handle; asyncio holds only a weak ref, and the evicted client isn't added to _upstream_clients_pending_close, so aclose_upstream_clients() won't reclaim it either. Needs >128 distinct URL/auth/header/timeout combos under a running loop to bite, but it's the exact leak vector. Retain the task in a set with a discard callback, or append to the pending-close list unconditionally.
  • Migration note completeness. The BREAKING CHANGE: footer names only the roster authorize arg. ProposalStore.get's now-required expected_account_id is an equally breaking Protocol signature change for adopters who implement or call the store. Enumerate both in the migration prose.
  • enforce_authenticated_tenant is a matched pair with the routed-tenant pin. The example's _build_context_factory pins tenant_id to the routed host and is only safe because main() also wires middleware=[enforce_authenticated_tenant]. An adopter who copies the pin without the middleware reintroduces the cross-tenant rebind. Worth a prominent doc callout that the two ship together.
  • _stripped tenant-less existence seam (pre-existing, not introduced here): in _classify_entry_access the auth_tid is None → PERMISSION_DENIED check sits after the entry_account is None → ACCOUNT_NOT_FOUND check, so a tenant-less authenticated caller can still distinguish exists-somewhere from nonexistent. Unreachable when bearer auth maps every principal to a tenant; tighten by checking auth_tid is None before consulting resolve_by_ref.

Minor nits (non-blocking)

  1. get_media_buys str/int key asymmetry. The upstream_orders filter keys on str(o.get("order_id")) but the loop reads self._buy_state[buy_key] off the raw order["order_id"]. Safe only because upstream ids are strings today; an integer order_id upstream would KeyError → 500. Wrap the read in str(...) for symmetry.
  2. _scrub_value evaluated up to 3× per element in _scrub_dict's governance_agents/notification_configs ternaries. Compute once into a local. Pure inefficiency.
  3. Unbounded tenant-partition count in registry_cache._buckets (only 16-per-call idle-pruned). Fine while tenant_id is trusted/routed; a one-line comment asserting that would age well.

code-reviewer: clean, no blockers. security-reviewer: CLEAR, no High. ad-tech-protocol-expert: sound-with-caveats (the upstream.py recovery value above). 261 identity + 59 reference-seller/migration tests green.

Approved. Follow-ups noted.

Comment thread examples/v3_reference_seller/tests/test_smoke.py Fixed
Comment thread examples/v3_reference_seller/tests/test_smoke_broadening.py Fixed
Comment thread examples/v3_reference_seller/tests/test_smoke_broadening.py Fixed
Comment thread examples/v3_reference_seller/tests/test_smoke_broadening.py Fixed
BREAKING CHANGE: create_roster_account_store now requires an authorize callback.
@bokelley
bokelley force-pushed the codex/security-identity-isolation branch from 4c3d25e to a4d8c71 Compare August 5, 2026 04:43

@aao-ipr-bot aao-ipr-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe to merge. Tenant/credential isolation done at the right layer — the record keys themselves carry the account boundary, so a cross-tenant id can't index another tenant's state, and the redaction runs at the one canonical wire boundary instead of N hand-rolled strippers.

Correctly shipped as fix(identity)!: with a BREAKING CHANGE: footer. Three experts ran — security-reviewer: sound, no new hole; code-reviewer: no blockers; ad-tech-protocol-expert: sound-with-caveats, no wire-shape divergence.

Things I checked

  • Proposal store, both impls. In-memory re-keyed to (account_id, proposal_id) tuples across every mutator (commit/try_reserve/finalize/release/mark_consumed/discard/get_by_media_buy_id); _media_buy_index stays (account_id, media_buy_id) and lines up. PG get() unconditionally runs WHERE account_id = %s AND proposal_id = %s — the old unscoped diagnostic branch is gone. No residual equality-check enumeration path.
  • Breaking-API audit. create_roster_account_store now requires authorize, and ProposalStore.get's expected_account_id flipped optional→required — both real public-surface breaks, both covered by the !. The only in-repo get callers (proposal_lifecycle.py, proposal_dispatch.py) already pass expected_account_id, so no internal breakage.
  • Roster fail-closed. _is_authorized denies on None auth, on callback exception (caught/logged), and on any non-True return; resolveNone, list[]. Required factory arg means misconfig fails at construction, not open at runtime.
  • Rate-limiter isolation. max_buckets applies inside each _TenantBuckets, so one tenant can't evict another's aggregate. The bucket_idle_ttl >= burst/rps constructor guard makes idle-evict-then-recreate-full equivalent to a full refill — no credential-stuffing oracle. Aggregate charged only when the lookup tier didn't already exhaust; no double-charge.
  • enforce_authenticated_tenant presence-not-truthiness. An authenticated principal with tenant_id=None yields the key present-with-None, so None != routed_id denies rebinding to any host. Authoritative metadata is written after the **principal_metadata spread in auth_context_factory, so a smuggled key can't spoof the check. Keys are tenant ids, not credential-shaped — no ctx_metadata prohibition trip.
  • MCP session first-claim. _authorize_existing_session under _session_creation_lock: claimed session requires requestor == owner; an anonymous non-discovery caller on an auth-enabled server is denied. All three _session_owners.pop sites now pair with _session_bindings.pop — no stale-binding leak.
  • Upstream error info-leak. _project_status dropped the body_text param entirely and _request no longer reads response.text — no untrusted upstream body reaches an AdcpError message.
  • get_media_buys largest-file read. Confirmed the shadow-state re-key is consistent across create/update/sync_creatives/delivery/feedback; the pre-index LIMIT 2 ambiguity guards in both the example registry and pg/buyer_agent_registry._sync_lookup_by_api_key_id fail closed before the unique-index migration lands.

Follow-ups (non-blocking — file as issues)

  • UpstreamClientPool._retired grows unbounded under auth-identity churn. Eviction retires (doesn't close) clients, and only aclose() drains them. A single-transport seller minting a fresh DynamicBearer/auth object per request gets a new id(auth) cache key every miss → steady socket/FD growth with no shutdown drain (the on_shutdown wiring is transport="both" only). Either close-on-evict when there's no in-flight borrower, or document the "stable auth identity per tenant" assumption. (upstream.py, serve.py)
  • BREAKING CHANGE footer names only the roster arg. The ProposalStore.get required-arg flip is a second break not called out in the footer prose or changelog. ! covers the semver signal; the migration note should mention both.
  • Non-default 404 recovery is hardcoded correctable. ad-tech-protocol-expert: valid today (every in-repo call threads MEDIA_BUY_NOT_FOUND), but ACCOUNT_NOT_FOUND is terminal in error-code.json — if an adopter ever threads a terminal not-found code through this branch, recovery (authoritative on the wire) would contradict the code. Derive recovery from the code. (upstream.py _project_status)
  • New public export UpstreamClientPool in adcp.decisioning — additive, but check README/AGENTS.md drift.

Minor nits (non-blocking)

  1. tenant_store docstring overstates the spec. It calls the unknown/cross-tenant→ACCOUNT_NOT_FOUND collapse "the AdCP 3.1 sync-governance-response existence-hiding posture." ad-tech-protocol-expert couldn't find that mandate in the response schemas — it's a defensible chosen posture consistent with the anti-enumeration guidance, not schema-prescribed. Soften to "our chosen posture."
  2. Session ownership token interpolates principal_identity raw while repr-ing tenant_id: sha256(f"{principal}\0{tenant!r}"). Auth principals are URLs/domains and never contain NUL, so not exploitable — repr-ing both removes the theoretical separator ambiguity. (mcp_sessions.py _requestor)
  3. _account_scope uses .get("tenant_id") + raise while _get_owned_order direct-indexes ctx.account.metadata["advertiser_id"]. Both keys are guaranteed by the account store so neither crashes; the two styles just read inconsistently. (platform.py)

The notification_configs write-only credential strip is the right shape: credentials is genuinely writeOnly in notification-config.json, max_length=16 mirrors the schema's maxItems, and the response projection's extra="forbid" is a deliberate fail-closed on a deprecated, credential-bearing object — stricter than ingest, which is correct on the emit side.

Approving on the strength of the record-key isolation plus the single-boundary redaction scrubber. Follow-ups noted.

@aao-ipr-bot

aao-ipr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

@aao-ipr-bot

aao-ipr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

⚠️ Argus review could not complete

The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.

View workflow run

This is an automated message from the Argus AI review workflow.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants