|
| 1 | +"""Roster-backed :class:`AccountStore` factory for ``resolution='explicit'`` |
| 2 | +publisher-curated platforms. |
| 3 | +
|
| 4 | +Use when the publisher curates accounts out-of-band (admin UI, config |
| 5 | +file, publisher-managed DB row) and buyers pass ``account_id`` on every |
| 6 | +request. The adopter holds a fixed allowlist of accounts; the SDK |
| 7 | +provides the :class:`AccountStore` plumbing. |
| 8 | +
|
| 9 | +Pairs with the existing reference adapters. Pick by asking *who creates |
| 10 | +the account?*: |
| 11 | +
|
| 12 | +* **Buyer self-onboards via** ``sync_accounts`` — multi-tenant adopter |
| 13 | + store (custom :class:`AccountStore` impl). Framework owns persistence; |
| 14 | + the buyer's first request to a tenant-scoped tool resolves from a |
| 15 | + prior sync. (LinkedIn, some retail-media operators.) |
| 16 | +* **Upstream OAuth API owns the roster** — :class:`ExplicitAccounts` |
| 17 | + with a loader that calls the upstream ``/me/adaccounts``. Returns |
| 18 | + whatever the OAuth bearer is authorized for. (Snap, Meta, TikTok.) |
| 19 | +* **Publisher ops curates the roster out-of-band** — |
| 20 | + :func:`create_roster_account_store` (this module). Adopter keeps the |
| 21 | + persistence layer; the SDK provides the AccountStore. (Most SSPs, |
| 22 | + broadcasters, and retail-media networks where AE/CSM provisions the |
| 23 | + account in an internal admin tool before the buyer ever calls.) |
| 24 | +
|
| 25 | +Design notes |
| 26 | +------------ |
| 27 | +
|
| 28 | +* **Roster IS the allowlist.** Auth-based filtering happens upstream of |
| 29 | + this layer — the framework's account-resolution gate enforces |
| 30 | + principal-vs-account scope. The store does not consult ``ctx`` to |
| 31 | + filter ``list``. |
| 32 | +* **Immutable post-construction.** The input dict is copied into an |
| 33 | + internal :class:`MappingProxyType` so external mutation of the |
| 34 | + caller's dict cannot widen the allowlist after the fact. Adopters |
| 35 | + who need a dynamic roster wrap :func:`create_roster_account_store` |
| 36 | + in their own factory and rebuild on change. |
| 37 | +* **Write paths fail closed per-entry.** :meth:`upsert` and |
| 38 | + :meth:`sync_governance` return ``PERMISSION_DENIED`` for every input |
| 39 | + entry rather than silently no-oping (which would lie to the buyer |
| 40 | + about their write succeeding) or operation-level raising (which |
| 41 | + would fail the whole batch on a single bad entry). Per-entry |
| 42 | + rejection matches the wire shape and lets the buyer correlate the |
| 43 | + failure to their request entry. |
| 44 | +* **Resolve returns None on miss.** ``{brand, operator}``-shaped refs |
| 45 | + and ref-less calls return ``None`` — publisher-curated platforms |
| 46 | + expect explicit ids. Adopters who need a synth tenant for |
| 47 | + ``list_creative_formats`` / ``provide_performance_feedback`` / |
| 48 | + ``preview_creative``, or natural-key resolution, wrap ``resolve``. |
| 49 | +
|
| 50 | +Example:: |
| 51 | +
|
| 52 | + from adcp.decisioning import Account, create_roster_account_store |
| 53 | +
|
| 54 | + store = create_roster_account_store( |
| 55 | + roster={ |
| 56 | + "acct_alpha": Account(id="acct_alpha", name="Alpha", status="active"), |
| 57 | + "acct_beta": Account(id="acct_beta", name="Beta", status="active"), |
| 58 | + }, |
| 59 | + ) |
| 60 | +""" |
| 61 | + |
| 62 | +from __future__ import annotations |
| 63 | + |
| 64 | +from collections.abc import Mapping |
| 65 | +from types import MappingProxyType |
| 66 | +from typing import TYPE_CHECKING, Any, Generic, Literal |
| 67 | + |
| 68 | +from typing_extensions import TypeVar |
| 69 | + |
| 70 | +from adcp.decisioning.helpers import ref_account_id |
| 71 | +from adcp.decisioning.types import ( |
| 72 | + Account, |
| 73 | + SyncAccountsResultRow, |
| 74 | + SyncGovernanceEntry, |
| 75 | + SyncGovernanceResultRow, |
| 76 | +) |
| 77 | + |
| 78 | +if TYPE_CHECKING: |
| 79 | + from adcp.decisioning.accounts import ResolveContext |
| 80 | + from adcp.decisioning.context import AuthInfo |
| 81 | + from adcp.types import AccountReference |
| 82 | + |
| 83 | +__all__ = ["create_roster_account_store"] |
| 84 | + |
| 85 | +#: Per-platform metadata generic. Defaults to ``dict[str, Any]`` for |
| 86 | +#: adopters who don't define a typed metadata shape. |
| 87 | +TMeta = TypeVar("TMeta", default=dict[str, Any]) |
| 88 | + |
| 89 | +_DENIED_MESSAGE = ( |
| 90 | + "roster-backed account store does not support upsert; " |
| 91 | + "the publisher curates accounts out-of-band" |
| 92 | +) |
| 93 | +_DENIED_MESSAGE_GOVERNANCE = ( |
| 94 | + "roster-backed account store does not support sync_governance; " |
| 95 | + "the publisher curates accounts out-of-band" |
| 96 | +) |
| 97 | + |
| 98 | + |
| 99 | +class _RosterAccountStore(Generic[TMeta]): |
| 100 | + """``AccountStore`` implementation backed by an immutable in-memory |
| 101 | + roster. |
| 102 | +
|
| 103 | + Constructed via :func:`create_roster_account_store`. The class is |
| 104 | + not part of the public API; adopters reference the factory. |
| 105 | + """ |
| 106 | + |
| 107 | + resolution: Literal["explicit"] = "explicit" |
| 108 | + |
| 109 | + def __init__(self, roster: Mapping[str, Account[TMeta]]) -> None: |
| 110 | + # Copy into a plain dict, then wrap in MappingProxyType so the |
| 111 | + # store's view is decoupled from the caller's input. Two layers |
| 112 | + # of protection: external mutation of the input dict can't |
| 113 | + # reach our copy, and adopter code that gets a reference to |
| 114 | + # ``self._roster`` can't mutate it either. |
| 115 | + copied: dict[str, Account[TMeta]] = dict(roster) |
| 116 | + for key, account in copied.items(): |
| 117 | + if account.id != key: |
| 118 | + raise ValueError( |
| 119 | + f"roster key {key!r} does not match Account.id " |
| 120 | + f"{account.id!r}; every roster value's id must match " |
| 121 | + f"its key" |
| 122 | + ) |
| 123 | + self._roster: Mapping[str, Account[TMeta]] = MappingProxyType(copied) |
| 124 | + |
| 125 | + async def resolve( |
| 126 | + self, |
| 127 | + ref: AccountReference | None, |
| 128 | + auth_info: AuthInfo | None = None, |
| 129 | + ) -> Account[TMeta] | None: |
| 130 | + """Resolve a wire reference to a roster :class:`Account`. |
| 131 | +
|
| 132 | + ``account_id``-arm refs hit a dict lookup; misses, natural-key |
| 133 | + refs, and ref-less calls return ``None``. The framework |
| 134 | + projects ``None`` to ``ACCOUNT_NOT_FOUND`` on the wire. |
| 135 | +
|
| 136 | + Signature mirrors the :class:`AccountStore` Protocol's |
| 137 | + ``resolve(ref, auth_info=None)`` — the framework dispatcher |
| 138 | + passes ``auth_info`` as a keyword argument. ``auth_info`` is |
| 139 | + accepted for Protocol parity but unused: the roster IS the |
| 140 | + allowlist, no auth-based filtering at this layer. |
| 141 | + """ |
| 142 | + del auth_info # roster is the allowlist; no per-principal filtering |
| 143 | + account_id = ref_account_id(ref) |
| 144 | + if account_id is None: |
| 145 | + return None |
| 146 | + return self._roster.get(account_id) |
| 147 | + |
| 148 | + async def upsert( |
| 149 | + self, |
| 150 | + refs: list[AccountReference], |
| 151 | + ctx: ResolveContext | None = None, |
| 152 | + ) -> list[SyncAccountsResultRow]: |
| 153 | + """Reject every entry with ``PERMISSION_DENIED``. |
| 154 | +
|
| 155 | + ``sync_accounts`` is a buyer-driven write path; on a roster- |
| 156 | + backed store the adopter curates accounts out-of-band, so the |
| 157 | + buyer cannot write. Per-entry rejection (not operation-level |
| 158 | + throw) so a multi-account batch sees the rejection per row, |
| 159 | + matching the wire shape. |
| 160 | + """ |
| 161 | + del ctx |
| 162 | + rows: list[SyncAccountsResultRow] = [] |
| 163 | + for ref in refs: |
| 164 | + brand = _ref_brand(ref) |
| 165 | + operator = _ref_operator(ref) |
| 166 | + rows.append( |
| 167 | + SyncAccountsResultRow( |
| 168 | + brand=brand, |
| 169 | + operator=operator, |
| 170 | + action="failed", |
| 171 | + status="failed", |
| 172 | + errors=[ |
| 173 | + { |
| 174 | + "code": "PERMISSION_DENIED", |
| 175 | + "message": _DENIED_MESSAGE, |
| 176 | + "recovery": "terminal", |
| 177 | + } |
| 178 | + ], |
| 179 | + ) |
| 180 | + ) |
| 181 | + return rows |
| 182 | + |
| 183 | + async def sync_governance( |
| 184 | + self, |
| 185 | + entries: list[SyncGovernanceEntry], |
| 186 | + ctx: ResolveContext | None = None, |
| 187 | + ) -> list[SyncGovernanceResultRow]: |
| 188 | + """Reject every entry with ``PERMISSION_DENIED``. |
| 189 | +
|
| 190 | + ``sync_governance`` registers buyer-supplied governance agent |
| 191 | + endpoints per-account; on a roster-backed store the adopter |
| 192 | + doesn't model buyer-supplied governance bindings. Per-entry |
| 193 | + rejection so a multi-account batch surfaces the rejection per |
| 194 | + row. |
| 195 | + """ |
| 196 | + del ctx |
| 197 | + return [ |
| 198 | + SyncGovernanceResultRow( |
| 199 | + account=entry.account, |
| 200 | + status="failed", |
| 201 | + errors=[ |
| 202 | + { |
| 203 | + "code": "PERMISSION_DENIED", |
| 204 | + "message": _DENIED_MESSAGE_GOVERNANCE, |
| 205 | + "recovery": "terminal", |
| 206 | + } |
| 207 | + ], |
| 208 | + ) |
| 209 | + for entry in entries |
| 210 | + ] |
| 211 | + |
| 212 | + # ``list`` MUST be declared LAST in this class. mypy resolves |
| 213 | + # annotations in class scope; once ``list`` is defined as a method, |
| 214 | + # any subsequent method whose annotations reference ``list[...]`` |
| 215 | + # binds to the method, not the builtin — even with |
| 216 | + # ``from __future__ import annotations`` (lazy string evaluation |
| 217 | + # at runtime doesn't change static-analysis class-scope lookup). |
| 218 | + # Keeping ``list`` last means ``upsert``/``sync_governance`` above |
| 219 | + # see the builtin. |
| 220 | + async def list( |
| 221 | + self, |
| 222 | + filter: dict[str, Any] | None = None, |
| 223 | + ctx: ResolveContext | None = None, |
| 224 | + ) -> list[Account[TMeta]]: |
| 225 | + """Return every roster entry. |
| 226 | +
|
| 227 | + Adopters who need filtering (status, sandbox, pagination) wrap |
| 228 | + ``list`` and post-filter the returned list — the roster store |
| 229 | + does not interpret ``filter`` because the typical roster |
| 230 | + cardinality (single-digit to low-thousands of accounts per |
| 231 | + publisher) is small enough that in-memory filtering at the |
| 232 | + adopter layer is fine. |
| 233 | + """ |
| 234 | + del filter, ctx |
| 235 | + return list(self._roster.values()) |
| 236 | + |
| 237 | + |
| 238 | +def _ref_brand(ref: AccountReference | None) -> dict[str, Any]: |
| 239 | + """Extract ``brand`` from a natural-key ref; empty dict otherwise. |
| 240 | +
|
| 241 | + The wire schema requires ``brand`` on every |
| 242 | + :class:`SyncAccountsResultRow`. For id-arm refs we don't have a |
| 243 | + brand, so we return an empty dict — the row is ``failed`` anyway, |
| 244 | + and the buyer correlates by request order. |
| 245 | + """ |
| 246 | + if ref is None: |
| 247 | + return {} |
| 248 | + brand = getattr(ref, "brand", None) |
| 249 | + if brand is None: |
| 250 | + return {} |
| 251 | + if hasattr(brand, "model_dump"): |
| 252 | + return brand.model_dump(mode="json", exclude_none=True) # type: ignore[no-any-return] |
| 253 | + if isinstance(brand, dict): |
| 254 | + return brand |
| 255 | + return {} |
| 256 | + |
| 257 | + |
| 258 | +def _ref_operator(ref: AccountReference | None) -> str: |
| 259 | + """Extract ``operator`` from a natural-key ref; empty string |
| 260 | + otherwise. Same fallback rationale as :func:`_ref_brand`.""" |
| 261 | + if ref is None: |
| 262 | + return "" |
| 263 | + operator = getattr(ref, "operator", None) |
| 264 | + return operator if isinstance(operator, str) else "" |
| 265 | + |
| 266 | + |
| 267 | +def create_roster_account_store( |
| 268 | + *, |
| 269 | + roster: Mapping[str, Account[TMeta]], |
| 270 | +) -> _RosterAccountStore[TMeta]: |
| 271 | + """Build an :class:`AccountStore` backed by a fixed publisher- |
| 272 | + curated roster. |
| 273 | +
|
| 274 | + The returned object conforms to the :class:`AccountStore` Protocol |
| 275 | + plus the optional :class:`AccountStoreList`, |
| 276 | + :class:`AccountStoreUpsert`, and :class:`AccountStoreSyncGovernance` |
| 277 | + Protocols. ``upsert`` and ``sync_governance`` fail closed with |
| 278 | + ``PERMISSION_DENIED`` per entry — adopters who need to support |
| 279 | + write paths use a custom :class:`AccountStore` implementation |
| 280 | + instead. |
| 281 | +
|
| 282 | + :param roster: Mapping from ``account_id`` → :class:`Account`. Each |
| 283 | + value's ``id`` MUST match its key; mismatch raises |
| 284 | + :class:`ValueError` at construction. The mapping is copied into |
| 285 | + an internal immutable view, so subsequent mutation of the |
| 286 | + caller's dict does not affect the store. |
| 287 | +
|
| 288 | + :returns: An :class:`AccountStore` whose: |
| 289 | +
|
| 290 | + * :meth:`resolve` returns the roster entry for an |
| 291 | + ``account_id``-arm ref, ``None`` otherwise. |
| 292 | + * :meth:`list` returns every roster entry. |
| 293 | + * :meth:`upsert` rejects every input entry with |
| 294 | + ``PERMISSION_DENIED``. |
| 295 | + * :meth:`sync_governance` rejects every input entry with |
| 296 | + ``PERMISSION_DENIED``. |
| 297 | +
|
| 298 | + :raises ValueError: When any roster value's ``id`` does not match |
| 299 | + its dict key. |
| 300 | + """ |
| 301 | + return _RosterAccountStore(roster) |
0 commit comments