feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785
feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785bokelley wants to merge 1 commit into
Conversation
Final stage of issue #350 — wires the BrandAuthorizationResolver (landed in PR #770) into the framework's dispatch path so adopters can opt into per-brand authorization the same way they opt into Tier 2 commercial-identity gating today. Surface (serve()): brand_authz_resolver: BrandAuthorizationResolver | None = None brand_identity_resolver: Callable[[Account, BuyerAgent | None], BrandIdentity | None | Awaitable[BrandIdentity | None]] | None = None Both must be wired together — partial wiring raises ValueError at boot (a resolver without an extractor has no brand to check; an extractor without a resolver has nothing to do). Same opt-in shape as Tier 2's buyer_agent_registry. Dispatch sequence (_resolve_account): 1. Tier 2 — _resolve_buyer_agent (existing) — registry-resolved BuyerAgent on ctx.metadata. Suspended/blocked short-circuit here. 2. AccountStore.resolve — existing. 3. NEW: Tier 3 — _enforce_brand_authorization. Extractor pulls brand identity from (Account, BuyerAgent); resolver answers "is this agent authorized for THIS brand?"; rejection raises PERMISSION_DENIED with the cross-tenant-safe denial message. 4. Platform method runs. The gate is a no-op when no BuyerAgent has been resolved (Tier 2 not wired) — brand authorization without a subject identity has nothing to authorize. Denial surface: PERMISSION_DENIED with recovery=correctable, identical message bytes to the Tier 2 unrecognized-agent rejection so the wire-level error.message is not a side channel discriminating the two gates. Details defaults to {} (omit-on-unestablished-identity rule, same as Tier 2). Verifier-side spec-shape codes (request_signature_brand_origin_mismatch / _agent_not_in_brand_json) belong to the verifier path that issue #776 will plumb. Timing-oracle defense: reuses PermissionDeniedBudget from Tier 2. The brand.json fetch path's natural variance is larger than the registry-lookup variance Tier 2 absorbs, so the budget here is a floor — it prevents the cache-hit-authorized vs cache-hit-rejected paths from leaking timing-distinguishable decisions on the happy-cache path (the common case). New files: - src/adcp/decisioning/brand_authz_gate.py — BrandIdentity dataclass, BrandIdentityResolver callable Protocol, BrandAuthorizationGate bundle pairing (resolver, extractor) atomically. - tests/test_decisioning_brand_authz_dispatch.py — 11 tests covering boot validation, authorized path, denied path, no-buyer-agent skip, extractor-returns-None skip, async extractor, brand_id propagation, three-tier ordering conformance (Tier 2 rejects suspended BEFORE Tier 3 resolver consulted). Modified: - src/adcp/decisioning/handler.py — _enforce_brand_authorization helper; PlatformHandler accepts brand_authorization_gate; _resolve_account invokes the gate after accounts.resolve. - src/adcp/decisioning/serve.py — accepts the two opt-in kwargs on both create_adcp_server_from_platform and serve, bundles them into BrandAuthorizationGate, threads to PlatformHandler. Boot validation on partial wiring. Tests: 11 new (test_decisioning_brand_authz_dispatch). Full impacted surface (1244 tests across decision/buyer_agent/brand/registry/dispatch keyword grep) remains green. ruff + mypy clean. Deferred to issue #776: plumbing the JWKS-source discriminant (brand.json walk vs publisher pin) through BrandJsonJwksResolver so the verifier path can invoke check_key_origin_consistency with the carve-out per spec #3690 step 7. Verifier-side integration, separate concern from this dispatch-layer gate. Closes #350. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| from typing import TYPE_CHECKING, Union | ||
|
|
||
| if TYPE_CHECKING: | ||
| from adcp.decisioning.registry import BuyerAgent |
|
|
||
| if TYPE_CHECKING: | ||
| from adcp.decisioning.registry import BuyerAgent | ||
| from adcp.decisioning.types import Account |
| from adcp.decisioning.brand_authz_gate import ( | ||
| BrandAuthorizationGate, | ||
| BrandIdentityResolver, | ||
| ) |
There was a problem hiding this comment.
LGTM. Closes the v3-identity roadmap cleanly. Follow-ups below.
The dispatch-layer composition is right. Tier 2 → accounts.resolve → Tier 3 ordering is pinned by the three-tier conformance test; the gate is a no-op when Tier 2 isn't wired (subject-less authorization has nothing to authorize); partial wiring fails closed at boot via the XOR check. The BrandAuthorizationGate frozen-bundle pattern makes "both or neither" unrepresentable past the seam — right shape.
The cross-tenant onboarding-oracle defense holds: _denied_message at src/adcp/decisioning/handler.py:546-551 (Tier 2) and :689-694 (Tier 3) are byte-identical, details is omitted on both denial paths, recovery="correctable" matches the spec's enumMetadata for PERMISSION_DENIED. The verifier-side request_signature_brand_* codes correctly stay out of this dispatch-layer gate — those belong to #776.
Things I checked
- Dispatch ordering at
handler.py:1232→:1247→:1270-1279._prime_auth_contextstashes the resolved buyer-agent onctx.metadata[_BUYER_AGENT_METADATA_KEY];accounts.resolveruns; Tier 3 reads buyer_agent back fromctx.metadata. Reads after writes, correct. - Cross-tier message-byte parity: Tier 2 at
handler.py:546-551and Tier 3 at:689-694are verbatim identical strings, bothrecovery="correctable", both omitdetails. Pinned. - Boot XOR at
src/adcp/decisioning/serve.py:340-347.(brand_authz_resolver is None) != (brand_identity_resolver is None)is symmetric; diagnostic names both kwargs. inspect.isawaitableathandler.py:669is the correct check overasyncio.iscoroutine— covers async-def,asyncio.Future, and custom__await__-implementing awaitables. The extractor signature declaresAwaitable[...], soisawaitableis the right discriminator.- Public-API hygiene:
BrandIdentity,BrandIdentityResolver,BrandAuthorizationGateare NOT re-exported fromsrc/adcp/decisioning/__init__.py(grep-verified). Adopters import fromadcp.decisioning.brand_authz_gatedirectly. Conventional-commit prefixfeat(decisioning):is correct — additive opt-in kwargs, non-breaking. - Test-plan honesty: the "downstream import smoke / new symbols not re-exported" item is satisfied (verified). The "message-byte-identity property" item is satisfied by the byte-equality check above.
Follow-ups (non-blocking — file as issues)
agent_typeplumbing tois_authorized.handler.py:677-681calls the resolver withoutagent_type=.BuyerAgentatsrc/adcp/decisioning/registry.py:140doesn't carry the field, so the framework has no source to plumb it from today. Single-role brand.json entries work; a brand.json that lists the sameagent_urlunder multipletypes (a sales agent + a creative agent at the same endpoint) resolves asagent_ambiguousin_find_listed_agentsand fails closed. Fail-closed is the correct posture for the gap, but multi-role adopters will see false denials untilagent_typeis plumbed (either as aBuyerAgentextension or off the wire pinhole). Track separately.- Boot warning when Tier 3 is wired without Tier 2.
handler.py:665-666returns immediately whenbuyer_agent is None. The docstring legitimizes the read-only-audit-path use case, but the same code path silently disables the gate for misconfigured adopters who forgotbuyer_agent_registry=. A one-shotUserWarningatserve.py:349would catch the misconfig without breaking the audit-path intent. - Extractor exception bypasses the timing budget.
gate.extract_identity(...)runs athandler.py:668BEFOREPermissionDeniedBudget()is constructed at:676. An adopter extractor that raises on cache-miss-vs-hit (network exception, missing-key onaccount.metadata) leaks its own latency to the wire — not absorbed into the budget. Adopters with sync metadata lookups pay nothing; adopters with remote extractors are exposed. Either wrap the extractor call intry/exceptinside the budget window, or hoist the budget above the extractor and pay the no-brand-skip latency floor on every request. Track separately; theagent_typeissue feels like the same follow-up. - Extract
_denied_messageto a module constant. The byte-equality property athandler.py:546-551vs:689-694is load-bearing for the onboarding-oracle defense; the string is duplicated verbatim. A module constant plus anassert tier2_msg == tier3_msgtest intests/test_decisioning_brand_authz_dispatch.pywould pin the invariant against future drift. Notable that the comment block above each site says "MUST be identical" but the strings are hand-copied. - Test coverage for extractor-raises and resolver-raises. The 11 tests cover the happy paths and the documented skip paths; neither exception-on-the-edge case is exercised. Add once.
Minor nits (non-blocking)
- Hoist
import inspectout of_enforce_brand_authorization.handler.py:660does a per-call import on the hot dispatch path. Same critique applies to the localfrom adcp.decisioning._permission_denied_budget import PermissionDeniedBudgetandfrom adcp.decisioning.types import AdcpErrortwo lines down — Tier 2's helper at:506-512already does the same dance for consistency, but neither needs to.
LGTM. Follow-ups noted.
Summary
Final stage of issue #350 — closes the v3-identity roadmap by wiring the
BrandAuthorizationResolverfrom PR #770 into the framework's dispatch path. Adopters can now opt into per-brand authorization via two newserve()kwargs, matching the opt-in shape of Tier 2'sbuyer_agent_registry.After this lands, all three v3 identity tiers are framework-enforced when the adopter wires them:
verify_starlette_request+BrandJsonJwksResolver(PR #770)serve(buyer_agent_registry=...)(shipped earlier)serve(brand_authz_resolver=..., brand_identity_resolver=...)(this PR)Surface
Both must be wired together. Partial wiring raises
ValueErrorat boot — a resolver without an extractor has no brand to check; an extractor without a resolver has nothing to do.Dispatch sequence
The gate is a no-op when no
BuyerAgenthas been resolved (Tier 2 not wired) — brand authorization without a subject identity has nothing to authorize.Denial surface
PERMISSION_DENIEDwithrecovery="correctable", identical message bytes to the Tier 2 unrecognized-agent rejection so the wire-levelerror.messageis not a side channel discriminating the two gates.detailsdefaults to{}(omit-on-unestablished-identity rule, same as Tier 2).The spec-shape codes (
request_signature_brand_origin_mismatch/_agent_not_in_brand_json) belong to the verifier-side wire path — issue #776 will plumb theBrandJsonJwksResolversource discriminant through to the verifier socheck_key_origin_consistency(landed in PR #775) can be invoked with the publisher-pin carve-out per spec #3690 step 7. That's verifier-side integration, separate concern from this dispatch-layer gate.Timing-oracle defense
Reuses the
PermissionDeniedBudgetfrom Tier 2. The brand.json fetch path's natural variance (cache hit vs miss vs stale-on-error) is larger than the registry-lookup variance Tier 2 absorbs, so the budget here is a floor — it prevents the cache-hit-authorized vs cache-hit-rejected paths from leaking timing-distinguishable decisions on the happy-cache path (the common case).What's in this PR
src/adcp/decisioning/brand_authz_gate.py(new)BrandIdentitydataclass (domain + optional id),BrandIdentityResolvercallable type (sync OR async),BrandAuthorizationGatefrozen bundle pairing (resolver, extractor) atomicallysrc/adcp/decisioning/handler.py_enforce_brand_authorizationhelper next to_resolve_buyer_agent; PlatformHandler acceptsbrand_authorization_gate;_resolve_accountinvokes the gate afteraccounts.resolvesrc/adcp/decisioning/serve.pycreate_adcp_server_from_platformandserve. Bundles intoBrandAuthorizationGate. Boot validation raisesValueErroron partial wiringtests/test_decisioning_brand_authz_dispatch.py(new)Tests
11 new tests covering:
ValueError; neither wired is back-compat.brand_id: propagation through to resolver.PERMISSION_DENIEDwith the cross-tenant-safe message; platform method does NOT run.details == {}: omit-on-unestablished-identity per Tier 2 parity.Full impacted surface (1244 tests across
decision/buyer_agent/brand/registry/dispatchkeyword grep) remains green. ruff + mypy clean.What's deferred (not blocking)
BrandJsonJwksResolverso the verifier path can invokecheck_key_origin_consistencywith the publisher-pin carve-out per spec #3690 step 7. Verifier-side integration, separate concern from this dispatch-layer gate.idna(IDNA 2008) across signing/ #777 — package-wide IDNA 2003 → IDNA 2008 migration across the fourhost.encode("idna")callsites. Separate spec-conformance pass.Closes #350.
Test plan
adcp.decisioning— by design, they're framework-internal until adopters need them)🤖 Generated with Claude Code