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):

  • resolve returns None
  • upsert rejects every entry with PERMISSION_DENIED
  • sync_governance rejects every entry with PERMISSION_DENIED
  • list returns []

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: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<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),
)