jdlib — Implementation Design (Code Level)¶
Companion to the foundation design (rev 3) and contracts 02–06. This
document is the blueprint for implementation: architectural style, patterns,
modules, classes, interfaces, and cross-cutting rules. Behavior is owned by the
other documents; this one is about how the code is structured.
1. Architectural style¶
Layered ports-and-adapters:
integrations (FastAPI, CLI) ← adapters
tenancy · authn · audit ← application services (use cases)
authz · persistence · control ← domain services and persistence ports
context · models · errors · config ← foundation (no I/O)
Rules:
- Dependencies point inward only.
models,context,errors,configimport no framework service. - Every external concern enters through a
Protocolport (IsolationStrategy,Authenticator,TenantResolver,PolicyDecisionPoint,AuditSink,SecretProvider,ResourceTypeRegistry,TenantLifecycleHook). - Constructor injection everywhere. No DI container, no service locator, no
global mutable state. The only module-level state is the
contextvarscontextvar, and it is fail-closed. - Async-first (SQLAlchemy
AsyncSession,asyncpg). Sync code is confined to Alembic programmatic invocation and the CLI. - One concrete implementation per port ships in v1; ports exist because an interface was explicitly designed for replacement, not speculatively.
2. Patterns used, and patterns rejected¶
| Pattern | Where | Why |
|---|---|---|
| Ports & Adapters | all extension points | strategy/IdP/engine replacement without touching business code |
| Strategy | IsolationStrategy implementations |
the core requirement: swap physical tenancy |
| Chain of Responsibility | resolver chain, authenticator chain | layered request identification without branching |
| Factory | ContextFactory, SessionRouter, strategy registry |
the only sanctioned way to create authoritative objects |
| Context Object | TenantContext |
tenancy as infrastructure; implicit but fail-closed |
| Unit of Work | UnitOfWork |
one transaction per tenant operation; explicit boundaries |
| Repository / Data Mapper | TenantRepository over SQLAlchemy mappings |
business code never builds scoped queries by hand |
| Identity Map | SQLAlchemy AsyncSession (built in) |
no custom implementation |
| Registry | PermissionCatalog, ResourceTypeRegistry |
startup-validated, code-owned metadata |
| Decorator | @requires, @tenant_job |
ergonomic guards without magic internals |
| Composite | CompositeAuthenticator, CompositeAuditSink |
ordered fan-in without an event bus |
| Null Object | NoopAuditSink, StaticResolver (testing) |
tests without branches |
Rejected deliberately: DI framework, event bus/message broker abstraction, generic query DSL, ORM abstraction layer, Active Record, inheritance-heavy Template Method hierarchies, cross-request authorization caches (v1).
3. File map¶
src/jdlib/
├── __init__.py # public API re-exports only
├── config.py # TenancyConfig + sub-models
├── errors.py # exception hierarchy
├── lint.py # metadata lint L1-L19
├── context.py # Principal, TenantContext, PrivilegeContext,
│ # ContextFactory, current_*, scoped_key, envelope
├── control/
│ ├── base.py # ControlBase (DeclarativeBase, schema="jd_control")
│ ├── models.py # Tenant, TenantPlacement, TenantMigrationState, TenantRelocation,
│ │ # User, IdentityLink, PlatformOperator, ServiceAccount, ApiKey,
│ │ # Membership, Invitation, Role, RolePermission,
│ │ # RoleBinding, ResourceGrant, PlatformAuditEvent
│ ├── session.py # ControlPlaneSession (framework-internal)
│ ├── registry.py # TenantRegistry, DeprovisionMode, MigrationState
│ ├── purge.py # TenantPlanePurger (child-first privileged tenant-plane purge)
│ └── access.py # AccessControl, ownership validation
├── models/
│ ├── base.py # TenantBase + mixins: TenantOwned, TenantRouted,
│ │ # Timestamped, SoftDelete, OwnableByOrg, OwnableByTeam
│ ├── org.py # Organization, Team
│ ├── settings.py # TenantSetting
│ └── audit.py # AuditEvent
├── authn/
│ ├── base.py # Authenticator protocol, bearer_token,
│ │ # record_authn_failure (imports RequestInfo)
│ ├── oidc.py # OidcAuthenticator, JwksCache, TokenVerifier,
│ │ # ClaimMapper, UserLinker
│ ├── apikey.py # ApiKeyAuthenticator, KeyParser
│ ├── composite.py # CompositeAuthenticator
│ └── wiring.py # build_authenticator + ContextFactory port
│ # adapters over TenantRegistry/ControlAccessReader
├── authz/
│ ├── permissions.py # Permission, PermissionCatalog, builtin catalog
│ ├── resource_types.py # ResourceTypeDefinition, ResourceTypeRegistry
│ ├── scopes.py # ScopeRef, ResourceRef, ScopeType, ScopeResolver
│ ├── reader.py # Binding, AccessReader, ControlAccessReader
│ ├── pdp.py # PolicyDecisionPoint, DefaultPDP, Decision, MatchedRule
│ ├── cache.py # AuthzCache (request-scoped, invalidatable)
│ ├── access.py # AccessControl; one AuthzCache instance must be
│ │ # shared with the PDP per request
│ ├── guards.py # Enforcer, requires(), authorize()
│ └── ownership.py # validate_ownership, hierarchy helpers (I12)
├── tenancy/ # resolution.py (RequestInfo + resolvers) · middleware.py · jobs.py
├── persistence/
│ ├── session.py # TenantSession (scoping, stamping, guards, raw_sql modes)
│ ├── rawsql.py # RawSqlMode, RawSqlValidator (pglast structural validation)
│ ├── router.py # SessionRouter strategy dispatch
│ ├── secrets.py # SecretProvider, MemorySecretProvider, EnvSecretProvider
│ ├── uow.py # UnitOfWork
│ ├── repository.py # TenantRepository[ModelT]
│ ├── events.py # SQLAlchemy event registration (do_orm_execute, before_flush)
│ └── strategies/
│ ├── base.py # IsolationStrategy, StrategyCapabilities, StrategyName
│ ├── deprovision.py # DeprovisionMode
│ ├── shared.py # SharedSchemaStrategy (per-transaction RLS binding)
│ ├── schema.py # SchemaPerTenantStrategy, SchemaName validation
│ ├── database.py # DatabasePerTenantStrategy
│ └── rls.py # rls_eligible_tables, emit_rls_policies, install_rls, verify_rls
├── audit/
│ ├── base.py # AuditRecord, AuditSink
│ ├── sinks.py # DatabaseAuditSink (plane-aware), CompositeAuditSink
│ └── recorder.py # AuditRecorder (mandatory vs best-effort)
├── migrations/
│ ├── runner.py # MigrationRunner (programmatic Alembic; only supported path, no CLI ini)
│ ├── control/ # env.py + versions/0001_control_baseline.py
│ └── tenant/ # env.py + versions/0001_tenant_baseline.py
├── testing/
│ ├── fixtures.py # tenants, context, strategy_matrix, rls_on_off
│ ├── fakes.py # FakePDP, InMemoryAuditSink, StaticResolver, MemorySecretProvider
│ └── assertions.py # assert_isolation, assert_cross_tenant_rejected, assert_rls_matrix
└── integrations/
├── fastapi.py # middleware wiring, dependencies, exception handlers
└── cli.py # typer commands
4. Foundation types¶
class PrincipalKind(StrEnum):
USER = "user"; SERVICE_ACCOUNT = "service_account"; PLATFORM_OPERATOR = "platform_operator"
@dataclass(frozen=True, slots=True)
class Principal:
kind: PrincipalKind
id: uuid.UUID
user_id: uuid.UUID | None = None
tenant_id: uuid.UUID | None = None # pinned for service accounts
api_key_id: uuid.UUID | None = None
scopes: frozenset[str] | None = None # None = no narrowing
operator_level: OperatorLevel | None = None
@dataclass(frozen=True, slots=True)
class PrivilegeContext:
kind: PrivilegeKind # operator | system | test
capability: str | None
justification: str | None
actor: Principal
issued_at: datetime
expires_at: datetime | None
@dataclass(frozen=True, slots=True)
class TenantContext:
tenant_id: uuid.UUID
tenant_slug: str
strategy: StrategyName
principal: Principal
privilege: PrivilegeContext | None
request_id: str
correlation_id: str
trace_id: str | None
_CTX: ContextVar[TenantContext | None]
def current_tenant() -> TenantContext # raises MissingTenantContext
def current_principal() -> Principal
def current_privilege() -> PrivilegeContext | None
def scoped_key(namespace: str, *parts: object) -> str # jdlib:v1:{tenant}:{ns}:{enc parts}
ContextFactory is the only creator:
class ContextFactory:
def __init__(self, registry: TenantRegistry, access: AccessControl,
config: ContextConfig, clock: Clock = SystemClock()) -> None: ...
async def for_principal(self, principal: Principal, tenant: TenantRef, *,
request_id: str, correlation_id: str | None = None,
trace_id: str | None = None) -> TenantContext: ...
async def for_operator(self, operator: Principal, tenant: TenantRef, capability: str,
*, justification: str, ttl: timedelta) -> TenantContext: ...
async def for_system(self, tenant: TenantRecord, *, purpose: str,
issuer: _PrivilegeIssuer) -> TenantContext: ...
async def for_test(self, tenant: TenantRecord, *, principal: Principal,
issuer: _PrivilegeIssuer) -> TenantContext: ...
for_principal runs the authoritative flow (service-account tenant-pin check
first, then entitlement, then tenant servability, so a pinned service account
never reaches another tenant and non-entitled principals never learn tenant
state) and never trusts caller-supplied state beyond ids. A service-account
principal must resolve to the tenant it is pinned to; a missing (None) or
mismatched pin raises TenantAccessDenied.
for_system/for_test require the internal _PrivilegeIssuer instance held by
the factory; their use is audited.
Envelope for async boundaries:
@dataclass(frozen=True, slots=True)
class TenantContextEnvelope:
tenant_id: uuid.UUID; principal_type: PrincipalKind; principal_id: uuid.UUID
request_id: str; correlation_id: str; trace_id: str | None
issued_at: datetime; signature: bytes
class EnvelopeCodec: # HMAC-SHA256 over canonical fields, TTL-enforced
def encode(self, ctx: TenantContext) -> str: ...
def decode(self, token: str, *, now: datetime) -> TenantContextEnvelope: ...
class ContextPropagator: # rebuilds context: verify → reload statuses → re-authorize
async def reconstruct(self, token: str) -> TenantContext: ...
5. Configuration¶
Pydantic-settings models, environment-prefixed (JDLIB_), no defaults for
secrets:
class TenancyConfig(BaseSettings):
control_dsn: SecretStr
default_strategy: StrategyName = StrategyName.SHARED
strategies: StrategiesConfig
rls: RlsConfig # enabled, app_role, owner_role
pools: PoolConfig # max_targets, cap, health_check_interval
resolvers: list[ResolverName] = [JWT_CLAIM, SUBDOMAIN, PATH, HEADER, API_KEY]
oidc: OidcConfig # issuer, audience, algorithms, jwks_ttl, email_trusted
api_keys: ApiKeyConfig # prefix, pepper
context: ContextConfig # signing_key, envelope_ttl, operator_ttl
secrets: SecretProviderConfig
Validation happens at startup: unknown strategy, RLS with non-shared placement, missing signing key, or invalid resource-type definitions raise before the app serves traffic.
6. ORM design¶
6.1 Bases and naming¶
Two DeclarativeBases (ControlBase, TenantBase) with one shared naming
convention so constraint names are deterministic for migrations and lint:
convention = {"ix": "ix_%(table_name)s_%(column_0_N_name)s",
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_N_name)s",
"pk": "pk_%(table_name)s"}
Typed mappings (Mapped[...] + mapped_column). UUIDv7 via jdlib._uuid.uuid7
(RFC 9562, generated in-process; no UUID dependency),
client-side default. All datetimes timezone-aware UTC.
6.2 Mixins (tenant plane)¶
class TenantOwned:
tenant_id: Mapped[uuid.UUID] = mapped_column(primary_key=True, nullable=False)
class TenantRouted(TenantOwned):
"""TenantOwned + physical routing/RLS eligibility. Subclass, never a sibling."""
class Timestamped:
created_at: Mapped[datetime]; updated_at: Mapped[datetime]
class SoftDelete:
deleted_at: Mapped[datetime | None]
class OwnableByOrg:
organization_id: Mapped[uuid.UUID | None]
class OwnableByTeam:
team_id: Mapped[uuid.UUID | None]
# __table_args__ adds:
# CheckConstraint("team_id IS NULL OR organization_id IS NOT NULL")
# ForeignKeyConstraint(["tenant_id","team_id","organization_id"],
# ["teams.tenant_id","teams.id","teams.organization_id"])
Application models inherit TenantBase + TenantRouted + mixins; the metadata
lint (L1–L19) runs against the combined metadata at import time in tests and at
startup when config.strict_schema = true.
Model inheritance contract (frozen, enforced by L11 and L17–L19):
TenantOwned
│
TenantRouted
tenant-plane
+ application-plane
Control-plane tenant-scoped tables are tenant-owned but NOT
TenantRouted; they declare tenant_id explicitly (identity shape
enforced by L1/L4/L9).
- Every tenant-plane framework model and every application resource inherits
TenantRouted(TenantRouted ⊂ TenantOwned). - Control-plane tenant-scoped models (
Membership,Role,RolePermission,RoleBinding,ResourceGrant,Invitation,ApiKey,ServiceAccount) declaretenant_idexplicitly and never inheritTenantRouted. - L11: tenant-plane model without
TenantOwned→ build failure. L17: tenant-plane model withoutTenantRouted→ build failure. L18: registered application model withoutTenantRouted→ build failure. L19: control-plane model markedTenantRouted→ build failure.
6.3 Scoping implementation (the enforcement core)¶
In persistence/events.py, registered on every tenant session:
- Reads —
do_orm_executefor ORM SELECTs: whenexecute_state.is_selectand context has a tenant, attachwith_loader_criteria(TenantRouted, lambda cls: cls.tenant_id == ctx.tenant_id)(also applied to relationship loads and subqueries by SQLAlchemy). - Bulk writes —
TenantSession.update_where/delete_wherebuild ORM-enabledupdate()/delete()with an explicit tenant predicate. A seconddo_orm_executeguard inspects ORM-enabled DML againstTenantRoutedand raisesUnscopedBulkOperationif the compiled statement has notenant_idcriterion. This is a guard, not a substitute for the helpers; both exist. - Inserts/updates —
before_flush: - stamp
tenant_idfrom context (new objects); reject a different value; - reject mutation of
tenant_idon dirty objects (L8); - run
validate_ownership()forOwnableByTeamobjects (I12); - maintain
Timestampedvalues; - translate
session.delete()onSoftDeletemodels intodeleted_at(hard delete requires a privileged context, audited). - Raw SQL — explicit modes, never a boolean switch:
class RawSqlMode(StrEnum):
TENANT_BOUND = "tenant_bound"
PRIVILEGED = "privileged"
@dataclass(frozen=True, slots=True)
class CompiledRawSql:
sql: str # PostgreSQL dialect, $n positional placeholders
binds: Mapping[int, object] # bind values by index
class RawSqlValidator: # persistence/rawsql.py
def compile(self, statement: TextClause | Executable) -> CompiledRawSql: ...
def validate_tenant_bound(self, compiled: CompiledRawSql, ctx: TenantContext) -> None: ...
Validation pipeline (frozen): SQLAlchemy statement → PostgreSQL dialect
compilation (postgresql.asyncpg dialect; $n placeholders + bind metadata)
→ pglast parse → AST validation → execution. Uncompiled SQLAlchemy bind
syntax (:name) is never handed to the parser.
TENANT_BOUND allows SELECT, INSERT, UPDATE, DELETE and rejects DDL,
multiple statements, dynamic SQL, CTEs, transaction control
(BEGIN/COMMIT/ROLLBACK), SET, COPY, and administrative commands.
It must semantically prove tenant boundedness, not string-match, per
statement:
| Statement | Required proof |
|---|---|
SELECT |
every referenced tenant-routed relation (each join side included) has tenant_id = $n in the AND-conjunction |
UPDATE |
target relation has tenant_id = $n in the WHERE AND-conjunction |
DELETE |
target relation has tenant_id = $n in the WHERE AND-conjunction |
INSERT |
tenant_id explicitly supplied and its bound value $n proven equal to the context tenant id; INSERT ... SELECT sources must also be bounded |
Relations are resolved through authoritative metadata / the
ResourceTypeRegistry, never by table-name inference: known TenantRouted
relation → predicate validation; known control-plane/non-tenant relation →
rejected in TENANT_BOUND; unknown relation → rejected. No control-plane or
foreign-schema references, no table-valued functions. The WHERE clause must be
an AND-conjunction; OR, NOT, or nested boolean wrappers around the tenant
predicate are rejected (WHERE tenant_id = $1 OR TRUE cannot pass). Anything
unprovable raises UnscopedRawSql.
PRIVILEGED requires an explicit capability: tenant.raw_sql (current
tenant only; tenant-predicate validation still applies — the predicate is
never waived) or platform.raw_sql (cross-tenant/system only, admin-level,
mandatory justification and PlatformAuditEvent; skips tenant-predicate
validation while structural rejects still apply). A generic raw-SQL
capability is never a platform-wide bypass. Every call is logged with tenant
and mode.
Serialization of tenant identity is never implicit: sessions hold an immutable
TenantContext; there is no setter.
7. Persistence services¶
class UnitOfWork:
def __init__(self, router: SessionRouter, context: TenantContext,
audit: AuditRecorder | None = None) -> None: ...
async def __aenter__(self) -> TenantSession: ... # opens session
async def __aexit__(self, *exc) -> None: ... # commit clean / rollback dirty
class TenantRepository(Generic[ModelT]):
def __init__(self, session: TenantSession, model: type[ModelT], *,
audited: bool = True) -> None: ...
async def get(self, entity_id: uuid.UUID) -> ModelT | None: ...
async def list(self, *criteria, order_by=None, limit=None, offset=None) -> Sequence[ModelT]: ...
async def add(self, obj: ModelT, *, flush: bool = True) -> ModelT: ...
async def update(self, obj: ModelT, **changes: object) -> ModelT: ...
async def soft_delete(self, obj: ModelT) -> None: ...
async def count(self, *criteria) -> int: ...
class SessionRouter:
def __init__(self, strategies: Mapping[PlacementStrategy, IsolationStrategy]) -> None: ...
async def session(self, tenant: TenantRecord) -> AsyncSession: ...
class SharedSchemaStrategy: name, capabilities, session, provision, migrate, deprovision
class SchemaPerTenantStrategy: ... # per-begin search_path, validated handle
class DatabasePerTenantStrategy: ... # handle → SecretProvider → pool
SessionRouter is a thin dispatcher over the strategy registry: it selects
the IsolationStrategy for tenant.strategy and delegates, raising
StrategyCapabilityError for an unregistered placement. The pool cache lives
in DatabasePerTenantStrategy (max_targets, default 32): engines are created
lazily per handle and evicted LRU among fully idle pools only; a pool with any
checked-out connection is never closed, and cap exhaustion raises
PoolCapacityError.
8. Control-plane services¶
class TenantRegistry:
async def create(self, *, slug: str, name: str, strategy: StrategyName,
target_handle: str, region: str | None = None,
parent_tenant_id: uuid.UUID | None = None,
actor_id: uuid.UUID | None = None) -> Tenant: ...
async def provision(self, tenant_id: uuid.UUID) -> Tenant: ... # idempotent/resumable
async def migrate(self, tenant_id: uuid.UUID) -> Tenant: ...
async def suspend(self, tenant_id: uuid.UUID, *, reason: str,
actor_id: uuid.UUID | None = None) -> Tenant: ...
async def resume(self, tenant_id: uuid.UUID, *,
actor_id: uuid.UUID | None = None) -> Tenant: ...
async def archive(self, tenant_id: uuid.UUID, *,
actor_id: uuid.UUID | None = None) -> Tenant: ...
async def deprovision(self, tenant_id: uuid.UUID, *, mode: DeprovisionMode,
actor_id: uuid.UUID | None = None,
detach_children: bool = False) -> Tenant: ...
async def relocate(self, tenant_id: uuid.UUID, *, to_strategy: StrategyName,
to_target_handle: str, to_region: str | None = None,
actor: Principal) -> TenantRelocation: ...
async def resume_relocation(self, tenant_id: uuid.UUID, *, actor: Principal) -> TenantRelocation: ...
async def rollback_relocation(self, tenant_id: uuid.UUID, *, actor: Principal) -> TenantRelocation: ...
async def assert_servable(self, tenant_id: uuid.UUID) -> TenantRecord: ...
async def list_for_user(self, user_id: uuid.UUID) -> Sequence[Tenant]: ...
class AccessControl:
async def invite(self, *, tenant_id, email, scope: ScopeRef, role_id, actor) -> Invitation: ...
async def accept_invitation(self, token: str, principal: Principal) -> Membership: ...
async def create_role(self, tenant_id, name: str, permissions: Collection[str], actor) -> Role: ...
async def update_role(self, role: Role, *, permissions: Collection[str], actor) -> Role: ...
async def delete_role(self, role: Role, *, actor) -> None: ...
async def bind_role(self, *, tenant_id, role_id, principal: PrincipalRef,
scope: ScopeRef, actor) -> RoleBinding: ...
async def unbind_role(self, binding: RoleBinding, *, actor) -> None: ...
async def grant_resource(self, *, tenant_id, resource_type: str, resource_id: uuid.UUID,
principal: PrincipalRef, permission: str, actor) -> ResourceGrant: ...
async def revoke_grant(self, grant: ResourceGrant, *, actor) -> None: ...
async def transfer_resource(self, resource, *, to_org=None, to_team=None,
revoke_grants: bool = False, actor) -> None: ...
async def move_team(self, team: Team, *, to_org: Organization, actor) -> None: ...
All methods: run under the lifecycle advisory lock where they mutate lifecycle,
validate every polymorphic reference (I11/L14), enforce scope and namespace
compatibility (L16), write mandatory audit events, invalidate the request-scoped
AuthzCache after any authorization-state change (section 10), and never expose
a raw control-plane session.
DeprovisionMode (archive, purge, destroy) lives in
jdlib.persistence.strategies.deprovision — the registry imports it from the
strategy layer because the mode selects the IsolationStrategy.deprovision
behavior. deprovision returns the control-plane Tenant row after re-reading
it under the (re-acquired) lifecycle lock; assert_servable loads the tenant,
migration state, relocation, and placement in one session without taking the
lifecycle lock and returns the loaded row as a TenantRecord (missing placement
raises ProvisioningError).
Relocation record and phases:
class RelocationPhase(StrEnum):
PENDING = "pending"; PROVISIONING = "provisioning"; COPYING = "copying"
VERIFYING = "verifying"; FLIPPING = "flipping"
POST_FLIP_VERIFICATION = "post_flip_verification"
COMPLETED = "completed"; FAILED = "failed"; ROLLED_BACK = "rolled_back"
@dataclass(frozen=True, slots=True)
class TenantRelocation:
tenant_id: uuid.UUID; id: uuid.UUID
from_strategy: StrategyName; from_target_handle: str; from_region: str | None
to_strategy: StrategyName; to_target_handle: str; to_region: str | None
phase: RelocationPhase
started_at: datetime; verified_at: datetime | None
flipped_at: datetime | None; completed_at: datetime | None
error: str | None
TenantPlacement stays the single authoritative placement; relocate() only
updates it inside the FLIPPING transaction, and the old target is retained
until COMPLETED (section 6 of 05-tenant-lifecycle.md). Phase transitions are
validated against the table in 02 section 3.3.1; invalid moves raise
RelocationPhaseError, and only failed/rolled_back → pending (explicit
retry) or the forward edges are allowed.
Serving gate: assert_servable() is the one predicate used by the router,
middleware, and migration runner, and it includes relocation state —
active AND migration success AND current = desired AND relocation permits
serving. Relocation permits serving in pending and provisioning, and
blocks it in copying, verifying, flipping, and
post_flip_verification. No adapter reimplements this predicate.
Write fence (separate concept, same registry):
class ServingState(StrEnum):
SERVABLE = "servable"; READ_ONLY = "read_only"
QUIESCING = "quiescing"; NOT_SERVABLE = "not_servable"
class WriteFence(Protocol): # implemented by TenantRegistry
async def assert_writable(self, tenant_id: uuid.UUID) -> None: ... # TenantNotWritable
assert_writable() is checked at session/transaction admission by
TenantSession, UnitOfWork.begin(), and @tenant_job execution — never by
HTTP middleware alone — so background jobs, queue consumers, CLI commands,
service-to-service calls, and privileged tenant writes are all fenced during the
relocation critical phases. Only relocation-control operations (system context
with the relocation.manage capability) bypass it. servable → writable today;
the split exists so a future read-only state needs no redesign. Drain after the
fence is bounded by relocation_drain_grace (default 2× maximum transaction
duration), and the snapshot starts only after the drain bound elapses.
9. Authentication¶
class RequestInfo(NamedTuple):
headers: Mapping[str, str]; query: Mapping[str, str]; path: str; host: str
class Authenticator(Protocol):
async def authenticate(self, request: RequestInfo) -> Principal | None: ...
class OidcAuthenticator(Authenticator):
# JwksCache (TTL + rotation), TokenVerifier (iss/aud/alg/exp/skew),
# ClaimMapper → (issuer, subject, email, email_verified),
# UserLinker: IdentityLink match → email link (only if IdP email_trusted) → create
class ApiKeyAuthenticator(Authenticator):
# KeyParser("prefix.keyid.secret") → lookup by key_prefix (control plane)
# → Argon2id.verify → ServiceAccount/tenant status → Principal(scopes=...)
class CompositeAuthenticator(Authenticator):
# first authenticator that returns a Principal wins; all failures audited
UserLinker.link returns a User bound to a session that closes before the
caller reads it, so the control-plane session factory must be created with
expire_on_commit=False; otherwise the commit expires every attribute and the
detached instance cannot be read.
Each authenticator audits only credentials that match its own scheme: the OIDC
authenticator only when the token parses as a JWT with a kid header, the
API-key authenticator only when the token's first segment equals
ApiKeyConfig.prefix (default jd). Failures that escape the authentication
flow itself (JdlibError, SQLAlchemy errors, JWKS fetch errors) are audited
fail-closed as authn.failed with the exception class name as reason, never
with token material.
authn/wiring.py adapts the shipped services to the ContextFactory ports
with translation only, no policy: PrincipalDirectoryAdapter and
EntitlementAdapter over ControlAccessReader (can_act routes USER
principals through principal_active + membership_active, and
SERVICE_ACCOUNT principals through pin equality plus
service_account_active(tenant_id, id); platform operators stay denied), and
TenantDirectoryAdapter
over TenantRegistry.find, which returns the tenant row plus its placement as a
TenantRecord or None when either is missing (assert_servable raises
ProvisioningError for the same missing-placement condition, a registry
invariant). The service-account invariant is that a service account may act only
in the tenant it is pinned to and only while service_account_active reports
active: an unpinned or foreign-tenant service account is denied on every path,
and ContextPropagator.reconstruct rebuilds a SERVICE_ACCOUNT principal pinned
to envelope.tenant_id so the same check holds on the job path.
build_authenticator(config, session_factory, audit) assembles the
OIDC and API-key authenticators from TenancyConfig into a
CompositeAuthenticator, skipping the OIDC member when TenancyConfig.oidc is
None. The middleware's PrincipalProvider port is callable, so callers pass
a bound authenticator.authenticate (or an equivalent adapter).
RequestInfo is defined in jdlib.tenancy.resolution and imported by authn/base.py.
10. Authorization¶
@dataclass(frozen=True, slots=True)
class Permission:
code: str; namespace: str; allowed_scopes: frozenset[ScopeType]; description: str
class PermissionCatalog:
def register(self, permission: Permission) -> None: ... # duplicate → error
def validate(self, code: str) -> Permission: ... # UnknownPermission
def all(self) -> Collection[Permission]: ...
@dataclass(frozen=True, slots=True)
class ResourceTypeDefinition:
resource_type: str; model: type; tenant_owned: bool
tenant_routed: bool; permission_namespace: str
def __post_init__(self) -> None: ...
# tenant_routed ⇒ tenant_owned; a routed model must expose
# organization_id and team_id (ScopeResolver reads both)
@dataclass(frozen=True, slots=True)
class ScopeRef:
type: ScopeType
id: uuid.UUID | None
@dataclass(frozen=True, slots=True)
class ResourceRef:
resource_type: str
resource_id: uuid.UUID
Target = ScopeRef | ResourceRef
class ScopeResolver:
async def chain(self, session: TenantSession, target: Target) -> tuple[ScopeRef, ...]: ...
@dataclass(frozen=True, slots=True)
class Decision:
allowed: bool
reason: str
matched: tuple[MatchedRule, ...]
class PolicyDecisionPoint(Protocol):
async def evaluate(self, principal: Principal, permission: str,
target: Target, ctx: TenantContext) -> Decision: ...
class DefaultPDP(PolicyDecisionPoint):
# Precedence chain → bindings → role permissions → grants → API-key scopes
# Namespace/scope validation for binding/grants lives in AccessControl.
# Memoization goes through AuthzCache.
# ControlAccessReader keys every principal-owned lookup (bindings, grants,
# activity, membership) through one canonical principal id: for USER
# principals `principal.user_id if principal.user_id is not None else
# principal.id`, for SERVICE_ACCOUNT principals `principal.id`. Envelope and
# job reconstruction carry only `id`, so they resolve via `id` today;
# carrying `user_id` on the envelope for USER principals whose `id` differs
# from their `user_id` is a future addition.
class AuthzCache:
"""Request-scoped only. AccessControl calls invalidate() before any
authorization-state mutation (role, permission, binding, grant, membership,
ownership transfer, team move), so no stale allow can be reused. A rolled
back mutation leaves the cache invalidated — fail-safe, no restoration."""
def invalidate(self) -> None: ...
class Enforcer:
async def require(self, permission: str, target: Target) -> Decision: ... # raises AuthorizationError
Declaration and explicit use:
@requires("invoice:approve", target=ResourceScope("invoice"))
async def approve(...): ...
decision = await authorize("invoice:approve", target=ResourceRef("invoice", id))
Enforcement points are exactly two: the guard decorator (HTTP) and authorize
(jobs/CLI/services). Both call the same Enforcer.
11. Request pipeline (tenancy)¶
Pure-ASGI TenantMiddleware per request:
authenticate (CompositeAuthenticator) → Principal | 401
resolve tenant (ResolverChain) → TenantRef | 404
verify entitlement (AccessControl) → 404 non-entitled
ContextFactory.for_principal(...) → TenantContext
install contextvar (fail-closed) → token
handle request (app)
finally: reset contextvar
- Middleware is pure ASGI (no BaseHTTPMiddleware buffering); exception handlers
map
JdlibErrorto HTTP per the fixed table. @tenant_jobwraps worker functions: accepts an envelope, callsContextPropagator.reconstruct, installs/clears the context.- Queue payloads carry envelope fields explicitly; nothing relies on contextvar survival.
12. Audit¶
class AuditRecorder:
async def record(self, *, action: str, target: ScopeRef | None,
metadata: Mapping[str, object] | None = None,
mandatory: bool = True) -> None: ...
- Tenant-routed mutations:
AuditEventinserted in the session transaction by repositories withaudited=True(or services callingrecord); failure rolls the business mutation back too. - Platform events:
DatabaseAuditSinkvia control-plane connection; called even when no tenant context exists. - Transaction ordering is frozen in
05-tenant-lifecycle.mdsection 5.1: same-transaction on-plane; cross-plane mandatory platform audit is durably committed before the tenant transaction applies the mutation (a durable outbox is the documented future upgrade). No path commits business state and then fails a required audit. - Mandatory events fail the operation closed when no sink can durably record them; best-effort events buffer and log.
AuditSinkis a port;CompositeAuditSinkfans out to SIEM adapters in a later version.
13. Migrations¶
class MigrationRunner:
def __init__(self, config: TenancyConfig, strategies: Mapping[...]) -> None: ...
async def upgrade_control(self, revision: str = "head") -> None: ...
async def upgrade_tenant(self, tenant: Tenant, revision: str = "head") -> MigrationState: ...
async def upgrade_all(self, *, concurrency: int = 4) -> Sequence[MigrationState]: ...
def emit_rls_policies(metadata: MetaData, *, only: Collection[str] | None = None) -> None: ...
Consumer Alembic env templates import TenantBase metadata and call
emit_rls_policies after table creation; ResourceTypeRegistry supplies the
routed-table set.
14. Integrations¶
FastAPI (integrations/fastapi.py):
@dataclass(frozen=True, slots=True)
class JdlibContainer:
session_factory: Callable[[], AsyncSession]
uow_factory: Callable[[], UnitOfWork]
enforcer: Enforcer
def install(
app: FastAPI,
*,
factory: ContextFactory,
chain: ResolverChain,
principal_provider: PrincipalProvider,
container: JdlibContainer | None = None,
) -> None: ... # middleware + handlers
async def get_context() -> TenantContext: ... # dependency
async def get_uow(request: Request) -> AsyncIterator[UnitOfWork]: ... # one UoW per request
def require(permission: str, target_factory: Callable[..., Target] | None = None) -> ...: ...
require authorizes in its own unit of work: the guard opens
container.uow_factory() (so RegistryWriteFence applies to guarded reads,
which fail with 423 TenantNotWritable during relocation quiesce) and passes
that session to Enforcer.require. The guard authorizes once, in that
transaction; the handler's get_uow opens a separate session (each
UnitOfWork gets a new one), so the guard's decision is not re-validated
against the handler's work: a permission revoked after the check is observed
only by a fresh evaluation in the handler's own session, not by re-checking
the guard's decision (a TOCTOU window between the guard and the handler).
CLI (integrations/cli.py, typer): tenant create|provision|list,
db upgrade-control|upgrade-tenants, rls install|verify, schema lint.
Every command accepts --database-url (default JDLIB_CONTROL_DSN) and
--json; provisioning a database placement needs the database strategy (and
a SecretProvider) registered programmatically, since the CLI registers only
shared and schema.
15. Testing design¶
- Unit tests need no database:
FakePDP,InMemoryAuditSink,StaticResolver,MemorySecretProvider, frozen clock. - Integration tests: one parametrized fixture
(
strategy_matrix) yields(strategy, rls_on)combinations against testcontainers Postgres; the same behavioral tests run everywhere. jdlib.testingexportsassert_scoped_count,assert_tenant_isolated,assert_cross_tenant_write_rejected; thepytest11plugin suppliesjdlib_database_url,jdlib_schema,jdlib_models,jdlib_engine, andjdlib_session_factoryfixtures.- Lint tests run L1–L19 against the consumer's combined metadata.
- TDD is the implementation workflow: each phase's gate tests are written before the phase's code.
16. Cross-cutting rules¶
- Public API is exactly the re-exports in
jdlib/__init__.py:TenancyConfig,TenantContext,current_tenant,current_principal,ContextFactory,UnitOfWork,TenantRepository,TenantRegistry,AccessControl,requires,authorize,JdlibError, and theerrorsmodule; everything else is internal-by-convention with an underscore or module privacy. - Strict typing (
pyright/mypy --strictforsrc/jdlib); public functions carry complete annotations; noAnyin public signatures. - Immutable value objects for context/principals/decisions; ORM models are mutable by nature and never leave the persistence boundary as DTOs — services return explicit DTOs where needed.
- Logging: stdlib
loggingwith structuredextrafields (tenant_id,request_id,correlation_id,action); no PII, no tokens, no key material. - Errors: only
JdlibErrorsubclasses cross module boundaries; adapters map driver exceptions (IntegrityError,OperationalError) to framework errors at the persistence boundary. - No silent fallbacks anywhere: missing context, missing placement, missing handle, missing audit sink, and missing RLS policy all fail closed.
17. Build order (maps to foundation design phase table)¶
| Phase | Files |
|---|---|
| 0 | doc sign-off; scaffolding (pyproject, CI, lint config) |
| 1 | control/base.py, control/models.py (incl. TenantRelocation), models/*, baseline migrations |
| 2 | context.py, errors.py, config.py, tenancy/resolution.py, tenancy/middleware.py |
| 3 | persistence/events.py, session.py, rawsql.py, uow.py, repository.py, router.py, strategies/base.py, lint tests |
| 4 | strategies/shared.py, control registry.py provisioning path, first end-to-end |
| 5 | authz/* (incl. cache.py), control/access.py, registry relocation operations, authn/base.py (static principal provider for tests) |
| 6 | authn/oidc.py, authn/apikey.py, authn/composite.py |
| 7 | strategies/schema.py, migration distribution |
| 8 | strategies/database.py, SecretProvider |
| 9 | strategies/rls.py, RLS tests |
| 10 | audit/*, testing/*, integrations/*, docs |