Skip to content

feat(decisioning): create_tenant_store — opinionated multi-tenant AccountStore with fail-closed isolation#473

Merged
bokelley merged 2 commits intomainfrom
bokelley/feat-create-tenant-store
May 3, 2026
Merged

feat(decisioning): create_tenant_store — opinionated multi-tenant AccountStore with fail-closed isolation#473
bokelley merged 2 commits intomainfrom
bokelley/feat-create-tenant-store

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

Port of JS @adcp/sdk@6.7's createTenantStore — the headline 6.7 helper. Builds an opinionated multi-tenant AccountStore with the per-entry tenant gate baked into the framework so cross-tenant entries never reach adopter code.

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),
    sync_governance_row=lambda entry, ctx: persist_governance(entry),
)

Security semantics

  • Fail-closed: when resolve_from_auth(ctx) returns None (unauthenticated / unregistered principal), every upsert and sync_governance entry rejects with PERMISSION_DENIED. resolve returns None. list returns [].
  • Per-entry gate: cross-tenant entries are rejected BEFORE reaching adopter upsert_row / sync_governance_row callbacks. Mixed batches partition cleanly into pass / PERMISSION_DENIED / ACCOUNT_NOT_FOUND.
  • Immutability: gate methods are class-level and the store uses __slots__. Adopter code that tries store.upsert = custom_handler gets AttributeError instead of silently bypassing isolation. The Python equivalent of JS's Object.defineProperty(... writable: false).

Design decisions / divergences from JS

  • Flattened Tenant to tenant_id: str. The JS API takes a generic TTenant value plus a tenantId(tenant): string projection. The Python adaptation collapses this to a single string identity since adopters typically denormalize the owning tenant onto the Account itself. tenant_id(account) projects from Account; tenant_to_account(tenant_id) is the inverse. Same security semantics, simpler shape.
  • resolve() also enforces the gate. JS only gates upsert / syncGovernance; Python tightens this to gate resolve too (per the issue spec). Cross-tenant refs return None, hiding the existence of accounts the caller can't see.
  • list() is generated. JS deliberately omits list from the helper because cursor pagination + multi-tenant filter shapes don't fit a one-liner. Python provides a single-tenant projection (returns [tenant_to_account(auth_tenant)]) since the prompt specified it; adopters needing pagination compose by wrapping the returned store.
  • upsert_row / sync_governance_row are optional (matches JS). Without an adopter hook, authorized rows pass with action='unchanged' (no-op acknowledgment) rather than 501. Cross-tenant rejection still applies.

Tests cover

  • resolve: same tenant, cross-tenant (returns None), unknown ref, unauthenticated, Path-2 (no ref → auth-derived account)
  • upsert: in-tenant pass-through, cross-tenant rejected before adopter code runs, unknown ref → ACCOUNT_NOT_FOUND, fail-closed for unregistered + unauthenticated, mixed-batch partitioning, no-hook no-op behavior
  • list: same-tenant only, unregistered → [], unauthenticated → []
  • sync_governance: in-tenant, cross-tenant, fail-closed
  • immutability: cannot reassign upsert / sync_governance / list / resolve (all raise AttributeError)

23 tests, all green. Full regression suite (3383 tests) stays green.

Closes #457. Part of #452.

🤖 Generated with Claude Code

@bokelley bokelley force-pushed the bokelley/feat-create-tenant-store branch from 7a428ae to b69d0dd Compare May 3, 2026 13:35
bokelley added 2 commits May 3, 2026 09:44
…ountStore with fail-closed isolation

Ports JS @adcp/sdk@6.7's createTenantStore. The headline 6.7 helper:
an AccountStore with a per-entry tenant gate baked in so cross-tenant
entries never reach adopter code on upsert/sync_governance.

Fail-closed: when resolve_from_auth(ctx) returns None (unauthenticated
or unregistered principal), every entry rejects with PERMISSION_DENIED
and list() returns []. resolve() also enforces the gate on Path-1
(operator-routed) calls — cross-tenant refs return None, hiding the
existence of accounts the caller can't see.

Immutability: gate methods are class-level and the store class uses
__slots__, so adopter code that tries store.upsert = custom_handler
gets AttributeError instead of silently bypassing isolation.

Closes #457. Part of #452.
…ck isolation

Address code-review BLOCKER and security-review SHOULD-FIX items:

- resolve(ref, ctx) → resolve(ref, auth_info=None) matching the
  AccountStore Protocol. Adopter resolve_by_ref(ref, ctx) callback
  unchanged — ResolveContext is synthesized inside the public method.
- Per-entry try/except inside upsert and sync_governance: a single
  callback exception no longer poisons the rest of the batch. Failed
  entries surface as PERMISSION_DENIED on the wire (no exception text
  leaked) and are logged server-side via logger.warning(exc_info=True).
- list now catches tenant_to_account exceptions and returns [] —
  honoring the docstring's MUST-NOT-RAISE contract.
- resolve Path 1 catches resolve_by_ref exceptions, returns None +
  logs WARNING (so a single adopter raise doesn't 500 the request).
- Added tests for: AccountStore isinstance check, dispatcher kwarg
  call shape, four callback-raises cases.
- Class-level immutability docstring note added (instance immutability
  is enforced via __slots__; class-level monkey-patching is possible
  but the leading-underscore + non-export keep _TenantStore out of
  adopter code paths).
@bokelley bokelley force-pushed the bokelley/feat-create-tenant-store branch from b69d0dd to 8199270 Compare May 3, 2026 13:44
@bokelley bokelley merged commit 8dd5dab into main May 3, 2026
14 checks passed
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(server): createTenantStore — opinionated multi-tenant AccountStore with fail-closed isolation

1 participant