Skip to content

feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785

Open
bokelley wants to merge 1 commit into
mainfrom
bokelley/issue-350-stage-5
Open

feat(decisioning): Tier 3 brand-authz dispatch gate (#350 stage 5, closes #350)#785
bokelley wants to merge 1 commit into
mainfrom
bokelley/issue-350-stage-5

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Final stage of issue #350 — closes the v3-identity roadmap by wiring the BrandAuthorizationResolver from PR #770 into the framework's dispatch path. Adopters can now opt into per-brand authorization via two new serve() kwargs, matching the opt-in shape of Tier 2's buyer_agent_registry.

After this lands, all three v3 identity tiers are framework-enforced when the adopter wires them:

Tier What Surface
1 Cryptographic identity (signed request → JWKS via brand.json) verify_starlette_request + BrandJsonJwksResolver (PR #770)
2 Commercial recognition (BuyerAgent registry) serve(buyer_agent_registry=...) (shipped earlier)
3 Per-brand authorization (agent ↔ brand binding) serve(brand_authz_resolver=..., brand_identity_resolver=...) (this PR)

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.

Dispatch sequence

inbound request
  → Tier 2: registry.resolve_by_(agent_url|credential) → BuyerAgent | None
       ── suspended/blocked → AGENT_SUSPENDED / AGENT_BLOCKED (short-circuit)
  → AccountStore.resolve(ref, auth_info) → Account
  → NEW: Tier 3 brand-authz gate
       ── extract_identity(account, buyer_agent) → BrandIdentity | None
       ── (if None: skip — adopter says no brand to bind against)
       ── resolver.is_authorized(agent_url, brand_domain, brand_id?) → bool
       ── (if False: PERMISSION_DENIED, identical message bytes to Tier 2)
  → 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).

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 the BrandJsonJwksResolver source discriminant through to the verifier so check_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 PermissionDeniedBudget from 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

File Change
src/adcp/decisioning/brand_authz_gate.py (new) BrandIdentity dataclass (domain + optional id), BrandIdentityResolver callable type (sync OR async), BrandAuthorizationGate frozen bundle pairing (resolver, extractor) atomically
src/adcp/decisioning/handler.py _enforce_brand_authorization helper next to _resolve_buyer_agent; PlatformHandler accepts brand_authorization_gate; _resolve_account invokes the gate after accounts.resolve
src/adcp/decisioning/serve.py New kwargs on both create_adcp_server_from_platform and serve. Bundles into BrandAuthorizationGate. Boot validation raises ValueError on partial wiring
tests/test_decisioning_brand_authz_dispatch.py (new) 11 tests

Tests

11 new tests covering:

  • Boot validation: resolver-only and extractor-only both raise ValueError; neither wired is back-compat.
  • Authorized path: extractor runs → resolver runs → platform method runs.
  • Authorized with brand_id: propagation through to resolver.
  • Denied path: rejection emits PERMISSION_DENIED with the cross-tenant-safe message; platform method does NOT run.
  • Denied details == {}: omit-on-unestablished-identity per Tier 2 parity.
  • No-buyer-agent skip: Tier 3 silently passes when Tier 2 isn't wired.
  • Extractor-returns-None skip: adopter signals "no brand to bind against".
  • Async extractor: framework awaits when the return is an awaitable.
  • Three-tier conformance: Tier 2 rejects a suspended agent BEFORE the Tier 3 resolver is consulted. Pins the ordering against future refactor.

Full impacted surface (1244 tests across decision/buyer_agent/brand/registry/dispatch keyword grep) remains green. ruff + mypy clean.

What's deferred (not blocking)

Closes #350.

Test plan

  • CI green on Python 3.10–3.13
  • Argus AI reviewer pass on the timing-oracle posture and the message-byte-identity property
  • Downstream import smoke (new symbols not re-exported from adcp.decisioning — by design, they're framework-internal until adopters need them)

🤖 Generated with Claude Code

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
Comment on lines +43 to +46
from adcp.decisioning.brand_authz_gate import (
BrandAuthorizationGate,
BrandIdentityResolver,
)
Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

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_context stashes the resolved buyer-agent on ctx.metadata[_BUYER_AGENT_METADATA_KEY]; accounts.resolve runs; Tier 3 reads buyer_agent back from ctx.metadata. Reads after writes, correct.
  • Cross-tier message-byte parity: Tier 2 at handler.py:546-551 and Tier 3 at :689-694 are verbatim identical strings, both recovery="correctable", both omit details. 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.isawaitable at handler.py:669 is the correct check over asyncio.iscoroutine — covers async-def, asyncio.Future, and custom __await__-implementing awaitables. The extractor signature declares Awaitable[...], so isawaitable is the right discriminator.
  • Public-API hygiene: BrandIdentity, BrandIdentityResolver, BrandAuthorizationGate are NOT re-exported from src/adcp/decisioning/__init__.py (grep-verified). Adopters import from adcp.decisioning.brand_authz_gate directly. Conventional-commit prefix feat(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_type plumbing to is_authorized. handler.py:677-681 calls the resolver without agent_type=. BuyerAgent at src/adcp/decisioning/registry.py:140 doesn'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 same agent_url under multiple types (a sales agent + a creative agent at the same endpoint) resolves as agent_ambiguous in _find_listed_agents and fails closed. Fail-closed is the correct posture for the gap, but multi-role adopters will see false denials until agent_type is plumbed (either as a BuyerAgent extension or off the wire pinhole). Track separately.
  • Boot warning when Tier 3 is wired without Tier 2. handler.py:665-666 returns immediately when buyer_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 forgot buyer_agent_registry=. A one-shot UserWarning at serve.py:349 would catch the misconfig without breaking the audit-path intent.
  • Extractor exception bypasses the timing budget. gate.extract_identity(...) runs at handler.py:668 BEFORE PermissionDeniedBudget() is constructed at :676. An adopter extractor that raises on cache-miss-vs-hit (network exception, missing-key on account.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 in try/except inside the budget window, or hoist the budget above the extractor and pay the no-brand-skip latency floor on every request. Track separately; the agent_type issue feels like the same follow-up.
  • Extract _denied_message to a module constant. The byte-equality property at handler.py:546-551 vs :689-694 is load-bearing for the onboarding-oracle defense; the string is duplicated verbatim. A module constant plus an assert tier2_msg == tier3_msg test in tests/test_decisioning_brand_authz_dispatch.py would 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)

  1. Hoist import inspect out of _enforce_brand_authorization. handler.py:660 does a per-call import on the hot dispatch path. Same critique applies to the local from adcp.decisioning._permission_denied_budget import PermissionDeniedBudget and from adcp.decisioning.types import AdcpError two lines down — Tier 2's helper at :506-512 already does the same dance for consistency, but neither needs to.

LGTM. Follow-ups noted.

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.

feat(signing): BrandAuthorizationResolver — per-brand agent authorization (Tier 3, gated on ADCP #3690)

1 participant