Module adcp.decisioning.tenant_store
create_tenant_store() — opinionated multi-tenant :class:AccountStore
builder with a baked-in per-entry tenant-isolation gate.
Solves the recurring class of bug where adopters routing by wire-supplied
operator without cross-checking the auth principal could write across
tenants. The gate is enforced inside the framework: cross-tenant entries
on upsert / sync_governance are rejected with
PERMISSION_DENIED before reaching adopter callbacks.
Mirrors the JS-side createTenantStore at
packages/sdk/src/server/decisioning/tenant-store.ts (6.7). The
Python adaptation flattens the Tenant value to a string tenant_id
since adopters typically denormalize the owning tenant onto the Account
itself; the security semantics are unchanged.
Fail-closed. When resolve_from_auth(ctx) returns None
(unknown / unauthenticated principal):
resolvereturnsNoneupsertrejects every entry withPERMISSION_DENIEDsync_governancerejects every entry withPERMISSION_DENIEDlistreturns[]
Don't fork this around to fail-open. Adopters who copied the prior
fail-open shape (if home_tenant_id and tenant_id != home_tenant_id)
silently disabled isolation when a credential lacked a tenant binding.
Immutability. The returned store's methods are defined on the class
(not assigned in __init__ to a callable hook), and the class uses
__slots__ to forbid instance attribute assignment. An adopter who
writes store.upsert = custom_handler after construction gets an
:class:AttributeError instead of silently bypassing the gate. Adopters
with genuine custom needs compose at the method level (wrap the returned
store) or write a plain :class:AccountStore and own the gate.
_TenantStore is intentionally not exported from
adcp.decisioning; only the :func:create_tenant_store()
factory is public. Class-level monkey-patching is possible in pure
Python (no language-level final), but the leading-underscore +
non-export keep it out of adopter code paths.
Functions
def create_tenant_store(*,
resolve_by_ref: _ResolveByRef[TMeta],
resolve_from_auth: _ResolveFromAuth,
tenant_id: _TenantIdFn[TMeta],
tenant_to_account: _TenantToAccount[TMeta],
upsert_row: _UpsertRow | None = None,
sync_governance_row: _SyncGovernanceRow | None = None) ‑> _TenantStore[TMeta]-
Expand source code
def create_tenant_store( *, resolve_by_ref: _ResolveByRef[TMeta], resolve_from_auth: _ResolveFromAuth, tenant_id: _TenantIdFn[TMeta], tenant_to_account: _TenantToAccount[TMeta], upsert_row: _UpsertRow | None = None, sync_governance_row: _SyncGovernanceRow | None = None, ) -> _TenantStore[TMeta]: """Build an :class:`AccountStore` whose ``resolve`` / ``upsert`` / ``list`` / ``sync_governance`` methods enforce tenant isolation. Canonical helper for the multi-tenant pattern described in :class:`~adcp.decisioning.AccountStore` — composes tenant scope into the returned ``Account.id`` so every downstream store (:class:`~adcp.decisioning.ProposalStore`, :class:`~adcp.decisioning.TaskRegistry`, framework idempotency cache) sees globally-unique identifiers and treats tenancy as opaque. Adopters writing a hand-rolled multi-tenant :class:`AccountStore` should reach for this factory first. :param resolve_by_ref: ``(ref, ctx) -> Account | None``. Resolves a wire :class:`AccountReference` to the framework Account it points at — independent of who the caller is. Return ``None`` if the ref is unknown (helper emits ``ACCOUNT_NOT_FOUND`` for that row). May be sync or async. :param resolve_from_auth: ``(ctx) -> tenant_id | None``. Derives the tenant from the auth principal. Return ``None`` if no principal is resolvable (no auth, principal not registered) — every entry on per-entry tools then fails ``PERMISSION_DENIED`` (fail-closed). :param tenant_id: ``(account) -> str``. Stable identity for tenant- equality checks. The helper compares ``tenant_id(entry_account) == resolve_from_auth(ctx)`` to enforce isolation. A stable string id beats reference equality (Postgres-backed stores hand back fresh objects each fetch). :param tenant_to_account: ``(tenant_id) -> Account | None``. Project a tenant id to its Account. Used by Path-2 ``resolve`` (no-ref tools) and by ``list``. :param upsert_row: Optional ``(ref, ctx) -> SyncAccountsResultRow`` per-entry storage callback. Cross-tenant entries and unknown-ref entries NEVER reach this callback — the helper builds ``PERMISSION_DENIED`` / ``ACCOUNT_NOT_FOUND`` rows for those before invoking adopter code. Omit for adopters whose platform doesn't claim ``sync_accounts``; the helper returns ``action='unchanged'`` for authorized rows in that case. :param sync_governance_row: Optional ``(entry, ctx) -> SyncGovernanceResultRow``. Same gating rules as ``upsert_row``. Adopters persist the buyer's governance-agent binding here. :returns: An :class:`AccountStore`-shaped object whose gate methods are class-level (immutable per instance — ``__slots__`` forbids attribute assignment). Example:: from adcp.decisioning import create_tenant_store store = create_tenant_store( resolve_by_ref=lambda ref, ctx: lookup_account_by_ref(ref), resolve_from_auth=lambda ctx: principal_to_tenant.get( ctx.auth_info.principal if ctx.auth_info else None ), tenant_id=lambda account: account.metadata["tenant_id"], tenant_to_account=lambda tid: tenants[tid].account, upsert_row=lambda ref, ctx: persist_account(ref), ) """ return _TenantStore( resolve_by_ref=resolve_by_ref, resolve_from_auth=resolve_from_auth, tenant_id=tenant_id, tenant_to_account=tenant_to_account, upsert_row=upsert_row, sync_governance_row=sync_governance_row, )Build an :class:
AccountStorewhoseresolve/upsert/list/sync_governancemethods enforce tenant isolation.Canonical helper for the multi-tenant pattern described in :class:
~adcp.decisioning.AccountStore— composes tenant scope into the returnedAccount.idso every downstream store (:class:~adcp.decisioning.ProposalStore, :class:~adcp.decisioning.TaskRegistry, framework idempotency cache) sees globally-unique identifiers and treats tenancy as opaque. Adopters writing a hand-rolled multi-tenant :class:AccountStoreshould reach for this factory first.:param resolve_by_ref:
(ref, ctx) -> Account | None. Resolves a wire :class:AccountReferenceto the framework Account it points at — independent of who the caller is. ReturnNoneif the ref is unknown (helper emitsACCOUNT_NOT_FOUNDfor that row). May be sync or async. :param resolve_from_auth:(ctx) -> tenant_id | None. Derives the tenant from the auth principal. ReturnNoneif no principal is resolvable (no auth, principal not registered) — every entry on per-entry tools then failsPERMISSION_DENIED(fail-closed). :param tenant_id:(account) -> str. Stable identity for tenant- equality checks. The helper comparestenant_id(entry_account) == resolve_from_auth(ctx)to enforce isolation. A stable string id beats reference equality (Postgres-backed stores hand back fresh objects each fetch). :param tenant_to_account:(tenant_id) -> Account | None. Project a tenant id to its Account. Used by Path-2resolve(no-ref tools) and bylist. :param upsert_row: Optional(ref, ctx) -> SyncAccountsResultRowper-entry storage callback. Cross-tenant entries and unknown-ref entries NEVER reach this callback — the helper buildsPERMISSION_DENIED/ACCOUNT_NOT_FOUNDrows for those before invoking adopter code. Omit for adopters whose platform doesn't claimsync_accounts; the helper returnsaction='unchanged'for authorized rows in that case. :param sync_governance_row: Optional(entry, ctx) -> SyncGovernanceResultRow<code>. Same gating rules as </code>upsert_row. Adopters persist the buyer's governance-agent binding here.:returns: An :class:
AccountStore-shaped object whose gate methods are class-level (immutable per instance —__slots__forbids attribute assignment).Example::
from adcp.decisioning import create_tenant_store store = create_tenant_store( resolve_by_ref=lambda ref, ctx: lookup_account_by_ref(ref), resolve_from_auth=lambda ctx: principal_to_tenant.get( ctx.auth_info.principal if ctx.auth_info else None ), tenant_id=lambda account: account.metadata["tenant_id"], tenant_to_account=lambda tid: tenants[tid].account, upsert_row=lambda ref, ctx: persist_account(ref), )