fix(identity): enforce tenant and credential isolation - #1001
Conversation
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| """The request host cannot replace the tenant authenticated by the token.""" | ||
| import src.app as app_module |
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| """An account id owned by another buyer is indistinguishable from missing.""" | ||
| import src.platform as platform_module |
| async def test_account_store_upsert_cannot_overwrite_another_buyers_account( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| import src.platform as platform_module |
| import sqlalchemy as sa | ||
| from alembic import context, op | ||
|
|
||
| revision: str = "0003" |
| from alembic import context, op | ||
|
|
||
| revision: str = "0003" | ||
| down_revision: str | None = "0002" |
|
|
||
| revision: str = "0003" | ||
| down_revision: str | None = "0002" | ||
| branch_labels: str | Sequence[str] | None = None |
| revision: str = "0003" | ||
| down_revision: str | None = "0002" | ||
| branch_labels: str | Sequence[str] | None = None | ||
| depends_on: str | Sequence[str] | None = None |
There was a problem hiding this comment.
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 includingget_by_media_buy_idand_record_key's ambiguous-unscoped resolver. No bare-proposal_id lookup survives.code-reviewer:registry_cache.pycharges 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_clienthandles the no-running-loop case.security-reviewer: sound, no High. Credential redaction closes both the loose-dict and the Pydantic-model path inresponses.py(_serializenow dumps then scrubs), confirmed bytest_response_builder_scrubs_notification_credentials_from_pydantic_models. Idempotency replay cache dumps-then-scrubs atdispatch.py:1855.security-reviewer: MCP session binding (server/auth.py) is enforced by a real ASGI integration test — a second valid bearer replaying alice'sMcp-Session-Idgets 404, alice's own bearer gets 200.ad-tech-protocol-expert: sound-with-caveats.notification_config.jsonmarksAuthentication.credentialswrite-only (same class asBusinessEntity.bank), so stripping on the response path is spec-correct.schemes: min_length=1 max_length=1matches the generated model exactly.ad-tech-protocol-expert: cross-tenantsync_accounts→ACCOUNT_NOT_FOUNDis the spec-correct existence-hiding code, aligningupsertwith the pre-existingresolvebehavior. Both codes are terminal, so conforming buyers' retry logic is unaffected — not a breaking wire change.- Migration
0003preflightsHAVING COUNT(*) > 1and fails closed before touching indexes; creates the unique index before dropping the old one so a duplicate rolls back. RuntimeLIMIT 2+fetchalldefense-in-depth agrees with the DB guarantee. upstream.py:_project_statusno longer takesbody_text; the only other error path interpolates aJSONDecodeErrorposition string, not the body.test_error_response_body_is_not_exposedcovers it.
Follow-ups (non-blocking — file as issues)
- Timing-oracle equalization for the new
ACCOUNT_NOT_FOUNDbranch.ad-tech-protocol-expertflagged that cross-tenant rejection moved out of thePERMISSION_DENIEDbranch that_permission_denied_budget.pytiming-clamps. The cross-tenant path does more work (resolve succeeds, then tenant compare fails) than the unknown-ref path (earlyNone). Confirm the clamp covers the cross-tenantACCOUNT_NOT_FOUNDbranch, or existence-hiding leaks through timing. - Raw bearer in
scope['user'].server/auth.pysetsAccessToken(token=bearer, ...)while the adjacent comment claims only derived context is stored. No round-trip today (the framework readsrequest.state, keepscredential=None), but a logging middleware serializingscope['user']would surface the plaintext token. Pass a hash/opaque id since only the derived identity drives binding. - Unguarded
mcpimport on the authenticated path. Thefrom mcp.server.auth...imports inserver/auth.pyrun 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 bearernarrowing means a client attaching an expired token toget_adcp_capabilities/initializenow 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 inexamples/.../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)
- Stale rationale comment.
account_projection.py:457-467still saysstrip_credentials_from_wire_resultpasses Pydantic models through because "response-side codegen shapes don't define authentication/bank." The base model now structurally carriesnotification_configs[i].authentication.credentials— that's whyprojections.pyneeded_NotificationConfigResponse. The load-bearing safety is the handler-layermodel_dump, not schema structure; point the comment there. SchemaVariantonlistdropsmax_length=16. Perad-tech-protocol-expert, the marker is correctly required here (invariantlist[...]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.
Summary
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
origin/mainCompatibility
Roster stores now require an authorization callback at construction. The migration preflights duplicate credential hashes and fails safely before creating the unique index.