JDLib — Complete Technical Architecture and Implementation Reference¶
A snapshot, published with its own status
This is the long-form technical reference, written at commit 6125558 and synchronised through
the security-hardening programme. The successor phases landed after that revision -
jdlib.credentials, jdlib.caching, jdlib.data, jdlib.query, jdlib.storage,
jdlib.reliability, and the audit and observability boundaries - so its tree, its line counts
and its package list describe the earlier revision.
Where this document and a phase document disagree about a package that arrived later, the
phase document is current: see ../jdlib/successor/05-roadmap.md
for the per-phase status, ../jdlib/capabilities/README.md
for the capabilities delivered since, and ../jdlib/features/README.md
for how each mechanism is meant to be used.
Single source of truth for the
jdliblibrary. Derived from the repository at commita30601d(main) and synchronised with the security-hardening programme at commit6125558(main); the phase 11 documentation commit. §26.6 records exactly what that synchronisation revised, what was verified by execution, and what remains as inspected ata30601d; this revision's own edits landed in commits6125558(the documents),2bc22e3(the console script and the knowledge-base refresh),0623146(the phase 12 certification) and1b3044b(the suite numbers). Successor programme (after this snapshot). The successor phases landed after6125558:jdlib.credentials,jdlib.caching,jdlib.data(the connector framework),jdlib.query,jdlib.storageandjdlib.reliability, plus the audit and observability boundaries — each with its own record and status line underdocs/jdlib/successor/. The tree and the line counts in §3 describe the6125558revision; where this document and a phase document disagree about a package that arrived later, the phase document is current. Every architectural claim below is grounded in source code, tests, packaging metadata, or the frozen design documents underdocs/. All file references use repository-relative paths with line numbers, e.g.src/jdlib/control/registry.py:51-58.
Document Conventions¶
Status legend¶
| Label | Meaning |
|---|---|
| Implemented | Present in shipped source and covered by tests. |
| Partial | Present but with a narrower scope than the design documents imply. |
| Experimental | Present, unstable, or lightly covered. |
| Configured but unused | A configuration field exists but no shipped code path consumes it. |
| Not implemented | Named in design docs, absent from source. |
| Recommended | A usage pattern endorsed by the README/design docs, not enforced by code. |
| Unknown / Requires Confirmation | Cannot be determined from the repository. |
Accuracy rules applied¶
- No API, class, function, feature, dependency, or workflow is described unless it exists in the source tree.
- Design documents that drift from source are called out explicitly in §26 Accuracy and Verification Notes.
- No credentials, tokens, or real secrets appear in this document; only placeholder DSNs are used.
Table of Contents¶
- Executive Overview
- JDLib Purpose and Design Goals
- Package Architecture
- High-Level Architecture
- Module Architecture
- Public API Architecture
- Internal and Low-Level Architecture
- Feature-by-Feature Architecture
- Runtime and Execution Model
- Data Architecture
- Dependency Architecture
- Configuration Architecture
- Error and Exception Architecture
- Logging and Observability
- Security Architecture
- Performance and Concurrency
- Extensibility and Plugin Architecture
- Packaging and Distribution
- Testing Architecture
- Consumer Integration
- End-to-End Workflows
- Architectural Decisions
- Current Limitations
- Future Architecture (Proposed / Not Currently Implemented)
- Complete Architecture Diagrams
- Accuracy and Verification Notes
- Final Architecture Summary
1. Executive Overview¶
1.1 What JDLib is¶
jdlib is a Python library (importable package, not a service) that provides
the repetitive, failure-prone infrastructure of a multi-tenant SaaS application
on top of PostgreSQL and SQLAlchemy 2.0 (async).
It is a foundation: it owns request-scoped tenant context, physical/logical tenant isolation, row-level tenant scoping, scoped RBAC, authentication wiring, tenant lifecycle management, migrations, and audit — while leaving application models, HTTP routing, and business logic to the consumer.
| Property | Value | Source |
|---|---|---|
| Distribution name | jdlib |
pyproject.toml:5-6 |
| Version | 0.1.0 |
pyproject.toml:7, src/jdlib/__init__.py:27 |
| Python | >=3.12 (PEP 604 unions, PEP 695 generics) |
pyproject.toml:9 |
| Database | PostgreSQL 15+ (citext extension required by control plane) |
docs/jdlib/02-relational-schema.md:1-54 |
| Async model | AsyncSession / create_async_engine |
src/jdlib/persistence/session.py:256 |
| Build backend | hatchling |
pyproject.toml:1-3 |
| Public exports | 13 names | src/jdlib/__init__.py:11-25 |
| Test suite | 568 passing at a30601d; 1,217 passing / 6 skipped at the latest verified run in this revision |
verified by execution (§19.1, §26.6) |
1.2 The problem it solves¶
Multi-tenancy has a small number of design options and a large number of subtle failure modes. The most common production failures are:
- a query that silently reads or writes another tenant's rows;
- a raw SQL escape hatch that bypasses ORM scoping;
- a lifecycle operation that is not idempotent or not serialized;
- an authorization decision that silently widens for API keys;
- privileged access that is not explicit, bounded, or audited;
- an async background hop that executes under a stale tenant.
jdlib encodes these as mechanisms, not conventions:
| Failure mode | Mechanism | Implementation |
|---|---|---|
| Cross-tenant read/write | TenantSession event hooks scope every statement |
src/jdlib/persistence/session.py:109-254 |
| Raw SQL escape | pglast AST proof of tenant_id = $n per relation |
src/jdlib/persistence/rawsql.py:146-256 |
| Non-idempotent lifecycle | advisory locks + idempotent create/provision | src/jdlib/control/registry.py:285-365 |
| Authorization widening | additive, scope-checked, cache-invalidated, audited mutations | src/jdlib/authz/access.py:120-1027 |
| Implicit privilege | capability-bearing, time-bounded, audited PrivilegeContext |
src/jdlib/context.py:53-60, 254-302 |
| Stale async context | HMAC-signed envelope + re-validation at execution | src/jdlib/context.py:375-438, src/jdlib/tenancy/jobs.py:24-81 |
1.3 Who consumes it¶
A Python application team that:
- runs a multi-tenant SaaS product on PostgreSQL;
- wants one data-access API regardless of whether a tenant lives in a shared schema, its own schema, or its own database;
- wants framework-adjacent integrations (FastAPI, Typer) without adopting an application framework of its own.
1.4 Main use cases¶
- Shared-schema SaaS with logical
tenant_idscoping, optionally hardened with PostgreSQL row-level security. - Mid-market schema-per-tenant with per-schema namespaces and
search_pathtransaction scoping. - Enterprise database-per-tenant with secret-resolved DSNs and a bounded engine cache.
- Hybrid deployments — placement is per-tenant data; all three strategies
share one
TenantSessionsurface (README.md:271-298).
1.5 Core capabilities¶
- Request-scoped
TenantContextwith fail-closed accessors. - Five tenant resolvers (JWT claim, subdomain, path, header, API key) behind a first-match chain.
- Signed envelopes for internal/async hops with re-authorization at execution.
TenantSessionwith automatictenant_idscoping, stamping, immutability, and bulk-operation rejection.- Structural raw-SQL validation for
SELECT/UPDATE/DELETE/INSERT. - Scoped RBAC: permission catalog, roles, bindings, resource grants, invitations, ownership transfer, PDP with frozen precedence, request-scoped cache.
- Authentication: OIDC (JWKS + user linking) and Argon2-hashed API keys behind
one
Authenticatorprotocol. - Tenant lifecycle: create, provision, migrate, suspend, resume, archive, deprovision (archive/purge/destroy), plus tenant-plane purge.
- Programmatic Alembic migration runner for both planes.
- Platform and tenant audit sinks with caller-transaction participation.
- Schema metadata linter (12 implemented invariant rules).
- Testing kit: assertions plus an autoloaded pytest plugin.
- FastAPI middleware/dependencies and a Typer operator CLI.
1.6 Architectural principles (evidenced in source)¶
- Fail closed. No context → no data.
current_tenant()raisesMissingTenantContext(src/jdlib/context.py:113-117);TenantSessionconstructed withcontext=Noneraises immediately (src/jdlib/persistence/session.py:265-272). - Isolation is layered. Logical scoping is applied even when physical
separation already exists (invariant I9,
docs/jdlib/03-isolation-invariants.md). - Placement is data, not code. The strategy is chosen from a control-plane
row, not from branching code (
src/jdlib/persistence/router.py:13-23). - Privilege is explicit and audited. No implicit superuser path;
for_system/for_testrequire the module-privatePRIVILEGE_ISSUERsentinel and write an audit record before returning (src/jdlib/context.py:304-372). - Ports and adapters at the edges.
Protocoldefinitions (Authenticator,TenantResolver,IsolationStrategy,AccessReader,WriteFence,PlatformAudit,SecretProvider,SeedHook,PrivilegeAudit,OperatorAuthorizer,PrincipalDirectory,TenantDirectory,ServabilityChecker,EntitlementChecker,PolicyDecisionPoint) receive consumer implementations. - Crossing module boundaries uses
JdlibErroronly (README.md:389-390).
1.7 Major abstractions¶
| Abstraction | Role |
|---|---|
TenantContext / Principal / PrivilegeContext |
immutable request identity and authority |
ContextFactory |
builds a context after five fail-closed checks |
TenantSession |
scoped unit of database access |
UnitOfWork |
write-admission fence + transaction lifecycle |
TenantRepository |
scoped CRUD helper |
TenantRegistry |
control-plane lifecycle authority |
IsolationStrategy |
physical placement adapter |
DefaultPDP / Decision / MatchedRule |
authorization evaluation |
AccessControl |
authorization mutation authority |
AccessReader / ControlAccessReader |
control-plane authorization reads |
MigrationRunner |
programmatic Alembic driver |
EnvelopeCodec / ContextPropagator |
signed cross-hop context |
TenantMiddleware |
raw-ASGI request pipeline |
PermissionCatalog / ResourceTypeRegistry / ScopeResolver |
authorization vocabulary |
1.8 Main entry points¶
from jdlib import (
AccessControl, ContextFactory, JdlibError, TenancyConfig, TenantContext,
TenantRegistry, TenantRepository, UnitOfWork, authorize,
current_principal, current_tenant, errors, requires,
)
Framework entry points (submodules, not top-level re-exports):
from jdlib.integrations.fastapi import JdlibContainer, install, get_context, get_uow, require
from jdlib.integrations.cli import app # Typer
from jdlib.testing import assert_tenant_isolated # consumer test kit
1.9 System context¶
graph TB
subgraph Consumer["Consumer Application (not shipped by jdlib)"]
APP["Business app<br/>models, routes, jobs"]
CONFIG["Configuration & wiring<br/>TenancyConfig, engines, DI"]
end
subgraph JDLIB["JDLib library (src/jdlib)"]
API["Public API<br/>context · registry · uow · access · guards"]
CORE["Core enforcement<br/>TenantSession · raw SQL validator · PDP · lifecycle"]
ADAPTERS["Adapters<br/>FastAPI · Typer CLI · strategies · Alembic"]
end
subgraph EXT["External dependencies"]
PG[("PostgreSQL 15+<br/>control plane + tenant planes")]
IDP["OIDC identity provider"]
HTTP["httpx JWKS endpoint"]
FASTAPI["FastAPI / ASGI server"]
TYPER["Typer CLI host"]
end
APP --> API
CONFIG --> API
FASTAPI --> ADAPTERS --> CORE
TYPER --> ADAPTERS
CORE --> PG
ADAPTERS --> IDP
ADAPTERS --> HTTP
IDP --> HTTP
2. JDLib Purpose and Design Goals¶
2.1 Stated purpose¶
From README.md:1-7: "Reusable multi-tenant foundation for PostgreSQL +
SQLAlchemy applications. jdlib owns the repetitive, failure-prone parts of SaaS
tenancy."
From pyproject.toml:8: description = "Reusable multi-tenant foundation".
2.2 Frozen design goals¶
The approved specification (docs/superpowers/specs/2026-09-21-jdlib-multitenancy-foundation-design.md:1-64)
and the six design documents in docs/jdlib/ establish the following goals,
each of which is implemented:
| Goal | Design doc | Shipped mechanism |
|---|---|---|
| Two-plane data model: control plane owns metadata, tenant plane owns tenant data | 02-relational-schema.md:1-54 |
ControlBase (jd_control schema) vs TenantBase (unqualified) — src/jdlib/control/base.py:6-21, src/jdlib/models/base.py:13-14 |
Composite (tenant_id, id) identity, tenant-owned FKs, no cross-plane FKs |
02:462-527 |
TenantOwned + lint rules L1–L4 — src/jdlib/models/base.py:17-23, src/jdlib/lint.py:82-128 |
| Fail-closed tenant scoping of every data path (I1–I13) | 03-isolation-invariants.md:8-233 |
TenantSession events + RawSqlValidator |
| Explicit, capability-bearing, time-bounded, audited privilege (I6) | 04-authorization-model.md:248-290 |
PrivilegeContext, ContextFactory.for_operator |
| Signed envelopes with re-authorization across async boundaries (I7) | 05-tenant-lifecycle.md, phase-2 plan |
EnvelopeCodec + ContextPropagator.reconstruct |
| Placement as data across three strategies | 06-strategy-matrix.md:7-51 |
IsolationStrategy implementations + SessionRouter |
| Single servability predicate | 05:73-101 |
TenantRegistry._servability_error (src/jdlib/control/registry.py:124-140) |
| Platform and tenant audit with caller-transaction participation | 05:164-173 |
DatabaseAuditSink(session=...), TenantAuditRecorder |
| Framework-independent core, optional adapters | 07-implementation-design.md:747-787 |
pyproject.toml extras fastapi, cli; top-level package imports no optional dependency |
2.3 Non-goals (evidenced by absence)¶
- Not a web framework.
jdlibships no router, no app object, no template engine;README.mdsnippets compose it into FastAPI. - Not an ORM. SQLAlchemy models are declared, but no identity map, unit of identity, or migration authoring is provided beyond the two baseline revisions and the programmatic runner.
- Not a policy language engine. The PDP is Python code with SQL reads; no OPA/Cedar/Casbin integration exists.
- Not a job runner.
tenancy/jobs.pyprovides context reconstruction and a decorator; there is no queue, scheduler, or worker loop. - Not a secret manager. Secrets arrive through a
SecretProviderthe consumer implements. - Not a billing/metering product. No quota, plan, or entitlement
persistence;
EntitlementCheckeris a consumer-supplied port.
2.4 Design tensions and how the code resolves them¶
| Tension | Resolution in code |
|---|---|
| Convenience vs. isolation guarantees | Ergonomic repository/UoW API on top of hard enforcement in session events; guards read ambient context rather than accepting a tenant argument |
| One API across three placements | TenantSession wraps a plain AsyncSession; strategies only produce sessions and own physical lifecycle |
| Performance vs. proof | Bulk DML is refused (UnscopedBulkOperation) unless it flows through the scoped helpers; raw SQL is parsed and proven rather than regex-matched |
| Correctness vs. availability | Lifecycle failures are committed as failed state and re-raised; the tenant remains provisioning and unservable |
3. Package Architecture¶
3.1 Repository tree (tracked files)¶
jdlib/
├── .github/
│ └── workflows/
│ └── ci.yml # two jobs: test (ruff, mypy, pytest) and
│ # security (phase 10 gates — §19.6)
├── .gitleaks.toml # reviewed, value-pinned allowlist (phase 10)
├── .gitignore # ignores .venv, caches, dist, .superpowers,
│ # .worktrees, tests/infra/out/, security-artifacts
├── README.md # 390-line consumer guide + canonical wiring
│ # (+ a security/compliance section, phase 11)
├── pyproject.toml # hatchling metadata, deps, tool config (64 lines)
├── docs/
│ ├── jdlib/
│ │ ├── 02-relational-schema.md # 538 lines — schema contract
│ │ ├── 03-isolation-invariants.md # 293 lines — I1–I13 + raw-SQL proof table
│ │ ├── 04-authorization-model.md # 360 lines — RBAC contract + role/action matrix
│ │ ├── 05-tenant-lifecycle.md # 304 lines — lifecycle + relocation phases
│ │ ├── 06-strategy-matrix.md # 201 lines — three strategies + RLS
│ │ └── 07-implementation-design.md# 839 lines — code-level blueprint
│ ├── security/
│ │ ├── security-core.md # Phase 1 — context, config, errors, ports
│ │ ├── authentication-hardening.md# Phase 2 — JWKS/JWT, tokens, ZITADEL, identity
│ │ ├── authorization-hardening.md # Phase 3 — decisions, PDP port, Cerbos, PEP
│ │ ├── gateway-hardening.md # Phase 4 — adapters (Kong verified; Tyk limited)
│ │ ├── error-responses.md # Phase 5 — codes, envelope, WWW-Authenticate
│ │ ├── audit-hardening.md # Phase 6 — vocabulary, envelope, access, export
│ │ ├── observability-hardening.md # Phase 7 — logs, metrics, spans, collector
│ │ ├── adversarial-review.md # Phase 8 — attacks, findings, verdicts
│ │ ├── compliance-evidence.md # Phase 9 — register, evidence, posture, CLI
│ │ └── ci-security.md # Phase 10 — the CI security gates
│ └── superpowers/
│ ├── specs/2026-09-21-jdlib-multitenancy-foundation-design.md
│ └── plans/2026-09-21-jdlib-*.md # 9 phase plans, all marked complete
├── knowledge_base/ # 36 documents written at a30601d; refreshed in the
│ # working tree for the security programme (uncommitted
│ # at the time of writing — each page carries a banner)
├── src/
│ └── jdlib/ # 104 tracked files, 15,202 lines (shipped package)
└── tests/ # 125 tracked files, 23,269 lines
├── unit/ # 66 modules (37 top-level + 29 under security/),
│ # 677 test defs
├── integration/ # 33 test modules + 3 support modules, 307 test defs
├── infra/ # 5 modules, 35 test defs — Kong, Cerbos, ZITADEL,
│ # OpenTelemetry and Tyk harnesses
└── support/ # test-only fakes and a consumer model fixture
Absent by design or by omission: examples/, LICENSE, Dockerfile, root
compose files, Makefile, tox.ini, noxfile.py, lock files (uv.lock,
poetry.lock), setup.py, setup.cfg, requirements*.txt,
.pre-commit-config.yaml, AGENTS.md. pyproject.toml declares no readme,
license, authors, classifiers, keywords, or urls metadata.
Added by the phase 11 documentation commit (6125558, "documentation and
knowledge base in line with the code"): CHANGELOG.md, CONTRIBUTING.md,
SECURITY.md, docs/security/README.md, docs/compliance/README.md,
docs/operations/README.md, docs/threat-model/README.md, a modified
README.md, and the first synchronisation of this document.
3.2 Shipped package tree¶
src/jdlib/
├── __init__.py # 13 public re-exports + __version__
├── _uuid.py # uuid7() (private module)
├── config.py # pydantic-settings configuration models
├── context.py # Principal/PrivilegeContext/TenantContext,
│ # ContextFactory, EnvelopeCodec, context_scope
├── errors.py # JdlibError hierarchy (28 classes; 27 exported)
├── lint.py # metadata invariant linter (12 implemented rules)
├── py.typed # PEP 561 marker
├── authn/
│ ├── __init__.py # empty (0 bytes)
│ ├── base.py # Authenticator protocol, bearer_token, record_authn_failure
│ ├── apikey.py # parse/hash/verify + ApiKeyAuthenticator
│ ├── oidc.py # JwksCache, TokenVerifier, ClaimMapper, UserLinker,
│ │ # OidcAuthenticator
│ ├── composite.py # CompositeAuthenticator (first success wins)
│ └── wiring.py # build_authenticator + Principal/Tenant/Entitlement adapters
├── authz/
│ ├── __init__.py # empty (0 bytes)
│ ├── permissions.py # Permission, PermissionCatalog, 13 built-ins
│ ├── resource_types.py # ResourceTypeDefinition, ResourceTypeRegistry
│ ├── scopes.py # ScopeRef, ResourceRef, Target, ScopeResolver
│ ├── reader.py # AccessReader protocol, ControlAccessReader
│ ├── pdp.py # Decision, MatchedRule, PolicyDecisionPoint, DefaultPDP
│ ├── cache.py # AuthzCache (request-scoped memo, one-way invalidation)
│ ├── guards.py # tenant_target, Enforcer, requires, authorize
│ └── access.py # AccessControl (roles/bindings/grants/invitations/transfer)
├── control/ # namespace package (NO __init__.py)
│ ├── base.py # CONTROL_SCHEMA="jd_control", naming convention, ControlBase
│ ├── enums.py # control-plane StrEnums + sql_in()
│ ├── models.py # 16 control-plane SQLAlchemy models
│ ├── audit.py # PlatformAudit protocol, DatabaseAuditSink,
│ │ # CompositeAuditSink, RecordingPlatformAudit,
│ │ # PrivilegeAuditAdapter
│ ├── registry.py # TenantRegistry, RegistryWriteFence, SeedHook
│ ├── purge.py # TenantPlanePurger
│ └── session.py # ControlPlaneSession wrapper
├── integrations/
│ ├── __init__.py # docstring + __future__ only
│ ├── fastapi.py # install, JdlibContainer, get_context, get_uow, require
│ └── cli.py # Typer app: tenant/db/rls/schema/security groups
├── migrations/
│ ├── __init__.py # empty
│ ├── runner.py # MigrationRunner (synchronous, programmatic Alembic)
│ ├── control/
│ │ ├── env.py, script.py.mako
│ │ └── versions/0001_control_baseline.py
│ └── tenant/
│ ├── env.py, script.py.mako
│ └── versions/0001_tenant_baseline.py
├── models/ # namespace package (NO __init__.py)
│ ├── base.py # TenantBase, TenantOwned, TenantRouted, Timestamped,
│ │ # SoftDelete, OwnableByOrg/Team, ownership_constraints
│ ├── org.py # Organization, Team
│ ├── settings.py # TenantSetting
│ ├── audit.py # tenant-plane AuditEvent
│ └── audit_recorder.py # TenantAuditRecorder + fail-closed attribution (§8.13)
├── persistence/
│ ├── __init__.py # empty
│ ├── models.py # tenant_table_map, WriteFence, NullWriteFence,
│ │ # privileged_bypass
│ ├── session.py # TenantSession + SQLAlchemy event enforcement
│ ├── rawsql.py # RawSqlValidator, RawSqlMode, CompiledRawSql
│ ├── uow.py # UnitOfWork
│ ├── repository.py # TenantRepository[ModelT]
│ ├── router.py # SessionRouter
│ ├── secrets.py # SecretProvider, MemorySecretProvider, EnvSecretProvider
│ └── strategies/
│ ├── __init__.py # empty
│ ├── base.py # StrategyCapabilities, IsolationStrategy protocol
│ ├── deprovision.py # DeprovisionMode
│ ├── shared.py # SharedSchemaStrategy
│ ├── schema.py # SchemaPerTenantStrategy, validate_schema_name
│ ├── database.py # DatabasePerTenantStrategy, validate_database_handle
│ └── rls.py # rls_eligible_tables, emit_rls_policies, install_rls,
│ # RlsInspection, inspect_rls, verify_rls
├── security/ # security programme (§15.4); __init__ files re-export
│ ├── __init__.py # core surface: context, config, errors, interfaces, responses
│ ├── config.py # SecurityConfig + sections, validate_security_config
│ ├── context.py # SecurityContext, RequestIds, AuthMethod, contextvar binding
│ ├── errors.py # SecurityCode/Descriptor, describe_error, www_authenticate
│ ├── identity.py # service-identity formalisation (IdentityKind, ServiceIdentity)
│ ├── interfaces.py # Authenticator/Gateway/RateLimiter/TokenProvider ports
│ ├── redaction.py # the one canonical secret-redaction pattern set
│ ├── responses.py # ErrorResponse, error_response (safe client-visible failures)
│ ├── telemetry.py # security_log_record, SecurityMetrics, safe_emit (Phase 7)
│ ├── tracing.py # traceparent parse/format, security spans (Phase 7)
│ ├── audit/ # vocabulary, envelope, emitters, access, export, adapters
│ ├── authn/ # algorithm policy, JwtTokenValidator, token provider, ZITADEL
│ ├── authz/ # decision model, PDP port, Cerbos adapter, AuthorizationPEP
│ ├── gateway/ # NoGatewayAdapter, KongGatewayAdapter, TykGatewayAdapter
│ └── compliance/ # control register, evidence, posture, session collectors (§8.22)
├── tenancy/
│ ├── __init__.py # empty
│ ├── resolution.py # RequestInfo, resolvers, ResolverChain, build_chain
│ ├── middleware.py # TenantMiddleware, http_status_for, PrincipalProvider
│ └── jobs.py # ContextPropagator, tenant_job
└── testing/
├── __init__.py # 3 assertion re-exports
├── assertions.py # assert_scoped_count, assert_tenant_isolated,
│ # assert_cross_tenant_write_rejected
└── pytest_plugin.py # pytest11 plugin: jdlib_* fixtures
3.3 Public vs. internal API boundary¶
Public (top-level jdlib): exactly the 13 names in src/jdlib/__init__.py:11-25.
tests/unit/test_public_api.py:9-25 asserts this exact set, and
tests/unit/test_public_api.py:46-59 asserts that importing jdlib does not
import fastapi, pytest, or typer.
Public-by-submodule (used by the README quickstart, therefore de facto public):
| Submodule | Public symbols |
|---|---|
jdlib.authn.wiring |
build_authenticator, PrincipalDirectoryAdapter, TenantDirectoryAdapter, EntitlementAdapter, fetch_jwks |
jdlib.authz.guards |
Enforcer, tenant_target, requires, authorize |
jdlib.authz.pdp |
Decision, MatchedRule, PolicyDecisionPoint, DefaultPDP |
jdlib.authz.permissions |
Permission, PermissionCatalog |
jdlib.authz.resource_types |
ResourceTypeDefinition, ResourceTypeRegistry |
jdlib.authz.scopes |
ScopeRef, ResourceRef, Target, ScopeResolver |
jdlib.authz.reader |
AccessReader, ControlAccessReader, Binding |
jdlib.authz.cache |
AuthzCache |
jdlib.control.enums |
TenantStatus, PlacementStrategy, MigrationStatus, RelocationPhase, OperatorLevel, PrincipalType, ScopeType, MembershipStatus, InvitationStatus, ApiKeyStatus, ActorType, AuditSeverity |
jdlib.control.registry |
TenantRegistry, RegistryWriteFence, SeedHook |
jdlib.control.audit |
PlatformAudit, DatabaseAuditSink, CompositeAuditSink, RecordingPlatformAudit, PrivilegeAuditAdapter |
jdlib.control.session |
ControlPlaneSession |
jdlib.control.purge |
TenantPlanePurger |
jdlib.migrations.runner |
MigrationRunner |
jdlib.persistence.session |
TenantSession, RawSqlAuditor |
jdlib.persistence.uow |
UnitOfWork |
jdlib.persistence.repository |
TenantRepository |
jdlib.persistence.router |
SessionRouter |
jdlib.persistence.models |
tenant_table_map, WriteFence, NullWriteFence, privileged_bypass |
jdlib.persistence.rawsql |
RawSqlMode, RawSqlValidator, CompiledRawSql |
jdlib.persistence.secrets |
SecretProvider, MemorySecretProvider, EnvSecretProvider |
jdlib.persistence.strategies.* |
strategy classes, StrategyCapabilities, IsolationStrategy, DeprovisionMode, validate_schema_name, validate_database_handle, install_rls, verify_rls, emit_rls_policies, rls_eligible_tables |
jdlib.tenancy.resolution |
RequestInfo, TenantResolver, 5 resolvers, ResolverChain, build_chain |
jdlib.tenancy.middleware |
TenantMiddleware, http_status_for, PrincipalProvider |
jdlib.tenancy.jobs |
ContextPropagator, tenant_job |
jdlib.models.base |
TenantBase, TenantOwned, TenantRouted, Timestamped, SoftDelete, OwnableByOrg, OwnableByTeam, ownership_constraints |
jdlib.models.audit_recorder |
TenantAuditRecorder |
jdlib.lint |
LintIssue, lint_metadata, assert_clean |
jdlib.context |
Principal, PrincipalKind, PrivilegeContext, PrivilegeKind, TenantContext, TenantRef, TenantRecord, Clock, SystemClock, ContextFactory, EnvelopeCodec, TenantContextEnvelope, context_scope, current_tenant, current_principal, current_privilege, scoped_key, and the six collaborator protocols |
Internal (underscore-prefixed or clearly private):
| Symbol | Location | Note |
|---|---|---|
_CTX |
src/jdlib/context.py:99-101 |
the single ContextVar |
_PrivilegeIssuer, PRIVILEGE_ISSUER |
src/jdlib/context.py:167-171 |
identity sentinel gating for_system/for_test |
_uuid.uuid7 |
src/jdlib/_uuid.py:8 |
private module, imported by internal models |
_STATUS_MAP |
src/jdlib/tenancy/middleware.py:26-35 |
HTTP mapping table |
_transition, _servability_error, _advisory_lock |
src/jdlib/control/registry.py |
registry internals |
_register_events, _context_of |
src/jdlib/persistence/session.py:46-109 |
SQLAlchemy event wiring |
All rawsql private helpers |
src/jdlib/persistence/rawsql.py:42-143 |
AST walking internals |
Naming-package caveat: jdlib.models and jdlib.control have no
__init__.py; they are namespace-package portions. jdlib.authz,
jdlib.authn, jdlib.persistence, jdlib.tenancy, and
jdlib.persistence.strategies have empty __init__.py files with no
re-exports. The security programme's namespaces are the exception: jdlib.security
and its sub-packages (jdlib.security.audit, .authn, .authz, .gateway,
.compliance) have non-empty __init__.py files that re-export their
deliberate public surface (§15.4, §8.22).
4. High-Level Architecture¶
4.1 Layering¶
jdlib is a four-layer library:
graph TB
subgraph L4["Layer 4 — Adapters (optional deps)"]
FA["integrations/fastapi.py"]
CLI["integrations/cli.py"]
ALE["migrations/runner.py + env.py"]
RP["testing/pytest_plugin.py"]
end
subgraph L3["Layer 3 — Domain services"]
CTXF["ContextFactory"]
REG["TenantRegistry + RegistryWriteFence"]
AC["AccessControl"]
PDP["DefaultPDP / Enforcer / guards"]
PDPORT["AccessReader / TenantDirectory / EntitlementChecker / OperatorAuthorizer / SecretProvider / SeedHook / PlatformAudit"]
end
subgraph L2["Layer 2 — Core enforcement"]
TS["TenantSession (SQLAlchemy events)"]
RSV["RawSqlValidator (pglast AST)"]
UOW["UnitOfWork (fence + txn)"]
REPO["TenantRepository"]
CACHE["AuthzCache"]
ENV["EnvelopeCodec / ContextPropagator"]
LINT["lint_metadata"]
end
subgraph L1["Layer 1 — Value types and vocabularies"]
CTX["TenantContext / Principal / PrivilegeContext"]
ERR["errors.py (JdlibError)"]
CFG["config.py (TenancyConfig)"]
ENUM["control/enums.py + ScopeType + PermissionCatalog + ResourceTypeRegistry"]
BASE["models/base.py + control/base.py (SQLAlchemy bases)"]
end
L4 --> L3
L3 --> L2
L2 --> L1
L1 --> PG[("PostgreSQL")]
L4 --> PG
Dependency direction is strictly downward. No import cycles exist in the static module graph (verified in §26).
4.2 Component responsibilities¶
| Component | Responsibility | Key modules |
|---|---|---|
| Context subsystem | identity, authority, request scope, cross-hop propagation | context.py, tenancy/resolution.py, tenancy/jobs.py |
| Request pipeline | ASGI authentication → tenant resolution → context install → cleanup; error-to-HTTP mapping | tenancy/middleware.py |
| Control plane | tenant metadata, placement, lifecycle, users/service accounts/API keys, audit | control/* |
| Isolation strategies | physical placement provisioning, session production, deprovisioning | persistence/strategies/* |
| Data access | tenant-scoped session, repository, unit of work, session routing | persistence/session.py, repository.py, uow.py, router.py |
| Raw SQL gate | AST-level tenant proof and privileged escape hatch | persistence/rawsql.py |
| Authentication | OIDC, API keys, composition, wiring adapters | authn/* |
| Authorization | vocabulary, scope resolution, decisions, mutations | authz/* |
| Migrations | in-memory Alembic configuration and two baselines | migrations/* |
| Schema governance | metadata invariant linter | lint.py |
| Consumer tooling | FastAPI deps, Typer CLI, pytest fixtures, assertions | integrations/*, testing/* |
4.3 Package-level architecture¶
graph LR
CONSUMER["Consumer app"] --> PUBAPI["jdlib (13 exports)"]
PUBAPI --> CTX["context + tenancy"]
PUBAPI --> PERS["persistence"]
PUBAPI --> REG["control.registry"]
PUBAPI --> AUTHZ["authz"]
PUBAPI --> ERR["errors"]
CTX --> CFG["config"]
CTX --> ENUM["control.enums"]
PERS --> MODELS["models"]
PERS --> STRAT["persistence.strategies"]
REG --> STRAT
REG --> MIG["migrations.runner"]
REG --> PURGE["control.purge"]
AUTHZ --> CTRL["control.models + control.session"]
AUTHN["authn"] --> CTRL
AUTHN --> TENRES["tenancy.resolution"]
AUTHZ --> SCOPES["authz.scopes"]
SCOPES --> PERS
CLI["integrations.cli"] --> LINT["lint"]
CLI --> RLS["strategies.rls"]
FASTAPI["integrations.fastapi"] --> MW["tenancy.middleware"]
FASTAPI --> GUARDS["authz.guards"]
TESTING["testing"] --> PERS
4.4 Public API flow¶
sequenceDiagram
autonumber
participant App as Consumer handler
participant Ctx as current_tenant()
participant UoW as UnitOfWork
participant Fence as RegistryWriteFence
participant TS as TenantSession
participant Repo as TenantRepository
participant DB as PostgreSQL
App->>Ctx: read ambient context
Ctx-->>App: TenantContext (or raise MissingTenantContext)
App->>UoW: __aenter__(context, fence=…)
UoW->>Fence: assert_writable(tenant_id)
Fence->>DB: read tenants + migration state + relocations
DB-->>Fence: servability rows
UoW->>TS: wrap AsyncSession
App->>Repo: list() / get() / add() / count()
Repo->>TS: ORM statement
TS->>TS: do_orm_execute → with_loader_criteria(tenant_id)
TS->>DB: SELECT … WHERE tenant_id = $1
DB-->>App: rows
App->>UoW: __aexit__(no exception)
UoW->>TS: commit + close
5. Module Architecture¶
5.1 Module inventory (64 pre-hardening modules + 2 Mako templates)¶
The modules the library shipped at a30601d. The security programme's modules
(added by phases 1–10) are inventoried separately in §5.3.
| Module | Purpose | Public classes/functions | Depends on |
|---|---|---|---|
jdlib.__init__ |
curated public surface | 13 re-exports, __version__ |
authz.access, authz.guards, config, context, control.registry, errors, persistence.repository, persistence.uow |
jdlib._uuid |
UUIDv7 generation | uuid7() |
stdlib only |
jdlib.config |
settings schema | ResolverName, ContextConfig, ResolverConfig, OidcConfig, ApiKeyConfig, RlsConfig, TenancyConfig |
pydantic, pydantic_settings |
jdlib.context |
identity + context factory + envelopes | see §3.3 | config, control.enums, errors |
jdlib.errors |
exception hierarchy | JdlibError + 27 subclasses |
stdlib only |
jdlib.lint |
metadata invariants | LintIssue, lint_metadata, assert_clean |
sqlalchemy, control.base, errors, models.base |
jdlib.authn.base |
authn port + helpers | Authenticator, record_authn_failure, bearer_token |
context, control.audit, tenancy.resolution |
jdlib.authn.apikey |
API-key authentication | ParsedApiKey, parse_api_key, hash_api_key_secret, verify_api_key_secret, ApiKeyAuthenticator |
argon2, sqlalchemy, control.models/session/audit, context, config, errors |
jdlib.authn.oidc |
OIDC authentication | JwksCache, TokenVerifier, IdentityClaims, ClaimMapper, UserLinker, OidcAuthenticator |
pyjwt, sqlalchemy, control.models/session/audit, context, config, errors, _uuid |
jdlib.authn.composite |
authenticator fan-in | CompositeAuthenticator |
authn.base |
jdlib.authn.wiring |
assembly + adapters | fetch_jwks, build_authenticator, PrincipalDirectoryAdapter, TenantDirectoryAdapter, EntitlementAdapter |
httpx, authn.*, authz.reader, control.registry |
jdlib.authz.permissions |
permission vocabulary | Permission, PermissionCatalog |
control.enums, errors |
jdlib.authz.resource_types |
resource type vocabulary | ResourceTypeDefinition, ResourceTypeRegistry |
authz.permissions, errors |
jdlib.authz.scopes |
scope chain computation | ScopeRef, ResourceRef, Target, ScopeResolver |
authz.resource_types, control.enums, models.org, persistence.session |
jdlib.authz.reader |
authorization reads | Binding, principal_key, AccessReader, ControlAccessReader |
sqlalchemy, control.models/session, context, control.enums |
jdlib.authz.pdp |
decision evaluation | MatchedRule, Decision, PolicyDecisionPoint, DefaultPDP |
authz.{cache,permissions,reader,scopes}, context, errors, persistence.session |
jdlib.authz.cache |
request-scoped memo | AuthzCache |
stdlib only |
jdlib.authz.guards |
enforcement helpers | tenant_target, Enforcer, requires, authorize |
authz.{pdp,scopes}, context, control.enums, errors |
jdlib.authz.access |
authorization mutations | AccessControl, ScopeRecord, ResourceRecord, ScopeLookup, ResourceLookup, TenantWriteSession, build_invitation_token, hash_invitation_secret, verify_invitation |
argon2, sqlalchemy, control.models/session/audit, authz.{cache,permissions,reader,resource_types}, models.org, context, errors, _uuid |
jdlib.control.base |
control metadata | CONTROL_SCHEMA, NAMING_CONVENTION, ControlBase |
sqlalchemy |
jdlib.control.enums |
control vocabularies | 12 StrEnums + sql_in |
stdlib only |
jdlib.control.models |
control schema | 16 ORM models | sqlalchemy, control.base/enums, _uuid |
jdlib.control.audit |
platform audit | PlatformAudit, NullPlatformAudit, RecordingPlatformAudit, DatabaseAuditSink, CompositeAuditSink, PrivilegeAuditAdapter |
sqlalchemy, control.session, context, errors |
jdlib.control.registry |
lifecycle authority | TenantRegistry, RegistryWriteFence, SeedHook |
sqlalchemy, control.{audit,enums,models,purge,session}, errors, migrations.runner, persistence.strategies.{base,database,deprovision,schema}, context |
jdlib.control.purge |
tenant-plane purge | TenantPlanePurger |
sqlalchemy, config, context, models.base, persistence.models/session |
jdlib.control.session |
control session wrapper | ControlPlaneSession |
sqlalchemy |
jdlib.integrations.fastapi |
FastAPI adapter | JdlibContainer, install, get_context, get_uow, require |
fastapi, starlette, authz.guards/scopes, context, errors, persistence.uow, tenancy.middleware/resolution |
jdlib.integrations.cli |
Typer CLI | app (+ 5 sub-apps, incl. security_app) |
typer, sqlalchemy, control.*, errors, lint, migrations.runner, models.base, persistence.strategies.{rls,schema,shared}, security.compliance(.session), security.config |
jdlib.migrations.runner |
programmatic Alembic | MigrationRunner |
alembic, sqlalchemy, control.base, errors |
jdlib.migrations.control.env |
control Alembic env | run_migrations_offline/online |
alembic, sqlalchemy, control.models/base |
jdlib.migrations.tenant.env |
tenant Alembic env | run_migrations_offline/online |
alembic, sqlalchemy, models.* |
jdlib.models.base |
tenant metadata + mixins | TenantBase, TenantOwned, TenantRouted, Timestamped, SoftDelete, OwnableByOrg, OwnableByTeam, ownership_constraints |
sqlalchemy, control.base |
jdlib.models.org |
example org graph | Organization, Team |
sqlalchemy, models.base, _uuid |
jdlib.models.settings |
example KV model | TenantSetting |
sqlalchemy, models.base |
jdlib.models.audit |
tenant audit model | AuditEvent |
sqlalchemy, control.enums, models.base, _uuid |
jdlib.models.audit_recorder |
tenant audit writer | TenantAuditRecorder (record, record_security) |
context, models.audit, security.audit, security.context |
jdlib.persistence.models |
tenant model discovery + fence port | tenant_table_map, WriteFence, NullWriteFence, privileged_bypass |
context, models.base |
jdlib.persistence.session |
scoped session | TenantSession, RawSqlAuditor |
sqlalchemy, context, errors, models.base, persistence.models, persistence.rawsql |
jdlib.persistence.rawsql |
raw SQL proof | RawSqlMode, CompiledRawSql, RawSqlValidator |
pglast, sqlalchemy, errors, persistence.models |
jdlib.persistence.uow |
unit of work | UnitOfWork |
context, persistence.models/session |
jdlib.persistence.repository |
scoped repository | TenantRepository[ModelT] |
sqlalchemy, persistence.session |
jdlib.persistence.router |
strategy dispatch | SessionRouter |
sqlalchemy, context, control.enums, errors, persistence.strategies.base |
jdlib.persistence.secrets |
secret ports | SecretProvider, MemorySecretProvider, EnvSecretProvider |
stdlib only |
jdlib.persistence.strategies.base |
strategy port | StrategyCapabilities, IsolationStrategy |
context, migrations.runner, persistence.strategies.deprovision |
jdlib.persistence.strategies.deprovision |
deprovision modes | DeprovisionMode |
stdlib only |
jdlib.persistence.strategies.shared |
shared-schema strategy | SharedSchemaStrategy |
config, context, errors, migrations.runner, strategies.base/deprovision |
jdlib.persistence.strategies.schema |
schema strategy | validate_schema_name, SchemaPerTenantStrategy |
context, errors, migrations.runner, strategies.base/deprovision |
jdlib.persistence.strategies.database |
database strategy | validate_database_handle, DatabasePerTenantStrategy |
context, errors, migrations.runner, persistence.secrets, strategies.base/deprovision |
jdlib.persistence.strategies.rls |
RLS install/inspect/verify | rls_eligible_tables, emit_rls_policies, install_rls, RlsInspection, inspect_rls, verify_rls |
sqlalchemy, errors, models.base |
jdlib.tenancy.resolution |
tenant resolution | RequestInfo, TenantResolver, JwtClaimResolver, SubdomainResolver, PathResolver, HeaderResolver, ApiKeyResolver, ResolverChain, build_chain |
config, context |
jdlib.tenancy.middleware |
ASGI pipeline | http_status_for, PrincipalProvider, TenantMiddleware |
context, errors, tenancy.resolution |
jdlib.tenancy.jobs |
cross-hop context | ContextPropagator, tenant_job |
context, errors |
jdlib.testing.assertions |
isolation assertions | assert_scoped_count, assert_tenant_isolated, assert_cross_tenant_write_rejected |
sqlalchemy, context, errors, persistence.session |
jdlib.testing.pytest_plugin |
consumer fixtures | jdlib_test_models + 5 fixtures |
pytest, optional pytest_asyncio, sqlalchemy, models.base |
5.2 Module dependency graph (verified static imports)¶
graph LR
subgraph root["root modules"]
JD["jdlib"]
CFG["config"]
CTX["context"]
ERR["errors"]
LINT["lint"]
UUID["_uuid"]
end
subgraph model["models"]
MB["models.base"]
MORG["models.org"]
MSET["models.settings"]
MAUD["models.audit"]
MAREC["models.audit_recorder"]
end
subgraph control["control"]
CB["control.base"]
CE["control.enums"]
CM["control.models"]
CS["control.session"]
CA["control.audit"]
CREG["control.registry"]
CPURGE["control.purge"]
end
subgraph pers["persistence"]
PM["persistence.models"]
PS["persistence.session"]
PRAW["persistence.rawsql"]
PUOW["persistence.uow"]
PREPO["persistence.repository"]
PROUT["persistence.router"]
PSEC["persistence.secrets"]
SB["strategies.base"]
SDEP["strategies.deprovision"]
SSH["strategies.shared"]
SSC["strategies.schema"]
SD["strategies.database"]
SR["strategies.rls"]
end
subgraph auth["authn + authz"]
ANB["authn.base"]
ANK["authn.apikey"]
ANO["authn.oidc"]
ANC["authn.composite"]
ANW["authn.wiring"]
AZP["authz.permissions"]
AZR["authz.resource_types"]
AZS["authz.scopes"]
AZRD["authz.reader"]
AZPDP["authz.pdp"]
AZC["authz.cache"]
AZG["authz.guards"]
AZA["authz.access"]
end
subgraph tctx["tenancy"]
TR["tenancy.resolution"]
TM["tenancy.middleware"]
TJ["tenancy.jobs"]
end
MIG["migrations.runner"]
JD --> CFG
JD --> CTX
JD --> ERR
JD --> CREG
JD --> AZA
JD --> AZG
JD --> PUOW
JD --> PREPO
CTX --> CFG
CTX --> CE
CTX --> ERR
LINT --> CB
LINT --> MB
LINT --> ERR
MB --> CB
CM --> CB
CM --> CE
CM --> UUID
MAUD --> CE
MORG --> UUID
MAREC --> MAUD
MAREC --> CTX
CE -.used by.-> AZP
CB --> MIG
PS --> PM
PS --> PRAW
PS --> CTX
PS --> ERR
PRAW --> PM
PRAW --> ERR
PRAW --> AZPDP
PREPO --> PS
PUOW --> PS
PUOW --> PM
PM --> MB
PM --> CTX
PROUT --> SB
SD --> PSEC
SSH --> CFG
SR --> MB
SSC --> MIG
SD --> MIG
SSH --> MIG
SB --> MIG
SB --> SDEP
SSH --> SDEP
SSC --> SDEP
SD --> SDEP
CREG --> CM
CREG --> CA
CREG --> CS
CREG --> CE
CREG --> ERR
CREG --> CTX
CREG --> CPURGE
CREG --> MIG
CREG --> SB
CREG --> SD
CREG --> SDEP
CREG --> SSC
CPURGE --> PM
CPURGE --> PS
CPURGE --> CFG
CA --> CS
CA --> CTX
ANB --> TR
ANB --> CTX
ANK --> ANB
ANK --> CM
ANK --> CS
ANO --> ANB
ANO --> CM
ANO --> UUID
ANC --> ANB
ANW --> ANK
ANW --> ANO
ANW --> ANC
ANW --> AZRD
ANW --> CREG
AZP --> CE
AZR --> AZP
AZS --> AZR
AZS --> MORG
AZS --> PS
AZRD --> CM
AZRD --> CS
AZPDP --> AZRD
AZPDP --> AZS
AZPDP --> AZP
AZPDP --> AZC
AZPDP --> PS
AZG --> AZPDP
AZG --> AZS
AZA --> AZRD
AZA --> AZP
AZA --> AZR
AZA --> AZC
AZA --> CM
AZA --> CS
AZA --> CA
AZA --> MORG
AZA --> UUID
TM --> TR
TM --> CTX
TM --> ERR
TJ --> CTX
TR --> CFG
Leaves (no intra-package imports): _uuid, config, errors, authn,
authz, authz.cache, control.base, control.enums, control.session,
integrations, migrations, persistence, persistence.secrets,
persistence.strategies, persistence.strategies.deprovision, tenancy.
No import cycles exist in the static graph.
5.3 Security-programme module inventory (jdlib.security.*)¶
Added by the security-hardening programme (phases 1–10; see §15.4 for the phase
documents). "Public names" counts __all__ entries; "Depends on" lists
module-level jdlib.* imports only. Third-party module-level imports across the
whole namespace are limited to pydantic (the configuration models),
sqlalchemy (audit/export.py, compliance/session.py), pyjwt (parts of
authn/) and httpx (the two transports.py modules, behind the http
extra); no web framework and no OpenTelemetry SDK is imported anywhere.
| Module | Purpose | Public names | Depends on |
|---|---|---|---|
jdlib.security |
curated core surface | 38 re-exports | security.{config,context,errors,interfaces,responses} |
jdlib.security.config |
SecurityConfig + sections, validate_security_config, assert_strong_signing_key |
13 | security.errors |
jdlib.security.context |
SecurityContext, RequestIds, AuthMethod, TokenMetadata, contextvar binding |
8 | context, errors |
jdlib.security.errors |
SecurityCode/SecurityDescriptor, describe_error, www_authenticate |
8 | errors |
jdlib.security.identity |
service-identity formalisation (IdentityKind, ServiceIdentity) |
5 | context, errors |
jdlib.security.interfaces |
GatewayAdapter, TokenProvider, TokenValidator, RateLimiter, … |
8 | security.context, tenancy.resolution |
jdlib.security.redaction |
REDACTED, SECRET_PATTERNS, SENSITIVE_KEYS, redact_text, is_sensitive_key |
5 | stdlib only |
jdlib.security.responses |
ErrorResponse, error_response, MAX_MESSAGE_LENGTH |
3 | security.{context,errors,redaction} |
jdlib.security.telemetry |
structured logs, bounded metrics, safe_emit (phase 7) |
8 | security.{context,redaction} |
jdlib.security.tracing |
W3C trace context and security spans (phase 7) | 12 | security.{context,redaction} |
jdlib.security.audit |
audit namespace surface | 26 re-exports | security.audit.{access,adapters,emitters,events,export} |
jdlib.security.audit.events |
SecurityEventType, SecurityAuditEvent, audit_metadata |
5 | _uuid, security.{context,redaction} |
jdlib.security.audit.emitters |
SecurityEventSink, TransactionAuditSink, authorization_audit_observer |
6 | audit.events, security.authz.decision |
jdlib.security.audit.access |
AuditAccess, AuditCapability |
2 | errors |
jdlib.security.audit.export |
AuditQuery, AuditPage, read_events, export_events, renderers |
10 | models.audit, audit.{access,events} |
jdlib.security.audit.adapters |
AuditControlPlaneAudit, AuditPrivilegeAudit, AuditRawSqlAuditor |
5 | context, persistence.rawsql, audit.{emitters,events} |
jdlib.security.authn |
authentication namespace surface | 16 re-exports | security.authn.{algorithms,jwt,provider,zitadel} |
jdlib.security.authn.algorithms |
ASYMMETRIC_ALGORITHMS, assert_safe_algorithms |
2 | security.errors |
jdlib.security.authn.jwt |
JwtTokenValidator, JwtClaimMapping, SigningKeySource |
3 | authn.oidc, config, context, errors, security.* |
jdlib.security.authn.provider |
ClientCredentialsTokenProvider, ClientCredentialsConfig, ClientAuthMethod |
6 | context, errors, security.* |
jdlib.security.authn.zitadel |
ZitadelConfig, build_zitadel_token_validator/provider, zitadel_provider_config |
5 | authn.oidc, security.* |
jdlib.security.authn.transports |
HTTP transports for authentication (the http extra) |
3 | security.authn.provider |
jdlib.security.authz |
authorization namespace surface | 8 re-exports | security.authz.{cerbos,decision,interfaces,pep} |
jdlib.security.authz.decision |
AuthorizationDecision, AuthorizationQuery, Effect |
3 | stdlib only |
jdlib.security.authz.interfaces |
PolicyDecisionPoint port |
1 | security.authz.decision |
jdlib.security.authz.cerbos |
CerbosPDP, CerbosConfig, CerbosTransport |
3 | context, security.authz.decision, security.errors |
jdlib.security.authz.pep |
AuthorizationPEP |
2 | errors, security.{authz.decision,authz.interfaces,context} |
jdlib.security.authz.transports |
HTTP transport for Cerbos (the http extra) |
2 | httpx only |
jdlib.security.gateway |
gateway namespace surface | 5 re-exports | security.gateway.{adapters,config} |
jdlib.security.gateway.adapters |
NoGatewayAdapter, KongGatewayAdapter, TykGatewayAdapter |
3 | context, security.{gateway.config,interfaces}, tenancy.resolution |
jdlib.security.gateway.config |
GatewayConfig, GatewayMode |
3 | security.errors |
jdlib.security.compliance |
compliance namespace surface (§8.22) | 22 re-exports | security.compliance.{controls,evidence,posture} |
jdlib.security.compliance.controls |
CONTROL_REGISTRY and the control model |
10 | stdlib only |
jdlib.security.compliance.evidence |
SecurityEvidence and the checkout-side collectors |
8 | _uuid, context, security.audit, security.config |
jdlib.security.compliance.posture |
outcomes, findings, report, rules, evaluate_posture |
7 | context, security.compliance.{controls,evidence}, security.config |
jdlib.security.compliance.session |
collect_isolation_evidence, collect_schema_revision_evidence |
2 | context, models.base, persistence.strategies.rls, security.compliance.evidence |
The graph is acyclic and layered: redaction, compliance.controls and
authz.decision are the leaves. compliance.session is the only module in
the security tree that imports the persistence layer (§8.22). Inside the
pre-hardening tree only two modules import the security namespace:
models/audit_recorder.py (the security-audit envelope and the ambient security
context) and integrations/cli.py (the security sub-app, §8.20).
6. Public API Architecture¶
6.1 The 13 top-level exports¶
src/jdlib/__init__.py:11-25 (authoritative; asserted by
tests/unit/test_public_api.py:9-25):
__all__ = [
"AccessControl", "ContextFactory", "JdlibError", "TenancyConfig",
"TenantContext", "TenantRegistry", "TenantRepository", "UnitOfWork",
"authorize", "current_principal", "current_tenant", "errors", "requires",
]
TenancyConfig¶
- Import path:
from jdlib import TenancyConfig(jdlib.config.TenancyConfig) - Purpose: pydantic-settings model holding every deployment-level setting.
- Signature:
TenancyConfig()— constructed from keyword arguments and/orJDLIB_*environment variables (env_prefix="JDLIB_",env_nested_delimiter="__",src/jdlib/config.py:70-78). - Fields:
control_dsn: SecretStr | None = None,context: ContextConfig(required),resolvers: ResolverConfig = ResolverConfig(),oidc: OidcConfig | None = None,api_keys: ApiKeyConfig = ApiKeyConfig(),rls: RlsConfig = RlsConfig(). - Returns: a validated instance.
- Exceptions:
pydantic.ValidationError— e.g. missingJDLIB_CONTEXT__SIGNING_KEY(src/jdlib/config.py:19is required), orpath_prefixnot starting with/(src/jdlib/config.py:38-43). - Side effects: none; no I/O, no engine creation.
- Secrets:
control_dsnandcontext.signing_keyareSecretStr; they are excluded fromrepr(tests/unit/test_config.py:48-51). - Concurrency: stateless and immutable-by-convention; the library has no
settings singleton or cached
get_settings()— every caller constructs it.
config = TenancyConfig() # env-driven
config = TenancyConfig(context={"signing_key": "…"}) # code-driven
dsn = config.control_dsn.get_secret_value() # unwrap explicitly
TenantContext¶
- Import path:
from jdlib import TenantContext(jdlib.context.TenantContext) - Purpose: immutable, request-scoped identity + tenant + authority object.
- Signature: frozen, slotted dataclass (
src/jdlib/context.py:63-72):
@dataclass(frozen=True, slots=True)
class TenantContext:
tenant_id: uuid.UUID
tenant_slug: str
strategy: PlacementStrategy
principal: Principal
request_id: str
correlation_id: str
trace_id: str | None = None
privilege: PrivilegeContext | None = None
- Notes: it deliberately does not carry tenant status or
target_handle; servability and placement resolution are separate lookups.correlation_idis always populated becauseContextFactory._buildfalls back torequest_id(src/jdlib/context.py:212-221). - Exceptions: none on construction.
- Concurrency: immutable; safe to share across tasks.
current_tenant / current_principal¶
- Import paths:
from jdlib import current_tenant, current_principal - Signatures:
def current_tenant() -> TenantContextanddef current_principal() -> Principal(src/jdlib/context.py:113-121). - Behavior: read the module
ContextVar_CTX(src/jdlib/context.py:99-101).current_principal()delegates tocurrent_tenant().principal. - Exceptions:
MissingTenantContext("no tenant context is active")when unset — fail closed. - Concurrency:
contextvarsare task-local and copy-on-task-creation, so a context installed in one request/task is not visible to a sibling task. - Example:
ContextFactory¶
- Import path:
from jdlib import ContextFactory - Purpose: the single authority that turns a principal + tenant reference
into a
TenantContextafter fail-closed checks. - Constructor (
src/jdlib/context.py:174-194):
ContextFactory(
*,
config: ContextConfig,
principals: PrincipalDirectory,
tenants: TenantDirectory,
servability: ServabilityChecker,
entitlements: EntitlementChecker,
operators: OperatorAuthorizer,
audit: PrivilegeAudit,
clock: Clock | None = None,
)
- Methods:
for_principal,for_operator,for_system,for_test— see §8.1. - Side effects: every privileged path writes an audit record before
returning;
for_system/for_testalso mint a randomrequest_id. - Exceptions:
UnknownPrincipal,TenantNotFound,TenantAccessDenied,TenantSuspended,AuthorizationError. - Consumer wiring:
README.md:176-194shows the production wiring usingPrincipalDirectoryAdapter,TenantDirectoryAdapter,EntitlementAdapter,RegistryWriteFence-freeregistryas theServabilityChecker, andPrivilegeAuditAdapter.
UnitOfWork¶
- Import path:
from jdlib import UnitOfWork - Purpose: the write-admission gate and transaction lifecycle for tenant data access.
- Signature (
src/jdlib/persistence/uow.py:11-24):
UnitOfWork(
session_factory: Callable[[], Any],
context: TenantContext,
*,
fence: WriteFence,
auditor: RawSqlAuditor | None = None,
)
- Returns: an async context manager whose
__aenter__yields aTenantSession. - Exceptions: propagates
TenantNotWritable/TenantNotFoundfrom the fence before any session is opened; rolls back on body exceptions. - Side effects:
fence.assert_writable()→TenantSession(session, context, auditor=…)→commit()on clean exit,rollback()on exception,close()always (src/jdlib/persistence/uow.py:26-41). - Concurrency: the fence is an admission-time check, not a lock held for
the transaction's duration. There is no optimistic version column, no
SELECT … FOR UPDATE, and no re-check at commit. See §16.3 and §23.
async with UnitOfWork(tenant_sessions, context, fence=write_fence) as session:
invoices = await TenantRepository(session, Invoice).list()
TenantRepository¶
- Import path:
from jdlib import TenantRepository - Purpose: scoped CRUD helper for
TenantRoutedmodels. - Signature (
src/jdlib/persistence/repository.py:13-16):
class TenantRepository[ModelT]:
def __init__(self, session: TenantSession, model: type[ModelT]) -> None
- Methods (
src/jdlib/persistence/repository.py:24-77):get(entity_id),list(*criteria, order_by=None, limit=None, offset=None),add(obj),update(obj, **changes),soft_delete(obj),count(*criteria). - Exceptions:
CrossTenantReferenceErrorwhen flushing a row owned by another tenant (raised byTenantSession.before_flush);TypeErrorfromsoft_deletewhen the model has nodeleted_at. - Side effects:
add/update/soft_deleteflush(); they never commit — theUnitOfWorkowns the transaction. - Details: soft-delete support is duck-typed by the presence of
deleted_at; for soft-deletable models,get()issues a live-rowSELECTrather than using the identity map; a bareTableis never accepted becauseTenantSessionrejects untrusted table-level queries.
TenantRegistry¶
- Import path:
from jdlib import TenantRegistry - Purpose: the control-plane lifecycle authority (create/provision/ migrate/suspend/resume/archive/deprovision + servability).
- Constructor (
src/jdlib/control/registry.py:143-165):
TenantRegistry(
*,
session_factory: Callable[[], AsyncSession],
strategies: Mapping[PlacementStrategy, IsolationStrategy],
runner: MigrationRunner,
audit: PlatformAudit,
seeder: SeedHook | None = None,
desired_version: str = "0001_tenant_baseline",
purger: TenantPlanePurger | None = None,
)
- Methods:
get,create,provision,migrate,suspend,resume,archive,deprovision,assert_servable,list_for_user,find. There is no publicpurge,relocate, or generictransitionmethod (src/jdlib/control/registry.py:194-623). - Exceptions:
ProvisioningError,TenantOperationInProgress,TenantNotFound,TenantSuspended,StrategyCapabilityError. - Side effects: control-plane writes, advisory locks, strategy DDL, migrations, seeding, platform audit.
- Concurrency: serialization is via PostgreSQL transaction advisory locks
jdlib:tenant:{id},jdlib:tenant-slug:{slug},jdlib:tenant-handle:{handle}(src/jdlib/control/registry.py:285-304). Multi-process safe against the same control database.
AccessControl¶
- Import path:
from jdlib import AccessControl - Purpose: the authorization mutation authority (roles, bindings, grants, invitations, ownership transfer, team movement) with anti-escalation and auditing.
- Constructor (
src/jdlib/authz/access.py:120-140):
AccessControl(
*,
session_factory: Callable[[], AsyncSession],
catalog: PermissionCatalog,
reader: AccessReader,
cache: AuthzCache,
audit: PlatformAudit,
registry: ResourceTypeRegistry | None = None,
scope_lookup: ScopeLookup | None = None,
resource_lookup: ResourceLookup | None = None,
)
- Public methods:
actor_permissions,create_role,update_role,delete_role,bind_role,unbind_role,grant_resource,revoke_grant,transfer_resource,move_team,invite,accept_invitation. - Exceptions:
PermissionDenied,RoleEscalationBlocked,UnknownPermission,UnknownResourceType,InvalidResourcePermission,RoleScopeMismatch,InvalidReference,AuthorizationError. - Side effects: control-plane rows,
AuthzCache.invalidate()before every attempted mutation, advisory locks inunbind_roleandinvite, Argon2 invitation-token hashing, platform audit. - Not in this service: membership management, API-key issuance,
authentication, and decision evaluation (those are
reader/PDP concerns).
requires and authorize¶
- Import paths:
from jdlib import requires, authorize(jdlib.authz.guards) - Signatures (
src/jdlib/authz/guards.py:34-58):
def requires(
permission: str,
*,
enforcer: Enforcer,
target_factory: Callable[..., Target] | None = None,
) -> Callable[[F], F]: ...
async def authorize(
permission: str, target: Target, *, enforcer: Enforcer, session: object
) -> Decision: ...
- Behavior: both ultimately call
Enforcer.require, which evaluates the PDP with the ambient context and raisesPermissionDenied(f"{permission}: { decision.reason}")on denial (src/jdlib/authz/guards.py:20-31). requiresspecifics: the wrapped function runs only after authorization succeeds; the session is taken fromkwargs.get("session")only — a positional or differently named session argument yieldssession=Noneand aMissingTenantContextfrom the PDP. The default target istenant_target().- Exceptions:
PermissionDenied, plus any PDP exception (e.g.UnknownPermission).
JdlibError and errors¶
- Import paths:
from jdlib import JdlibError, errors - Purpose: the single exception family that crosses module boundaries
(
README.md:389-390);errorsexposes all 27 subclasses via__all__(src/jdlib/errors.py:119-147). - Shape: every class subclasses
JdlibError(Exception). Only three define custom constructors:TenantNotFound(tenant),CrossTenantReferenceError(reference, expected_tenant, actual_tenant), andSchemaLintError(issues)(which storesself.issues). The rest use the default constructor. - Mapping to HTTP: via
jdlib.tenancy.middleware.http_status_for— see §13.3.
6.2 De facto public submodule entry points¶
| Symbol | Import path | Signature (verbatim) |
|---|---|---|
install |
jdlib.integrations.fastapi |
install(app: FastAPI, *, factory: ContextFactory, chain: ResolverChain, principal_provider: PrincipalProvider, container: JdlibContainer | None = None) -> None |
JdlibContainer |
jdlib.integrations.fastapi |
dataclass: session_factory, uow_factory, enforcer |
get_context |
jdlib.integrations.fastapi |
async def get_context() -> TenantContext |
get_uow |
jdlib.integrations.fastapi |
async def get_uow(request: Request) -> AsyncIterator[UnitOfWork] |
require (FastAPI) |
jdlib.integrations.fastapi |
require(permission: str, target_factory: Callable[..., Target] | None = None) -> Callable[[Request], Awaitable[None]] |
app (Typer) |
jdlib.integrations.cli |
app = typer.Typer(no_args_is_help=True), registering the tenant, db, rls, schema and security sub-apps |
TenantSession |
jdlib.persistence.session |
TenantSession(session: AsyncSession, context: TenantContext | None, *, auditor: RawSqlAuditor | None = None, validator: RawSqlValidator | None = None) |
SessionRouter |
jdlib.persistence.router |
SessionRouter(strategies: Mapping[PlacementStrategy, IsolationStrategy]) |
MigrationRunner |
jdlib.migrations.runner |
MigrationRunner(database_url: str) |
RegistryWriteFence |
jdlib.control.registry |
RegistryWriteFence(*, session_factory: Callable[[], AsyncSession]) |
DatabaseAuditSink |
jdlib.control.audit |
DatabaseAuditSink(session_factory: Callable[[], AsyncSession]) |
ControlPlaneSession |
jdlib.control.session |
ControlPlaneSession(session: AsyncSession) |
TenantPlanePurger |
jdlib.control.purge |
TenantPlanePurger(session_factory, *, factory: ContextFactory, rls: RlsConfig | None = None) |
DefaultPDP |
jdlib.authz.pdp |
DefaultPDP(*, catalog: PermissionCatalog, reader: AccessReader, scopes: ScopeResolver, cache: AuthzCache | None = None) |
Enforcer |
jdlib.authz.guards |
Enforcer(pdp: PolicyDecisionPoint) |
PermissionCatalog |
jdlib.authz.permissions |
PermissionCatalog.with_builtins() -> PermissionCatalog |
ResourceTypeRegistry |
jdlib.authz.resource_types |
ResourceTypeRegistry() + register, get, all, validate_permission |
ScopeResolver |
jdlib.authz.scopes |
ScopeResolver(registry: ResourceTypeRegistry) |
ControlAccessReader |
jdlib.authz.reader |
ControlAccessReader(session_factory: Callable[[], AsyncSession]) |
AuthzCache |
jdlib.authz.cache |
AuthzCache() |
build_authenticator |
jdlib.authn.wiring |
build_authenticator(config: TenancyConfig, session_factory, audit: PlatformAudit) -> CompositeAuthenticator |
EnvelopeCodec |
jdlib.context |
EnvelopeCodec(*, config: ContextConfig, clock: Clock | None = None) |
ContextPropagator |
jdlib.tenancy.jobs |
ContextPropagator(*, codec, principals, tenants, servability, entitlements) |
tenant_job |
jdlib.tenancy.jobs |
tenant_job(propagator: ContextPropagator) -> Callable[[F], F] |
TenantMiddleware |
jdlib.tenancy.middleware |
TenantMiddleware(app: ASGIApp, *, factory, chain, principal_provider) |
build_chain |
jdlib.tenancy.resolution |
build_chain(config: ResolverConfig) -> ResolverChain |
lint_metadata / assert_clean |
jdlib.lint |
lint_metadata(controls: list[MetaData], rls_enabled: bool = False) -> list[LintIssue] / assert_clean(...) -> None |
TenantAuditRecorder |
jdlib.models.audit_recorder |
TenantAuditRecorder(session: object) + record(*, action, target_type=None, target_id=None, metadata=None) -> AuditEvent |
install_rls / verify_rls |
jdlib.persistence.strategies.rls |
install_rls(engine: AsyncEngine, *, metadata: sa.MetaData, app_role: str) -> None / verify_rls(engine: AsyncEngine, app_role: str, *, metadata: sa.MetaData) -> None (built on inspect_rls, §8.12) |
RlsInspection / inspect_rls |
jdlib.persistence.strategies.rls |
inspect_rls(engine: AsyncEngine, *, metadata: sa.MetaData) -> RlsInspection; frozen dataclass with tables_checked, tables_enforced, tables_missing_rls, tables_missing_force, tables_missing_policy, tables_unprovisioned, is_enforced |
CONTROL_REGISTRY and the control model |
jdlib.security.compliance |
CONTROL_REGISTRY: Mapping[str, Control]; Control, ControlMapping, ControlFramework, ControlCategory, ImplementationStatus, VerificationMethod, control_by_id, controls_for_framework, frameworks_in_use |
evaluate_posture / PostureReport |
jdlib.security.compliance |
evaluate_posture(config: SecurityConfig, *, evidence=(), rules=None, clock=None, environment=None) -> PostureReport; PostureOutcome, Severity, PostureFinding, POSTURE_RULES |
SecurityEvidence and collectors |
jdlib.security.compliance / .session |
collect_configuration_evidence, collect_migration_evidence, collect_policy_evidence; the session-backed collect_isolation_evidence / collect_schema_revision_evidence (async, AsyncEngine) |
security_app |
jdlib.integrations.cli |
security_app = typer.Typer(no_args_is_help=True) — the security group: posture, evidence, compliance (§8.20) |
| assertions | jdlib.testing |
assert_scoped_count, assert_tenant_isolated, assert_cross_tenant_write_rejected |
6.3 What happens internally after a consumer call¶
| Consumer call | Internal path |
|---|---|
current_tenant() |
_CTX.get() → raise MissingTenantContext if unset (context.py:113-117) |
UnitOfWork.__aenter__ |
fence check → wrap session → TenantSession.__init__ → session.sync_session.info["jdlib_context"] = context (persistence/session.py:265-272) |
TenantSession.execute(orm_select) |
do_orm_execute → mapper trust analysis → with_loader_criteria(tenant_id) → SQLAlchemy (persistence/session.py:109-211) |
TenantSession.raw_sql(...) |
privilege check → RawSqlValidator.compile → validate_tenant_bound / validate_privileged → audit (privileged) → execute(execution_options={"jdlib_raw_sql": True}) (persistence/session.py:335-363) |
registry.provision(id) |
advisory lock → status guard → strategy.provision → migration state running → strategy.migrate → SeedHook.seed → version equality check → audit → commit (control/registry.py:306-365) |
Enforcer.require(...) |
current_tenant() → PDP.evaluate (catalog validate → principal → tenant → membership → scope chain → bindings → permissions → grants → key-scope intersection) → raise or return Decision (authz/pdp.py:77-150) |
AccessControl.grant_resource(...) |
cache invalidate → resource:share + permission check → resource/namespace validation → INSERT … ON CONFLICT DO NOTHING RETURNING → audit (authz/access.py:540-624) |
7. Internal and Low-Level Architecture¶
7.1 Value objects and protocols¶
Principal (frozen, slots) TenantContext (frozen, slots)
├── kind: PrincipalKind ├── tenant_id: UUID
├── id: UUID ├── tenant_slug: str
├── user_id: UUID | None ├── strategy: PlacementStrategy
├── tenant_id: UUID | None (pin) ├── principal: Principal
├── api_key_id: UUID | None ├── request_id / correlation_id: str
├── scopes: frozenset[str] | None ├── trace_id: str | None
└── operator_level: OperatorLevel | None ├── privilege: PrivilegeContext | None
└── live_privilege(now) -> PrivilegeContext | None
PrivilegeContext (frozen, slots)
├── kind: PrivilegeKind (operator|system|test)
├── capability: str | None
├── justification: str | None
├── actor: Principal
├── issued_at: datetime
├── expires_at: datetime | None
└── is_live(now) -> bool # bounded on both sides — §8.1
Collaborator protocols and their production implementations:
| Protocol | Location | Shipped implementation |
|---|---|---|
Clock |
context.py:90-91 |
SystemClock (context.py:94-96), tests' FrozenClock |
PrincipalDirectory.is_active |
context.py:135-136 |
PrincipalDirectoryAdapter (authn/wiring.py:60-65) |
TenantDirectory.get |
context.py:139-140 |
TenantDirectoryAdapter (authn/wiring.py:68-73) |
ServabilityChecker.assert_servable |
context.py:143-144 |
TenantRegistry (control/registry.py:571-585) |
EntitlementChecker.can_act |
context.py:147-148 |
EntitlementAdapter (authn/wiring.py:76-87) |
OperatorAuthorizer.authorize |
context.py:151-152 |
none shipped (consumer supplies; README example denies all) |
PrivilegeAudit.record_privilege |
context.py:155-164 |
PrivilegeAuditAdapter (control/audit.py:153-172) |
Authenticator.authenticate |
authn/base.py:10-11 |
OidcAuthenticator, ApiKeyAuthenticator, CompositeAuthenticator |
TenantResolver.resolve |
tenancy/resolution.py:24-27 |
5 concrete resolvers |
IsolationStrategy |
strategies/base.py:19-37 |
SharedSchemaStrategy, SchemaPerTenantStrategy, DatabasePerTenantStrategy |
SecretProvider.resolve |
persistence/secrets.py:7-8 |
MemorySecretProvider, EnvSecretProvider |
AccessReader (7 methods) |
authz/reader.py:28-52 |
ControlAccessReader |
PolicyDecisionPoint.evaluate |
authz/pdp.py:30-39 |
DefaultPDP |
WriteFence.assert_writable |
persistence/models.py:32-33 |
RegistryWriteFence, NullWriteFence |
PlatformAudit.record |
control/audit.py:27-37 |
DatabaseAuditSink, CompositeAuditSink, RecordingPlatformAudit; NullPlatformAudit raises |
SeedHook.seed |
control/registry.py:72-78 |
none shipped (_NoopSeedHook default) |
ScopeLookup / ResourceLookup |
authz/access.py:53-82 |
none shipped (fail closed with AuthorizationError when omitted) |
RawSqlAuditor.record_raw_sql |
persistence/session.py:26-29 |
_NoopRawSqlAuditor default; consumers may supply one |
7.2 TenantSession internals¶
TenantSession
├── Responsibility: single fail-closed, tenant-scoped database session surface
├── Constructor: (AsyncSession, TenantContext | None, *, auditor=None, validator=None)
│ ├── context is None → raise MissingTenantContext (session.py:265-267)
│ ├── session.sync_session.info["jdlib_context"] = context (session.py:270-272)
│ └── _register_events(session.sync_session) (session.py:265-272)
├── State: self._session, self.context, self._auditor, self._validator
├── Public methods: execute, get, add, delete, update_where, delete_where,
│ raw_sql, flush, commit, rollback, close, __aenter__/__aexit__
└── Collaborators: TenantContext, RawSqlValidator, RawSqlAuditor
Two SQLAlchemy event hooks are registered per instance
(src/jdlib/persistence/session.py:109-254):
do_orm_execute— read scoping and DML gate:- rejects
sa.TextClause("use raw_sql for text statements"); - rejects
FromStatementORM selects; - rejects non-
*literal SQL columns; - builds the set of trusted
TenantRoutedmappers fromall_mappers, entity tables, explicit FROMs, joins, andbind_mapper; - rejects bare tenant
Tablereferences and raw table aliases ("aliases of tenant tables must be entity-derived; use aliased(Entity) or raw_sql"); - attaches
sa.orm.with_loader_criteria(mapper.class_, lambda cls: cls.tenant_id == tenant_id, include_aliases=True)for every trusted mapper; - rejects unscoped ORM
UPDATE/DELETE/INSERTwithUnscopedBulkOperationunless execution options mark the statement (jdlib_scoped=True) or it came from the raw path (jdlib_raw_sql=True) —src/jdlib/persistence/session.py:212-224. before_flush— write validation:- new
TenantRoutedobject: stampstenant_idwhen unset, raisesCrossTenantReferenceErroron mismatch; - dirty
TenantRoutedobject: anytenant_idhistory change →CrossTenantReferenceError; - deleted
SoftDeleteobject: requires a non-Noneprivilege.
Scoped bulk helpers are the only sanctioned bulk path:
async def update_where(self, model, values: dict[str, Any], *criteria) -> int
async def delete_where(self, model, *criteria) -> int
Both append model.tenant_id == self.context.tenant_id and mark the statement
jdlib_scoped=True (src/jdlib/persistence/session.py:308-333).
get() loads by composite primary key: session.get(model, (tenant_id,
entity_id)) — a row owned by another tenant returns None rather than
erroring (src/jdlib/persistence/session.py:297-300).
Trust-boundary caveat (verified): the DML gate trusts execution-option
markers; a consumer who constructs sa.update(…).execution_options(
jdlib_scoped=True) directly can bypass the predicate the marker implies. The
markers are a convention-enforced internal capability, not a
cryptographically unforgeable one.
7.3 DefaultPDP internals¶
DefaultPDP
├── Responsibility: additive, allow-only authorization decisions
├── Constructor: (*, catalog, reader, scopes, cache=None)
├── State: references only (no mutable state of its own beyond cache use)
├── evaluate(principal, permission, target, context, *, session) -> Decision
│ 1 catalog.validate(permission) → UnknownPermission
│ 2 reader.principal_active(principal) → "principal_inactive"
│ 3 reader.tenant_status_of(tenant_id) → "tenant_suspended"
│ 4 reader.membership_active(...) → "membership_inactive"
│ 5 session is None → MissingTenantContext
│ 6 scopes.chain(session, target) → ScopeRef tuple
│ 7 reader.bindings_for(principal, tenant, chain) → bindings
│ 8 reader.permissions_by_role(tenant, role_ids) → frozenset
│ 9 reader.grants_for(...) for ResourceRef → frozenset
│ 10 principal.scopes is not None → "outside_key_scopes"
│ 11 permission in authority → "allowed" / else "no_allow"
└── Collaborators: PermissionCatalog, AccessReader, ScopeResolver, AuthzCache
Decision reasons are exactly: allowed, principal_inactive,
tenant_suspended, membership_inactive, outside_key_scopes, no_allow
(src/jdlib/authz/pdp.py:77-150). Provenance is carried in
matched: tuple[MatchedRule, ...] with kinds binding and grant.
Cache keys (src/jdlib/authz/pdp.py:88-138): ("principal_active",
principal), ("tenant_status", tenant_id), ("membership_active", principal,
tenant_id), ("scope_chain", target), ("bindings", tenant_id, principal,
scope_chain), ("role_permissions", tenant_id, role_ids), ("grants",
tenant_id, principal, resource_type, resource_id). Note the scope-chain key
omits the tenant id, which is why the PDP/cache must be request-scoped.
7.4 AuthzCache semantics¶
src/jdlib/authz/cache.py:6-19: a dict memo with get, put, and
invalidate(). invalidate() sets a permanent dirty flag, clears the
memo, and makes every later get() return None — caching is never re-enabled
for the instance's lifetime. AccessControl invalidates before every attempted
mutation, so any authorization-changing call makes the rest of the request
uncached. This is deliberate (no cross-request staleness) but means a
single mutation permanently disables caching for that request.
7.5 TenantRegistry internals¶
TenantRegistry
├── Responsibility: lifecycle state machine + placement + migrations + servability
├── Constructor: (*, session_factory, strategies, runner, audit, seeder=None,
│ desired_version="0001_tenant_baseline", purger=None)
├── Allowed transitions (registry.py:51-58):
│ provisioning → {active, deleted}
│ active → {suspended, archived}
│ suspended → {active, archived}
│ archived → {active, deprovisioning}
│ deprovisioning → {deleted, archived}
│ deleted → {}
├── Internal helpers:
│ _advisory_lock(key) → SELECT pg_advisory_xact_lock(hashtextextended(key,0))
│ _transition(...) → guarded status change + audit + commit
│ _servability_error(tenant, state, relocating) → str | None
└── Collaborators: IsolationStrategy map, MigrationRunner, PlatformAudit,
SeedHook, TenantPlanePurger, ControlPlaneSession
Servability predicate (single source of truth,
src/jdlib/control/registry.py:124-140, 571-585):
tenants.status == 'active'
AND tenant_migration_states.status == 'success'
AND current_version == desired_version
AND no tenant_relocations row in {copying, verifying, flipping, post_flip_verification}
AND a placement row exists
Failure mapping: unknown tenant → TenantNotFound; any failed predicate →
TenantSuspended; missing placement → ProvisioningError.
RegistryWriteFence.assert_writable uses the same predicate but raises
TenantNotWritable (src/jdlib/control/registry.py:636-652).
Handle validation at create time
(src/jdlib/control/registry.py:194-283): schema handles must match
^t_[a-z0-9][a-z0-9_]{0,50}$; database handles must match
[a-z0-9][a-z0-9-]{0,50}; shared handles need only be non-empty. Invalid
values raise ProvisioningError; an unknown/unhashable strategy value also
raises ProvisioningError (normalization at
src/jdlib/control/registry.py:194-200).
7.6 MigrationRunner internals¶
MigrationRunner(database_url)
├── _sync_url(url): postgresql|postgresql+asyncpg|postgresql+psycopg2
│ → postgresql+psycopg (runner.py:17-29)
├── _config(plane, *, tenant_schema=None, database_url=None) -> alembic.Config
│ ├── script_location = Path(__file__).parent / plane
│ ├── sqlalchemy.url = sync URL with '%' doubled (ConfigParser safety)
│ └── config.attributes["tenant_schema"] = tenant_schema
├── upgrade_control(revision="head") → creates schema, then command.upgrade
├── upgrade_tenant(schema, revision) → no schema creation
├── upgrade_tenant_url(dsn, schema, revision)
├── provision_tenant_schema(schema, revision) → CREATE SCHEMA IF NOT EXISTS then upgrade
├── tenant_version(schema) / tenant_version_url(dsn, schema)
└── Validation: _TENANT_SCHEMA_RE = t_[a-z0-9][a-z0-9_]{0,50}
DEPLOYMENT_SCHEMA_RE = [a-z_][a-z0-9_]{0,62}
(fullmatch; failures → ProvisioningError before any engine)
All migration methods are synchronous; they are called from the async registry (blocking by design — see §23).
7.7 Internal helper functions worth knowing¶
| Helper | Location | Purpose |
|---|---|---|
tenant_table_map() |
persistence/models.py:11-29 |
discovers all loaded TenantRouted subclasses by table name (dynamic import of framework model modules) |
privileged_bypass(context) |
persistence/models.py:41-47 |
True only for PrivilegeKind.SYSTEM and capability exactly relocation.manage |
scoped_key(namespace, *parts) |
context.py:128-132 |
tenant-prefixed, percent-encoded external cache key jdlib:v1:{tenant_id}:{ns}{parts} |
sql_in(column, values) |
control/enums.py:90-92 |
renders col IN ('a','b') for CHECK constraints |
ownership_constraints() |
models/base.py:56-74 |
check + two composite FKs with ondelete="RESTRICT" |
http_status_for(exc) |
tenancy/middleware.py:38-42 |
ordered isinstance match, default 500 |
_b64/_unb64 |
context.py:441-447 |
unpadded URL-safe base64 for envelopes |
parse_api_key, hash_api_key_secret, verify_api_key_secret |
authn/apikey.py:29-44 |
jd.<prefix>.<secret> parsing; Argon2 hashing |
build_invitation_token, hash_invitation_secret, verify_invitation |
authz/access.py:102-117 |
inv_<32 hex>_<64 hex>; Argon2 hash of the secret half |
_resolve_actor_type(actor_id, actor_type) |
control/audit.py |
actor_type or ("system" if actor_id is None else "user") |
_request_ids(headers) |
tenancy/middleware.py:68-77 |
generates request_id, reads x-correlation-id, extracts 32-hex trace id from traceparent |
8. Feature-by-Feature Architecture¶
Each feature below states: overview, public API, internal architecture, execution flow, error flow, and (for the major ones) a sequence diagram.
8.1 Tenant context and ContextFactory¶
Overview. ContextFactory is the only sanctioned way to create a
TenantContext. It enforces five fail-closed checks in a fixed order and can
mint three privilege flavours (operator, system, test), each audited before
return.
Public API. ContextFactory (top-level), TenantContext, Principal,
PrivilegeContext, current_tenant, current_principal,
current_privilege, context_scope, PRIVILEGE_ISSUER, SystemClock.
Internal architecture. context.py:174-372. Collaborators are the six
protocols in §7.1. for_system and for_test require the module-private
_PrivilegeIssuer instance and check identity (issuer is PRIVILEGE_ISSUER),
so external callers cannot construct system privileges by passing a look-alike.
Execution flow — for_principal (context.py:223-252):
principals.is_active(principal)→ false ⇒UnknownPrincipal(str(id)).tenants.get(ref)→None⇒TenantNotFound(slug or id).SERVICE_ACCOUNTonly:principal.tenant_id == record.idelseTenantAccessDenied.entitlements.can_act(principal, record.id)→ false ⇒TenantAccessDenied.servability.assert_servable(record.id).- Build the context with
privilege=None,correlation_id or request_id.
Execution flow — for_operator (context.py:254-302): requires
PrincipalKind.PLATFORM_OPERATOR and a non-blank justification
(otherwise AuthorizationError); TTL defaults to and is capped by
config.operator_ttl; operators.authorize(principal, capability) must return
True; tenant resolution and servability follow; a PrivilegeContext is
created, audited, and only then returned.
Execution flow — for_system / for_test (context.py:304-372): sentinel
check; for_test additionally requires config.allow_test_contexts; a random
PLATFORM_OPERATOR actor and non-expiring SYSTEM/TEST privilege are built
and audited. Neither path consults the tenant directory, entitlement checker,
or servability checker.
Privilege liveness (phase 8). A privilege is bounded on both sides.
PrivilegeContext.is_live(now) (context.py:63-72) refuses a context used
before issued_at as well as one used at or after expires_at (None means the
kind carries no expiry), and every enforcement point reads the privilege through
TenantContext.live_privilege(now) (context.py:86-95) rather than the field,
so "the context carries a privilege" can never be mistaken for "the privilege is
still good". TenantSession decides through one _privilege_refusal(...)
helper (persistence/session.py:258-276) that names why a missing, not-yet-valid
or expired privilege cannot authorize the action — "you never had it" and "it
lapsed" are different findings for an investigator. Every privileged gate uses
it: hard delete in before_flush, update_where of tenant_id, delete_where
on a SoftDelete model, and privileged raw SQL, with the refusal recorded
through the auditor before the raise where the path audits
(persistence/session.py:253, 338, 356, 378-395).
Error flow. All raises are JdlibError subclasses, so the ASGI middleware
maps them to 401/404/423 by the table in §13.3. The ordering of entitlement
(4) before servability (5) means an unentitled caller for a suspended tenant
receives 404 TenantAccessDenied, not 423 — a deliberate non-disclosure
property.
sequenceDiagram
autonumber
participant MW as TenantMiddleware
participant PP as PrincipalProvider
participant CF as ContextFactory
participant PD as PrincipalDirectory
participant TD as TenantDirectory
participant EC as EntitlementChecker
participant SC as ServabilityChecker
participant PA as PrivilegeAudit
MW->>PP: authenticate(RequestInfo)
PP-->>MW: Principal
MW->>CF: for_principal(principal, ref, request_id=…, correlation_id=…, trace_id=…)
CF->>PD: is_active(principal)
PD-->>CF: bool
CF->>TD: get(ref)
TD-->>CF: TenantRecord | None
CF->>EC: can_act(principal, tenant_id)
EC-->>CF: bool
CF->>SC: assert_servable(tenant_id)
SC-->>CF: TenantRecord
CF-->>MW: TenantContext
MW->>MW: context_scope(context) → call app
MW->>MW: reset context
8.2 Tenant resolution and the ASGI middleware¶
Overview. Resolvers answer "which tenant is this request for?" from five possible signals; the middleware turns that into an installed context and maps library errors to HTTP responses.
Public API. RequestInfo, TenantResolver, JwtClaimResolver,
SubdomainResolver, PathResolver, HeaderResolver, ApiKeyResolver,
ResolverChain, build_chain, TenantMiddleware, PrincipalProvider,
http_status_for.
Internal architecture. tenancy/resolution.py:14-127,
tenancy/middleware.py:24-134. RequestInfo is a NamedTuple with
headers, query, path, host, claims (default {}). _slug_ref
normalizes a value by stripping, lowercasing, trying UUID first, then a slug
regex ^[a-z0-9][a-z0-9-]{1,62}$ (a one-character slug is rejected).
ResolverChain.resolve returns the first non-None result and never
compares conflicting resolvers.
Middleware flow (middleware.py:110-134):
- Non-
httpscope → pass through untouched (no auth, no context). - Build
RequestInfofrom the ASGI scope: headers decoded latin-1 and lowercased, query parsed with last-value-wins,pathfrom scope,hostfrom thehostheader, andclaims={}. principal = await principal_provider(request);None⇒AuthenticationError("no credentials presented").tenant_ref = await chain.resolve(request, principal);None⇒TenantNotFound("tenant could not be resolved").- Generate
request_id(uuid4 hex), readcorrelation_id(x-correlation-id, defaulting torequest_id), and extract a 32-hex trace id fromtraceparentwhen well-formed. context = await factory.for_principal(...).with context_scope(context): await self.app(...).- Any
JdlibErrorraised in steps 3–6 is converted to a JSON response{"error": "<ClassName>", "detail": "<message>"}withcontent-lengthandcontent-type: application/json; non-JdlibErrorexceptions propagate. - The context is always reset, including when the inner app raises.
Error flow. http_status_for is the single mapping function, used both by
the middleware and by the FastAPI exception handler.
Accuracy note (verified): the shipped middleware always passes
claims={} into RequestInfo, so the default JwtClaimResolver receives no
claims through this path. Supplying OIDC-claim-based tenant selection requires
a custom principal provider/adapter that populates RequestInfo.claims or a
custom resolver chain.
sequenceDiagram
autonumber
participant C as Client
participant MW as TenantMiddleware
participant AU as Authenticator
participant DB as jd_control DB
participant H as Downstream app
C->>MW: HTTP request (X-Tenant-Slug: acme, Authorization: Bearer …)
MW->>AU: authenticate(RequestInfo)
AU->>DB: api_keys / users / identity_links lookup
DB-->>AU: rows
AU-->>MW: Principal
MW->>MW: ResolverChain → HeaderResolver → TenantRef(slug="acme")
MW->>DB: tenants.get + servability
DB-->>MW: TenantRecord
MW->>MW: context_scope(TenantContext)
MW->>H: call app
H-->>C: 200
MW->>MW: context reset
8.3 Signed envelopes and background jobs¶
Overview. A request context cannot be shared across process/queue
boundaries as an object, so EnvelopeCodec serializes a minimal identity
payload with an HMAC, and ContextPropagator rebuilds a fresh context on the
far side — re-validating principal, tenant, entitlement, and servability.
Public API. EnvelopeCodec(*, config: ContextConfig, clock=None) with
encode(context) -> str and decode(token) -> TenantContextEnvelope;
TenantContextEnvelope; ContextPropagator(*, codec, principals, tenants,
servability, entitlements) with reconstruct(token) -> TenantContext;
tenant_job(propagator) decorator.
Envelope format. <unpadded-b64url(payload)>.<unpadded-b64url(signature)>,
where the payload is compact sorted JSON containing exactly tenant_id,
principal_type, principal_id, request_id, correlation_id, trace_id,
issued_at, and the signature is HMAC-SHA256 over those bytes using
context.signing_key (src/jdlib/context.py:387-438). It intentionally carries
no scopes, permissions, roles, or privilege.
Decode validation order (context.py:409-438): split (InvalidToken
"malformed envelope") → base64 decode → hmac.compare_digest signature check
(InvalidToken "envelope signature mismatch") → JSON parse and dataclass
construction → reject issued_at more than 60 s in the future (InvalidToken
"envelope issued in the future") → reject age greater than envelope_ttl
(ExpiredToken "envelope expired") → other parse/type/attribute failures
become InvalidToken "invalid envelope payload".
Reconstruction flow (tenancy/jobs.py:40-68): decode → rebuild a
Principal (service accounts get the envelope tenant as their pin; all other
kinds get tenant_id=None) → principals.is_active → tenants.get(TenantRef(
id=…)) → entitlements.can_act → servability.assert_servable → build a new
TenantContext(privilege=None) carrying the envelope's request/correlation/trace
ids.
tenant_job behavior (tenancy/jobs.py:71-81): reconstruct before
entering context_scope; on failure the context is never installed; the
envelope token is consumed and not forwarded to the wrapped function; the
decorator does not call the write fence, so write-performing jobs must use a
UnitOfWork.
sequenceDiagram
autonumber
participant W as Web request
participant EC as EnvelopeCodec
participant Q as Queue
participant CP as ContextPropagator
participant PD as PrincipalDirectory
participant TD as TenantDirectory
participant J as Job handler
W->>EC: encode(context)
EC-->>Q: signed token
Q->>CP: tenant_job wrapper(token)
CP->>EC: decode(token) → TenantContextEnvelope
EC-->>CP: envelope
CP->>PD: is_active(principal)
CP->>TD: get(TenantRef(id=envelope.tenant_id))
TD-->>CP: TenantRecord
CP->>CP: entitlement + servability re-checks
CP->>J: context_scope(new context) → fn(*args, **kwargs)
J-->>Q: result
CP->>CP: context reset
8.4 Control plane, TenantRegistry, and the lifecycle state machine¶
Overview. The control plane stores all cross-tenant metadata in the
jd_control schema and is the authority for tenant existence, placement,
migration state, relocations, identities, access-control rows, and platform
audit.
Public API. TenantRegistry (all 11 methods), RegistryWriteFence,
SeedHook, ControlPlaneSession, TenantPlanePurger, DatabaseAuditSink,
CompositeAuditSink, RecordingPlatformAudit, PrivilegeAuditAdapter,
TenantStatus, PlacementStrategy, MigrationStatus, RelocationPhase.
Internal architecture. control/registry.py:143-623; tables in
control/models.py:29-487; schema constant in control/base.py:6-21.
Method contracts:
| Method | Guards | Side effects |
|---|---|---|
create |
strategy normalization (ProvisioningError on bad value), handle regex, slug advisory lock, existing-slug idempotency (identical strategy/handle/region → return existing), handle advisory lock for schema/database |
inserts Tenant + TenantPlacement + pending TenantMigrationState; audits tenant.created; one commit |
provision |
tenant advisory lock; already-active ⇒ no-op; status must be provisioning |
strategy.provision → migration state running → strategy.migrate → seeder.seed → version equality check → audit → activate; on failure commits failed + last_error, audits tenant.migration.failed, re-raises (tenant stays provisioning) |
migrate |
tenant advisory lock; no status eligibility check | strategy.migrate → set current/success + audit tenant.migration.succeeded; on failure mark failed and, if the tenant was active, suspend it with suspended_reason="migration_failed" |
suspend / resume / archive |
allowed-transition map; already-in-target-state returns the tenant unchanged | sets/clears suspended_at, suspended_reason, archived_at; audits; commit |
deprovision |
ARCHIVE requires active/suspended → archived (non-destructive, optional detach_children); PURGE/DESTROY require archived → deprovisioning (or already deprovisioning), then release/reacquire the advisory lock and populate_existing=True re-read |
deletes control access rows in _ACCESS_ROW_DELETION_ORDER (api_keys → service_accounts → invitations → role_bindings → resource_grants → role_permissions → roles → memberships), re-checks children, calls strategy.deprovision, requires a TenantPlanePurger for shared placement, marks deleted, audits counts, commits |
assert_servable |
the single servability predicate | none |
find |
ID first, then slug; no servability check | none |
list_for_user |
active memberships only | none |
Error flow. TenantOperationInProgress for illegal transitions,
TenantNotFound for unknown ids, TenantSuspended for unservable tenants,
ProvisioningError for invalid handles/strategy/missing placement,
StrategyCapabilityError when a strategy lacks a handle/engine/secret.
sequenceDiagram
autonumber
participant Op as Operator/CLI
participant R as TenantRegistry
participant PG as jd_control
participant St as IsolationStrategy
participant MR as MigrationRunner
participant A as PlatformAudit
Op->>R: create(slug, name, strategy, target_handle)
R->>PG: pg_advisory_xact_lock(jdlib:tenant-slug:acme)
R->>PG: insert tenants + tenant_placements + migration_state(pending)
R->>A: tenant.created
R->>PG: COMMIT
Op->>R: provision(tenant_id)
R->>PG: pg_advisory_xact_lock(jdlib:tenant:{id})
R->>St: provision(TenantRecord)
St->>PG: CREATE SCHEMA / CREATE DATABASE
R->>PG: migration_state.status = running
R->>St: migrate(TenantRecord, runner)
St->>MR: upgrade_tenant[_url](...)
MR->>PG: alembic upgrade head
R->>R: seeder.seed(record)
R->>MR: tenant_version(migration_schema(record))
R->>PG: migration success + status=active
R->>A: audit success + activation
R->>PG: COMMIT
8.5 Isolation strategies¶
Overview. Three strategies implement the same IsolationStrategy protocol
(src/jdlib/persistence/strategies/base.py:19-37):
session, provision, migrate, migration_schema, deprovision, plus the
attributes name and capabilities.
| Capability | SharedSchemaStrategy |
SchemaPerTenantStrategy |
DatabasePerTenantStrategy |
|---|---|---|---|
name |
shared |
schema |
database |
capabilities.supports_rls |
True |
False |
False |
capabilities.supports_ddl |
False |
True |
True |
| Session source | caller-supplied AsyncEngine |
caller-supplied AsyncEngine + SET search_path |
create_async_engine(resolved DSN) |
migration_schema |
configured shared schema (default public) |
validated t_* handle |
"public" in the tenant database |
| Provision | no-op | CREATE SCHEMA IF NOT EXISTS |
catalog check then CREATE DATABASE |
ARCHIVE deprovision |
no-op | no-op | no-op |
PURGE/DESTROY deprovision |
no-op (registry requires TenantPlanePurger) |
DROP SCHEMA IF EXISTS … CASCADE |
terminate backends, then DROP DATABASE IF EXISTS |
| Health check | none | none | none |
Shared strategy (strategies/shared.py:20-65): rejects unsafe shared
schema names via DEPLOYMENT_SCHEMA_RE; a missing engine raises
StrategyCapabilityError("shared strategy has no engine configured"); when
rls.enabled an after_begin listener runs
SELECT set_config('app.tenant_id', :tenant_id, true) — transaction-local.
rls.app_role is not used by the strategy (the engine must already connect
as that role).
Schema strategy (strategies/schema.py:16-107): validate_schema_name
enforces ^t_[a-z0-9][a-z0-9_]{0,50}$; each transaction issues
SET search_path TO <driver-quoted schema>; a SQLAlchemy checkin reset
listener issues RESET search_path when the connection is asyncio-safe;
provisioning uses admin_engine or engine; identifiers are quoted before
interpolation.
Database strategy (strategies/database.py:18-203):
validate_database_handle enforces [a-z0-9][a-z0-9-]{0,50}; the handle is
opaque (a DSN placed in target_handle is rejected by validation);
_resolve calls secrets.resolve(handle) and converts KeyError into
StrategyCapabilityError; engine cache is an OrderedDict[str, AsyncEngine]
capped by max_targets=32; cache hits move the entry to most-recent;
eviction picks the first engine whose pool.checkedout() == 0 and raises
PoolCapacityError if every cached engine is checked out; invalidate(handle)
bumps a generation counter, drops the entry and disposes the engine;
dispose() clears tenant and admin engines.
Concurrency notes. A generation counter makes an _engine_for call that
crossed an await during idle eviction retry and re-resolve after an
invalidation (strategies/database.py:101-153). There is no asyncio.Lock
around cache lookup, insertion, eviction, invalidation, or admin-engine
creation, so concurrent first-time resolution of the same handle can create
duplicate engines; see §23.
RLS is not a strategy. strategies/rls.py is a helper module:
rls_eligible_tables, emit_rls_policies, install_rls, verify_rls.
8.6 TenantSession, scoping, and the write fence¶
Covered structurally in §7.2. The feature-level contract:
Overview. TenantSession is the only sanctioned way to touch tenant data
in jdlib. UnitOfWork adds write admission; TenantRepository adds
ergonomics; SessionRouter selects the physical session by placement.
Public API. TenantSession, UnitOfWork, TenantRepository,
SessionRouter, WriteFence, NullWriteFence, RegistryWriteFence,
privileged_bypass, tenant_table_map.
Execution flow — a scoped read:
TenantSession.execute(stmt)triggersdo_orm_execute.- The listener rejects untrusted shapes (text, literal columns, bare tables,
raw aliases,
FromStatement). - Trusted
TenantRoutedmappers are identified. with_loader_criteria(...)is attached per mapper.- SQLAlchemy emits
WHERE tenant_id = $1; relationship loads and subquery loaders inherit the criteria.
Execution flow — a scoped write: ORM unit-of-work flush triggers
before_flush; new rows are stamped with the context tenant; dirty
tenant_id changes are rejected; add/update flush. Bulk
update_where/delete_where append the predicate themselves. Raw
hard-delete of a SoftDelete model requires a privilege.
Error flow. MissingTenantContext (no context),
CrossTenantReferenceError (foreign or mutated tenant_id),
UnscopedBulkOperation (unscoped bulk DML), AuthorizationError (privileged
raw SQL without the required capability), UnscopedRawSql (validator
rejection).
Strategy routing. SessionRouter(strategies).session(record) selects
strategies[record.strategy] and delegates; an unregistered strategy raises
StrategyCapabilityError (src/jdlib/persistence/router.py:13-23). The router
does not validate tenant status, secrets, or consistency between the
returned session's placement and a later TenantContext — the consumer is
expected to feed it a record from registry.assert_servable (README
example: SessionRouter(strategies).session(await
registry.assert_servable(tenant_id)), README.md:267-269).
8.7 Raw SQL validation (RawSqlValidator)¶
Overview. TenantSession.raw_sql is the only path to hand-written SQL.
The validator parses SQL with pglast (parse_sql, i.e. the real
PostgreSQL/libpg_query AST — src/jdlib/persistence/rawsql.py:8-24) and
requires structural proof that the active tenant is bound; it never
pattern-matches strings.
Public API.
class RawSqlMode(StrEnum): # rawsql.py:31-33
TENANT_BOUND = "tenant_bound"
PRIVILEGED = "privileged"
@dataclass(frozen=True, slots=True) # rawsql.py:36-39
class CompiledRawSql:
sql: str
binds: tuple[object, ...]
class RawSqlValidator: # rawsql.py:146-256
def __init__(self, tenant_tables: dict[str, type] | None = None) -> None
@staticmethod
def compile(statement: sa.TextClause, dialect: sa.Dialect,
params: dict[str, object]) -> CompiledRawSql
def validate_tenant_bound(self, compiled: CompiledRawSql,
tenant_id: uuid.UUID) -> None
def validate_privileged(self, compiled: CompiledRawSql) -> None
Capabilities required by TenantSession.raw_sql
(src/jdlib/persistence/session.py:335-355):
| Capability | Validation performed | Audited |
|---|---|---|
none (RawSqlMode.TENANT_BOUND) |
full validate_tenant_bound |
no |
tenant.raw_sql |
full validate_tenant_bound |
yes |
platform.raw_sql |
validate_privileged only |
yes |
| any other / missing privilege | — | AuthorizationError |
Compilation. SQLAlchemy compiles the TextClause against the session
dialect; bind order comes from compiled.positiontup; failures become
UnscopedRawSql("raw SQL could not be compiled") chained from the cause.
Parse-level rules (both modes). Exactly one statement; the statement root
must be SelectStmt, InsertStmt, UpdateStmt, or DeleteStmt; CTEs are
rejected on all four roots; any parser error becomes
UnscopedRawSql("raw SQL could not be parsed"). DDL, transaction control,
SET, and COPY roots are therefore unsupported by construction.
Tenant-bound proof rules (rawsql.py:179-256):
- Every relation must be a known tenant table from
tenant_table_map(); schema-qualified names and unknown relations are rejected. - Relation collection covers
FROM(includingJoinExprrecursively),UPDATE … FROM, andDELETE … USING. - Each relation needs its own top-level
WHEREconjunct of the form<qualifier>.tenant_id = <bound param>where the bound value equals the active tenant UUID. With multiple relations the qualifier must name the specific relation/alias. ORandNOTin theWHEREdecomposition raiseUnscopedRawSql("OR/NOT predicates are not allowed in tenant-bound SQL").- Any
SubLinkanywhere in the AST is rejected (scalar andEXISTSsubqueries);RangeSubselectrelations are rejected. INSERTrequires an explicittenant_idcolumn, a bound value equal to the active tenant in everyVALUESrow, rejects literals, rejectsINSERT … SELECT, rejectstenant_idassignment inON CONFLICT DO UPDATE, and rejectstenant_idin theUPDATEtarget list.- Assignment of
tenant_idanywhere in a DML target list is rejected (rawsql.py:64-70). - Union/intersect/except have no dedicated handling: a set-operation root fails
the per-relation proof (or lands in the relation allow-list check) and is
rejected. The exact message text depends on the installed pglast version
(
pglast>=6.0).
Privileged proof rules. validate_privileged only checks: one statement,
no CTE, supported root. It performs no tenant, relation, schema, subquery,
boolean, or assignment checks.
Error flow. All validator rejections raise UnscopedRawSql; missing or
wrong capability raises AuthorizationError before execution; privileged
calls are audited before execution. Execution errors after validation are not
normalized.
sequenceDiagram
autonumber
participant App as Consumer
participant TS as TenantSession
participant P as Privilege (context)
participant V as RawSqlValidator
participant A as RawSqlAuditor
participant DB as PostgreSQL
App->>TS: raw_sql(text("SELECT … WHERE tenant_id = :tid"), {"tid": tenant_id})
TS->>P: privilege capability check
alt no privilege (tenant_bound)
TS->>V: compile(text, dialect, params)
V-->>TS: CompiledRawSql(sql, binds)
TS->>V: validate_tenant_bound(compiled, tenant_id)
V-->>TS: ok (or raises UnscopedRawSql)
else tenant.raw_sql
TS->>V: validate_tenant_bound(...)
TS->>A: record_raw_sql(mode, capability, tenant_id, sql)
else platform.raw_sql
TS->>V: validate_privileged(...)
TS->>A: record_raw_sql(...)
end
TS->>DB: execute(execution_options={"jdlib_raw_sql": True})
DB-->>App: Result
8.8 Authentication (jdlib.authn)¶
Overview. One Authenticator protocol, two shipped implementations
(OIDC and API keys), a first-success composite, and wiring adapters that
satisfy ContextFactory's collaborator ports.
Authenticator port (authn/base.py:10-11):
class Authenticator(Protocol):
async def authenticate(self, request: RequestInfo) -> Principal | None: ...
Returning None means "no credentials / not mine"; raising is reserved for
genuine failures. Helpers in the same module: bearer_token(request) (case-
insensitive Bearer, arbitrary whitespace, returns None when malformed) and
record_authn_failure(audit, reason) (writes authn.failed with
tenant_id=None, actor_id=None, metadata={"reason": reason}).
8.8.1 API keys¶
- Token shape:
<configured-prefix>.<key-id>.<secret>; default prefixjd(ApiKeyConfig.prefix,src/jdlib/config.py:61-62). The lookup key is the first two segments joined by.; the secret is Argon2-hashed and verified withargon2(authn/apikey.py:29-44). - Flow (
authn/apikey.py:61-103): parse → look upjd_control.api_keysbykey_prefix→ requirestatus = activeand not expired → require the parentServiceAccountto exist and be active → buildPrincipal(kind=SERVICE_ACCOUNT, id=service_account.id, tenant_id=key.tenant_id, api_key_id=key.id, scopes=frozenset(scopes) or None)→ update and commitlast_used_at. - Scope narrowing: SQL
NULLscopes becomeNone(no narrowing); any non-null list becomes afrozensetintersected with base authority by the PDP. - Audit gating: a token whose first segment does not match the configured
prefix is silently ignored (not audited); a matching prefix with an invalid
key is audited as
authn.failed(typicallyreason="invalid_api_key"). Token material is never included in metadata (tests/integration/test_apikey_auth.py:89-193).
8.8.2 OIDC¶
- JWKS cache (
authn/oidc.py:26-79):JwksCache(*, url, ttl, fetcher, clock=None, min_refresh_interval=1s); caches JWKs bykid; refreshes when stale or when an unknownkidis requested, subject to the minimum refresh interval; parses keys viajwt.PyJWK.from_dict(...).key; malformed keys and JWT-library errors returnNone; fetch exceptions propagate to the authenticator, which audits and returns no principal. There is no lock around concurrent refreshes. - Token verification (
authn/oidc.py:82-124):TokenVerifier.verifyuses PyJWT for signature, configured algorithms, audience, and issuer, with PyJWT'sexp/nbf/iatchecks disabled and reimplemented:expis rejected whenexp <= now - leeway;nbf/iatare rejected only beyondnow + leeway. - Claim mapping (
authn/oidc.py:127-150):IdentityClaims(issuer, subject, email, email_verified);subis mandatory; a missingissfalls back to the configured issuer; an unverified email is dropped. - User linking (
authn/oidc.py:153-216):(issuer, subject)identity link first; ifconfig.email_trustedthen a verified, case-insensitive email match to an active user; otherwise create a new active user (storing the verified email, orNone). Identity-link uniqueness races are handled by rollback-and-reread. ReturnsUser | None(None⇒ auditedunlinked_identity). - Result: always
Principal(kind=USER, id=user.id, user_id=user.id). No claims, tenant, scopes, or issuer are retained on the principal. - Audit: missing/malformed tokens and missing
kidare silent; JWT, key, or linking failures auditauthn.failedwith the exception class name only.
8.8.3 Composite and wiring¶
CompositeAuthenticator(authenticators)returns the first non-Noneprincipal and does not catch exceptions (authn/composite.py:10-19).build_authenticator(config, session_factory, audit)assemblesOidcAuthenticator(only whenconfig.oidcis set) followed byApiKeyAuthenticator(always), returning aCompositeAuthenticator(authn/wiring.py:30-57).fetch_jwks(url)creates ahttpx.AsyncClientper call with no explicit timeout and requires a mapping response (authn/wiring.py:20-27).- Adapters:
PrincipalDirectoryAdapter(delegates toAccessReader.principal_active),TenantDirectoryAdapter(delegates toTenantRegistry.find),EntitlementAdapter(service accounts must match the tenant pin and be active; other principals need an active principal and an active membership; platform operators are consequently denied byControlAccessReader) (authn/wiring.py:60-87).
sequenceDiagram
autonumber
participant MW as TenantMiddleware
participant CA as CompositeAuthenticator
participant OA as OidcAuthenticator
participant JC as JwksCache
participant IDP as OIDC issuer (httpx)
participant UL as UserLinker
participant AK as ApiKeyAuthenticator
participant PG as jd_control
participant A as PlatformAudit
MW->>CA: authenticate(RequestInfo)
CA->>OA: authenticate(request)
OA->>OA: bearer_token + kid
OA->>JC: signing_key(kid)
JC->>IDP: JWKS fetch (if stale/unknown)
IDP-->>JC: JWK set
JC-->>OA: key
OA->>OA: TokenVerifier.verify (sig/alg/aud/iss/times)
OA->>UL: link(IdentityClaims)
UL->>PG: identity_links / users lookup+insert
PG-->>UL: User
UL-->>OA: User
OA-->>CA: Principal(USER)
Note over CA,AK: API key is only consulted if OIDC returns None
CA-->>MW: Principal
8.9 Authorization decisioning (jdlib.authz PDP, guards, cache)¶
Overview. The vocabulary (permissions, resource types, scopes) is code
defined; authority is data (control-plane rows); decisions are computed by
DefaultPDP with frozen precedence; enforcement is Enforcer/requires/
authorize.
Permission catalog (authz/permissions.py:23-66): Permission(code,
namespace, allowed_scopes: frozenset[ScopeType], description);
PermissionCatalog.with_builtins() registers exactly 13 codes:
| Code | Namespace | Bindable scopes |
|---|---|---|
tenant:read, tenant:manage |
tenant |
tenant |
member:invite, member:manage |
member |
tenant |
role:manage |
role |
tenant |
org:manage |
org |
tenant, org |
team:manage |
team |
tenant, org, team |
audit:read |
audit |
tenant |
resource:read, resource:create, resource:update, resource:delete, resource:share |
resource |
tenant, org, team |
Duplicate codes raise ValueError; unknown codes raise UnknownPermission.
Resource types (authz/resource_types.py:9-56):
ResourceTypeDefinition(resource_type, model, tenant_owned, tenant_routed,
permission_namespace); register rejects empty names/namespace, requires
tenant_routed ⇒ tenant_owned, requires the model to expose
organization_id/team_id when routed, and rejects duplicates; get raises
UnknownResourceType; validate_permission requires
Permission.namespace == definition.permission_namespace else
InvalidResourcePermission. No application resource type is registered by the
library — consumers register their own.
Scope resolution (authz/scopes.py:15-83): ScopeRef(type, id),
ResourceRef(resource_type, resource_id), Target = ScopeRef | ResourceRef.
ScopeResolver.chain(session, target) returns:
- tenant scope:
[(TENANT, None)]; - org target: tenant → target org → each parent org to the root (cycle-safe
via a
seenset); - team target: tenant → the team's org and its ancestors → the team;
- resource: looks up the registered model, reads
organization_id/team_id, then tenant → org chain → team.
There is no own, all, or ancestor scope. Tenant-wide authority exists
because every chain starts at tenant. Missing org/team/resource rows collapse
to the tenant-only chain rather than raising; deleted_at is not consulted.
Decisions, guards, and precedence are described in §7.3 and §6.1.
Enforcer.require is the only place denials become exceptions
(src/jdlib/authz/guards.py:20-31).
Cache. See §7.4. The README is emphatic that a PDP/cache must not be shared
across requests (README.md:220-222) and shows a RequestScopedEnforcer
that constructs a fresh PDP per evaluation (README.md:150-159).
8.10 AccessControl — authorization mutations¶
Overview. AccessControl is the only writer of roles, bindings, grants,
and invitations. It enforces anti-escalation, scope compatibility, resource
existence, last-owner protection, and audit, and invalidates the PDP cache
before every attempt.
Actor authorization model. AccessControl does not call the PDP.
actor_permissions(tenant_id, principal) reads only tenant-scope bindings
for the principal, unions their role permissions, and intersects
principal.scopes when non-None; it does not re-check principal/tenant/
membership activity (src/jdlib/authz/access.py:149-175). Every method also
accepts both actor_id and actor_principal, and the code does not verify
that they identify the same principal.
Method contracts:
| Method | Required actor authority | Validation | Notes |
|---|---|---|---|
create_role |
role:manage |
every code exists; actor holds every requested code | is_system=True is allowed; is_system only blocks update/delete |
update_role |
role:manage + subset |
rejects system roles | replaces permissions, audits a sorted diff |
delete_role |
role:manage |
rejects system roles; rejects roles with bindings or invitations | no role rename operation exists |
bind_role |
role:manage + all role permissions |
scope shape via ScopeLookup; each permission's allowed_scopes; grantee must be an active service account in the tenant or have an active membership; a role named exactly owner requires the actor to hold the entire current catalog |
validation happens in one control session; insert/audit/commit in a second; duplicates rely on the unique constraint (no idempotent path) |
unbind_role |
role:manage |
advisory lock jdlib:tenant:{id}; refuses removing the last binding of a role named owner |
remaining-owner count is not filtered by active principal/membership status |
grant_resource |
resource:share and the granted permission |
resource type exists; namespace matches; ResourceLookup confirms existence/tenant/not-deleted |
idempotent via INSERT … ON CONFLICT DO NOTHING RETURNING; no audit event when an existing grant is returned; grantee activity is not validated |
revoke_grant |
resource:share only |
— | does not require holding the originally granted permission |
transfer_resource |
resource:share |
destination scopes; organization required for a team destination | operates on a caller-supplied tenant session, flushes only (no commit); grants are preserved unless revoke_grants=True; audit is written in a separate control transaction |
move_team |
team:manage |
destination org via ScopeLookup |
cascades the new org id to every registered model exposing team_id + organization_id; optional grant revocation; flush-only; separate audit transaction |
invite |
member:invite |
advisory lock; role exists; scope/permission compatibility; owner rule; actor subset; revokes prior pending invitations for the same tenant/email/scope | returns (Invitation, plaintext_token); token is inv_<32 hex>_<64 hex>; only the 64-hex secret is Argon2-hashed; no email is sent |
accept_invitation |
none (token is the authority) | token, pending status, expiry; atomic conditional claim; re-validates scope and role compatibility | reuses an active membership/existing binding or creates both; does not verify the invitation email against the accepting identity; advisory-free but race-safe via conditional update (tests/integration/test_access_invitations.py:643-685) |
Audit events (exact action names, src/jdlib/authz/access.py):
role.created, role.permissions_changed, role.deleted, binding.created,
binding.removed, grant.created, grant.revoked, resource.owner_changed,
team.moved, invitation.revoked, invitation.created, invitation.accepted.
Audit actor typing (verified nuance). AccessControl never passes
actor_type; DatabaseAuditSink._resolve_actor_type therefore records
"user" for every non-null actor id — even when the actor was a service
account (src/jdlib/control/audit.py).
Cache invalidation. Every attempted mutation calls cache.invalidate()
first, so the rest of the request is uncached even when the mutation fails.
8.11 Migrations¶
Overview. MigrationRunner configures Alembic in memory (there is no
alembic.ini — it was removed; the programmatic runner is the only supported
path) and drives two independent chains: control and tenant.
Public API. MigrationRunner(database_url) with upgrade_control,
upgrade_tenant, upgrade_tenant_url, provision_tenant_schema,
tenant_version, tenant_version_url (all synchronous).
Two planes:
| Aspect | Control chain | Tenant chain |
|---|---|---|
| script location | src/jdlib/migrations/control |
src/jdlib/migrations/tenant |
| target metadata | ControlBase.metadata (schema jd_control) |
TenantBase.metadata (unqualified) |
| version table | jd_control.alembic_version |
<tenant schema>.alembic_version |
| baseline revision | 0001_control_baseline (creates schema, CREATE EXTENSION citext, create_all) |
0001_tenant_baseline (create_all) |
| connection handling | engine_from_config(..., poolclass=NullPool) |
same, plus SET search_path TO <quoted schema> committed before the migration transaction |
| offline mode | include_schemas=True, literal_binds=True |
no include_schemas (tables are unqualified) |
Transactions. upgrade_control commits CREATE SCHEMA IF NOT EXISTS in
one engine.begin() block and then runs command.upgrade in Alembic's
transaction; provision_tenant_schema similarly commits schema creation before
upgrading. There is no single outer transaction spanning both steps, and no
retry.
Version checks. tenant_version first probes to_regclass(:quoted) and
then SELECT version_num FROM "<schema>".alembic_version LIMIT 1; a missing
schema yields None.
Schema validation. provision_tenant_schema uses
_TENANT_SCHEMA_RE = t_[a-z0-9][a-z0-9_]{0,50}; the deployment-facing methods
use DEPLOYMENT_SCHEMA_RE = [a-z_][a-z0-9_]{0,62}; both fullmatch and raise
ProvisioningError before any engine or SQL is created
(tests/integration/test_migrations.py:10-89).
8.12 PostgreSQL row-level security¶
Overview. RLS is opt-in hardening for shared-schema placements. It is a
helper module used by the CLI, by consumer Alembic environments
(emit_rls_policies), and by operators (install_rls/verify_rls).
Policy emitted per eligible table (strategies/rls.py:54-67):
ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <t> FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON <t>;
CREATE POLICY tenant_isolation ON <t>
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
-- optional: GRANT SELECT, INSERT, UPDATE, DELETE ON <t> TO <app_role>
Eligibility: TenantRouted subclasses present in the supplied metadata
(rls_eligible_tables, rls.py:40-51).
Install (rls.py:79-106): up to three attempts, each with
SET LOCAL lock_timeout = '5s' inside the DDL transaction; retries only for
exception class names DeadlockDetectedError and LockNotAvailableError with
asyncio.sleep delays of 0.25 s then 0.50 s; a successful install always
follows with verify_rls.
Inspect (rls.py:109-172): reading the catalog and judging it are separate
concerns. inspect_rls(engine, *, metadata) returns a frozen RlsInspection —
tables_checked, tables_enforced, the four problem tuples
(tables_missing_rls, tables_missing_force, tables_missing_policy,
tables_unprovisioned) and the is_enforced property — and makes no judgement:
a table the engine cannot see is reported as unprovisioned, because for evidence
that is an observation and for a caller who asked for a schema it is a failure
it can explain.
Verify (rls.py:175-197): the app role must exist, must be neither
BYPASSRLS nor a superuser, and then verify_rls raises
StrategyCapabilityError on the first problem inspect_rls reports, including
a hint that the engine must be bound to the target schema
(tests/unit/test_rls_policies.py:10-53). The split exists because the guard
must raise on the first problem while evidence has to report all of them, and
both must agree on what the catalog said:
jdlib.security.compliance.session.collect_isolation_evidence shares
inspect_rls (§8.22), which is why the CLI's rls verify and the evidence path
cannot drift apart.
Session binding. SharedSchemaStrategy sets app.tenant_id in an
after_begin listener with set_config(..., true) (transaction-local), so an
unset setting yields zero rows, never all rows
(tests/integration/test_rls_db.py:112-322).
8.13 Audit¶
Overview. Two audit domains with separate models and sinks.
| Aspect | Platform audit | Tenant audit |
|---|---|---|
| Port | PlatformAudit protocol (control/audit.py:27-37) |
TenantAuditRecorder class (models/audit_recorder.py:37-117) |
| Durable model | jd_control.platform_audit_events |
<tenant schema>.audit_events |
| Implementations | DatabaseAuditSink, CompositeAuditSink, RecordingPlatformAudit; NullPlatformAudit raises RuntimeError("no platform audit sink is configured") |
writes into the consumer's TenantSession |
| Identity columns | request_id, correlation_id, trace_id filled from the active context when present, else NULL |
same, always from the context (context is required) |
| Tenant id | nullable, no FK (history survives deletion) | part of the composite PK |
DatabaseAuditSink.record (control/audit.py:79-116):
- With no
sessionargument: create → flush → commit → close (own transaction). - With a
ControlPlaneSession: flush into the caller's transaction, no commit, no close — this is how registry andAccessControlmutations get same-transaction audit. - Actor inference:
actor_type or ("system" if actor_id is None else "user"). - Trace ids come from
current_tenant()guarded byMissingTenantContext(control/audit.py:19-24); no synthesis when absent. target_*,ip, andoccurred_atare not protocol parameters, so they stay null/default exceptseverity="info".
CompositeAuditSink (control/audit.py:119-150) fans out sequentially and
stops at the first failure; when actor_type is None it omits that keyword
entirely so older sinks lacking the parameter still work.
PrivilegeAuditAdapter (control/audit.py:153-172) adapts a
PlatformAudit to ContextFactory's PrivilegeAudit port, emitting
action=f"privilege.{kind.value}", actor_id=actor.id,
actor_type=actor.kind.value, and
metadata={"capability": …, "justification": …}.
TenantAuditRecorder.record (models/audit_recorder.py:42-64): requires a
context (MissingTenantContext otherwise), stamps tenant/actor/request ids,
copies metadata, add()s the row, and never flushes or commits.
Attribution is fail-closed and time is corroborated (phase 8).
TenantAuditRecorder.record_security(event) (models/audit_recorder.py:66-117)
persists a SecurityAuditEvent (jdlib.security.audit) in the caller's
transaction and refuses a row that would be mis-attributed: an event naming
another tenant, or another actor id or actor kind, than the ambient one raises
ValueError rather than being written — a mis-attributed audit row points an
investigation at the wrong tenant or blames the wrong principal, which is worse
than a missing row. The actor is taken from the most specific bound context (the
request's SecurityContext when there is one, else the TenantContext,
_ambient_actor, audit_recorder.py:22-34), and an event that names no actor
is attributed the same way the tenant is. The timestamp is corroborated rather
than trusted: occurred_at must sit within MAX_TIMESTAMP_SKEW (one minute,
audit_recorder.py:19) of the recorder's clock, so request-derived data cannot
backdate, postdate or replay an entry. Reading history is not writing it —
export rebuilds envelopes from stored rows with their original timestamps
(docs/security/audit-hardening.md).
8.14 Tenant-plane purge (TenantPlanePurger)¶
Overview. For shared placement, "purge" means deleting every tenant row
from the shared tables; for schema/database placement the strategy drops the
namespace/database instead. TenantPlanePurger implements the former.
Public API. TenantPlanePurger(session_factory, *, factory: ContextFactory,
rls: RlsConfig | None = None) with purge(tenant: TenantRecord) -> dict[str,
int].
Flow (control/purge.py:15-61): dynamically import the framework tenant
model modules; walk TenantRouted.__subclasses__() into a table map; derive
child-first order from TenantBase.metadata.sorted_tables (reverse); obtain a
system context via factory.for_system(tenant, purpose="tenant.purge",
issuer=PRIVILEGE_ISSUER); when RLS is enabled set app.tenant_id; delete every
registered tenant-row class through TenantSession.delete_where; commit once;
return per-table row counts.
TenantRegistry.deprovision requires a purger for shared placement on
non-archive modes and fails closed when it is missing
(src/jdlib/control/registry.py:512-544).
8.15 Schema metadata lint¶
Overview. jdlib.lint statically validates SQLAlchemy metadata against the
relational contract before it ever reaches a database.
Public API.
@dataclass(frozen=True)
class LintIssue: # lint.py:21-25
rule: str
table: str
message: str
def lint_metadata(controls: list[MetaData], rls_enabled: bool = False) -> list[LintIssue]
def assert_clean(controls: list[MetaData], rls_enabled: bool = False) -> None # raises SchemaLintError
Implemented rules (12 of the L1–L19 ids referenced in the design docs):
| Rule | Check | Message |
|---|---|---|
| L1 | tenant-scoped table PK lacks tenant_id |
primary key does not include tenant_id |
| L4 | tenant_id in PK but not first |
primary key does not lead with tenant_id |
| L9 | tenant-scoped tenant_id is nullable |
tenant_id is nullable |
| L2 | tenant→tenant FK omits tenant_id |
FK {name} omits tenant_id |
| L3 | FK crosses planes (table.schema != referred.schema) |
FK {name} crosses planes |
| L6 | table transitively reachable from tenants via ondelete="CASCADE" |
cascade path from tenants |
| L11 | tenant-scoped model is not TenantOwned |
tenant-scoped model is not TenantOwned |
| L12 | unique constraint/index lacks tenant_id (unless whitelisted) |
unique … is not tenant-scoped |
| L13 | with rls_enabled=True, a schema-less table is not RLS-eligible |
tenant-plane table is not RLS-eligible |
| L15 | team_id present without the check + composite team FK |
team ownership constraints missing |
| L17 | schema-less known model is not TenantRouted |
tenant-plane model must be TenantRouted |
| L19 | jd_control model is TenantRouted |
control-plane model must not be TenantRouted |
Not implemented (named in docs/jdlib/03-isolation-invariants.md but never
emitted by lint.py): L5, L7, L8, L10, L14, L16, L18. There is no L20+.
Other constants: GLOBAL_UNIQUE_WHITELIST allows
("api_keys","uq_api_keys_key_prefix"), ("invitations",
"uq_invitations_token_id"), ("tenants","uq_tenants_slug");
TENANT_ID_REFERENCE_ONLY = {"platform_audit_events"}; tables named
alembic_version* are skipped. Because L1 and L4 share an elif chain, one
call never emits both for the same table.
8.16 Secrets¶
Overview. Database-per-tenant targets are opaque handles resolved to full DSNs by a consumer-supplied provider.
class SecretProvider(Protocol): # persistence/secrets.py:7-8
def resolve(self, handle: str) -> str: ...
class MemorySecretProvider: # :11-19 (copies mapping)
class EnvSecretProvider: # :22-31 (prefix "JDLIB_TENANT_DB_")
EnvSecretProvider computes prefix + handle.upper().replace("-", "_"), so
handle db-acme resolves to JDLIB_TENANT_DB_DB_ACME; a missing variable
raises KeyError, which DatabasePerTenantStrategy._resolve converts to
StrategyCapabilityError("no secret for database handle {handle}").
Rotation contract (documented behavior): the resolved DSN is retained by
the cached AsyncEngine; changing the provider value does not hot-reload a
cached engine. The caller must await strategy.invalidate(handle) after
rotation; dispose() clears everything.
8.17 UUIDv7 generation¶
jdlib._uuid.uuid7() (src/jdlib/_uuid.py:8-14) builds a UUIDv7 from
int(time.time() * 1000) in the top 48 bits, os.urandom(16) for randomness,
version bits 0x70, and RFC 4122 variant bits 0x80. It is the application-side
ID default for all control and tenant models (control/models.py,
models/org.py, models/audit.py, authz/access.py).
Properties: lexically ordered across different milliseconds; no same-millisecond monotonic counter, no lock, no clock-rollback guard. The module is private (leading underscore) and not re-exported.
8.18 Testing kit (jdlib.testing)¶
Public API (testing/__init__.py:1-13): exactly three assertion helpers.
async def assert_scoped_count(session_factory, context, model, expected: int) -> None
async def assert_tenant_isolated(session_factory, context_a, context_b, model, **values) -> uuid.UUID
async def assert_cross_tenant_write_rejected(session_factory, context_a, model, **values) -> None
Semantics: assert_scoped_count counts visible rows through a new
TenantSession; assert_tenant_isolated creates+commits a row for tenant B and
requires tenant A to see zero rows and None for B's id (returns the created
id); assert_cross_tenant_write_rejected requires
CrossTenantReferenceError on flush and raises AssertionError if the flush
succeeds.
Pytest plugin (testing/pytest_plugin.py, autoloaded via
[project.entry-points.pytest11], pyproject.toml:36-37):
| Fixture | Scope | Behavior |
|---|---|---|
jdlib_database_url |
session | JDLIB_TEST_DATABASE_URL; skips the test when unset |
jdlib_schema |
session | JDLIB_TEST_SCHEMA, default jdlib_test |
jdlib_models |
function | tuple of model modules (jdlib_test_models() defaults to org/settings/audit); override to extend |
jdlib_engine |
function | async engine (via pytest_asyncio if installed, else a skipping sync fixture) that imports the model modules, converts the DSN to asyncpg, sets search_path, creates the schema and TenantBase tables, and disposes at teardown (it does not drop them) |
jdlib_session_factory |
function | async_sessionmaker(engine, expire_on_commit=False) |
pytest_asyncio is imported inside a try/except ImportError at module level
(testing/pytest_plugin.py:19-21); the plugin is never imported by
import jdlib.
8.19 FastAPI integration¶
Public API. JdlibContainer, install, get_context, get_uow,
require (§6.2).
install (integrations/fastapi.py:41-58) optionally stores the container on
app.state.jdlib_container, adds TenantMiddleware, and registers
_handle_jdlib_error for JdlibError. It does not construct any
authenticator, PDP, registry, or context factory.
get_context returns current_tenant(). get_uow yields
container.uow_factory() (annotated UnitOfWork; entering it yields a
TenantSession). require(permission, target_factory=None) returns a FastAPI
dependency that opens its own uow_factory() transaction, builds the
target (tenant_target() by default, otherwise target_factory(request)), and
calls container.enforcer.require(...). _container raises
RuntimeError("jdlib container is not installed on the app") when missing.
_handle_jdlib_error returns JSONResponse(status_code=http_status_for(exc),
content={"error": type(exc).__name__, "detail": str(exc)}).
Documented TOCTOU (module docstring, integrations/fastapi.py:1-12): the
guard authorizes in its own unit of work — so RegistryWriteFence applies to
guarded reads too (a guarded read during a relocation quiesce fails with 423) —
but the handler's own session does not re-validate the guard's decision.
8.20 Typer CLI¶
Public API. app (jdlib.integrations.cli). Six Typer app objects:
app, tenant_app, db_app, rls_app, schema_app, security_app
(integrations/cli.py:52-62). Shared options: --database-url (env
JDLIB_CONTROL_DSN), --shared-schema (default public), --json.
| Group | Command | Options/arguments |
|---|---|---|
tenant |
create |
--slug, --name, --strategy (default shared), --target-handle (default default), --region, --shared-schema, --json |
tenant |
provision |
positional <tenant_id: UUID> |
tenant |
list |
inner-joins tenants and placements (a tenant without a placement is omitted) |
db |
upgrade-control |
implicit head |
db |
upgrade-tenants |
iterates registered tenants sequentially |
rls |
install |
--schema, --app-role |
rls |
verify |
--schema, --app-role |
schema |
lint |
--rls/--no-rls; no database/shared-schema options |
security |
posture |
--environment, --database-url + --schema (live evidence), --branch (default tenant), --policy-directory, --pdp-kind (default cerbos), --json; exits 1 when any control FAILs (§8.22) |
security |
evidence |
the same options; prints the evidence records behind the report (--json emits the list) |
security |
compliance |
--framework, --environment, --json; the register and its framework mappings plus the posture counts |
The CLI registers only SharedSchemaStrategy and SchemaPerTenantStrategy
(integrations/cli.py:115-127); database placement requires programmatic
wiring (README.md:362-365). It cannot suspend/resume/archive/deprovision,
manage identities/roles/grants/invitations, choose a revision, or downgrade.
The security group is read-only reporting: it assesses the profile the
operator declares (--environment) rather than a deployment's private settings,
--database-url without --schema is refused, and an unknown environment,
branch or framework is a BadParameter. JdlibError and SQLAlchemy errors
print error: … to stderr with exit code 1; unexpected exceptions are not
normalized. At 6125558 there is no [project.scripts] entry — consumers
exposed app themselves (README.md:69-72) — and the console script landed in
2bc22e3: def main() (integrations/cli.py:624-627) and
[project.scripts] jdlib = "jdlib.integrations.cli:main" (pyproject.toml:41-42),
so an install ships a jdlib console script (typer comes from the cli extra),
and the CI smoke step runs it.
8.21 Internal component architecture¶
graph TB
subgraph Request["Request path"]
MW["TenantMiddleware"]
AUTHN["CompositeAuthenticator<br/>Oidc + ApiKey"]
RES["ResolverChain"]
CF["ContextFactory"]
end
subgraph AuthzPath["Decision path"]
ENF["Enforcer"]
PDP["DefaultPDP"]
CACHE["AuthzCache (request-scoped)"]
SCOPES["ScopeResolver"]
READER["ControlAccessReader"]
CAT["PermissionCatalog"]
RTT["ResourceTypeRegistry"]
end
subgraph DataPath["Data path"]
UOW["UnitOfWork"]
FENCE["RegistryWriteFence"]
TS["TenantSession"]
EVT["do_orm_execute + before_flush"]
RAW["RawSqlValidator (pglast)"]
REPO["TenantRepository"]
ROUTER["SessionRouter"]
end
subgraph ControlPath["Control path"]
REG["TenantRegistry"]
CP["ControlPlaneSession"]
AUDIT["DatabaseAuditSink"]
MIG["MigrationRunner"]
STRATS["IsolationStrategy map"]
end
MW --> AUTHN
MW --> RES
MW --> CF
CF --> REG
ENF --> PDP
PDP --> CACHE
PDP --> SCOPES
PDP --> READER
PDP --> CAT
SCOPES --> RTT
UOW --> FENCE
UOW --> TS
TS --> EVT
TS --> RAW
REPO --> TS
ROUTER --> STRATS
REG --> CP
REG --> AUDIT
REG --> MIG
REG --> STRATS
READER --> CP
8.22 Compliance evidence and posture (jdlib.security.compliance)¶
Overview. The compliance surface (phase 9; directive §9.1–§9.5) answers
"which technical controls does this library implement, what evidence stands
behind that claim, and are they in force here" — and answers in readiness
language throughout. A ControlMapping names where a control belongs in a
framework; it is never an audit result, and the package docstring says so. Three
properties are enforced by tests because each decays silently: no silent
partials (a status other than IMPLEMENTED/NOT_APPLICABLE must carry a
status_note), every cited evidence_sources path must exist on disk, and
at least three gaps must be recorded so the register cannot quietly become a
wish list. The register's own gaps are in the register, not omitted from it
(docs/security/compliance-evidence.md).
The registry — 19 controls over 7 framework families. CONTROL_REGISTRY
(controls.py:759-761) is a frozen Mapping[str, Control]; Control carries
control_id, title, description, category, implementation_status,
mappings, evidence_requirements, evidence_sources, technical_owner,
verification_method and an optional status_note. Control ids are shaped
JDL-… (JDL-AUTHN-01, JDL-ISO-01, JDL-SUPPLY-01, …) and map onto
ControlFramework members — SOC 2, ISO/IEC 27001, NIST CSF, NIST SP 800-53,
OWASP ASVS, OWASP API Security Top 10, CIS — the seven families
frameworks_in_use() returns. At this revision the register holds 19 controls:
15 IMPLEMENTED, 2 PARTIAL (Tyk plugin execution; pipeline security), 1
NOT_IMPLEMENTED with its reason (tamper-evident audit storage — a deployment
responsibility, not a library one) and 1 NOT_APPLICABLE (replay protection for
a stateless validator). control_by_id, controls_for_framework and
frameworks_in_use are the lookup helpers (§26.6 records the run that confirmed
these counts).
Evidence (directive §9.3). SecurityEvidence is a frozen dataclass that
makes the five required properties structure rather than convention:
observations (a flat mapping of JSON scalars, passed through the same
sanitiser as audit metadata — jdlib.security.audit.audit_metadata — so a
credential-shaped value is redacted, a credential-named key is redacted whole,
and a nested or oversized value is refused rather than smuggled into a report),
collected_at (from an injected clock), collected_by (cannot be blank),
reproducible (the command or procedure that regenerates the observation) and
control_id/subject; repr prints observation keys, never values. Three
collectors ship in evidence.py: collect_configuration_evidence (reads the
fixed allow-list CONFIGURATION_EVIDENCE_KEYS, 16 keys, so a future field
cannot arrive in a report unreviewed), collect_migration_evidence (one head per
branch of MIGRATION_BRANCHES = ("control", "tenant"), refusing a branch with
two heads or a revision file with no identifier), collect_policy_evidence
(names and counts of policy files only — a policy body is a deployment's
business and is never read) — plus two session-backed collectors in
evidence's sibling session.py: collect_isolation_evidence (the
PostgreSQL catalog, through inspect_rls, §8.12) and
collect_schema_revision_evidence (the revision a live schema has applied,
against the checkout's expected head; a missing revision table is an observation,
not an error). A collector that cannot reach its subject raises: "no evidence"
is a state a report may describe, but an assumed state is not.
Posture (directive §9.4). evaluate_posture(config, *, evidence=(), rules=None,
clock=None, environment=None) (posture.py:667-716) evaluates every control
in the registry exactly once and returns a PostureReport — findings in
deterministic order, counts, worst_outcome, is_ready, failures and
as_dict(); a registry control with no rule raises. The four outcomes are
PASS / WARN / FAIL / NOT_APPLICABLE with a Severity per finding, and
the semantics are fixed in the module rather than left to each rule: PASS is a
control in force — either a gate inside a code path a caller cannot route around
or a state something actually observed; WARN is in force but unverified here
(no evidence, or incomplete evidence, and the reason names what is missing), or
switched off in development, the profile that declares itself unsafe on
purpose; FAIL is a control that should be in force and is absent, disabled or
contradicted; NOT_APPLICABLE is a subject this deployment does not have. A
finding needs a reason of at least 20 characters, must name a known control, and
cannot cite another control's evidence; serialization carries outcomes, reasons
and evidence summaries — never observation values. POSTURE_RULES covers the
registry exactly, and rules= exists so a caller (and the tests) can document
what a missing rule would look like.
Import isolation. The namespace is additive by construction: the security
core's public surface is pinned by an exact-equality test, so new compliance API
lives in a submodule rather than in jdlib.security. Importing
jdlib.security.compliance pulls no optional dependency and nothing from the
persistence layer; compliance/session.py is the only module in the namespace
that imports it (jdlib.persistence.strategies.rls, jdlib.models.base), and
the CLI imports that module explicitly (§8.20). A posture report over a
configuration therefore needs no database.
Tests. 67 unit tests — 9 (test_compliance_controls.py), 12
(test_compliance_evidence.py), 31 (test_compliance_posture.py), 15
(tests/unit/test_cli_security.py) — plus 6 live-collector tests on real
PostgreSQL (tests/integration/test_compliance_live_collectors.py). The phase
document also records a bug the phase found in itself: the audit-posture rule
selected its configuration section by string, silently read the wrong section and
would have passed a deployment with the audit trail switched off; it now takes
the section name and uses getattr, with the regression test
(docs/security/compliance-evidence.md §4).
9. Runtime and Execution Model¶
9.1 Import-time behavior¶
import jdlibexecutes eager imports of exactly eight modules' symbols (src/jdlib/__init__.py:1-9) plus__all__and__version__.- The security namespace stays out of that set:
import jdlibleavesjdlib.securityand every subpackage under it out ofsys.modules, so nothing under it is loaded unless a consumer imports it explicitly (verified by execution, §26.6). - No lazy-import machinery (
__getattr__, import maps) exists. fastapi,typer, andpytestare not imported as a result ofimport jdlib— asserted in a subprocess bytests/unit/test_public_api.py:46-59and independently verified.jdlib.testing.pytest_pluginis loaded only by pytest through thepytest11entry point, never by package import.- Importing
jdlib.models.*/jdlib.control.*modules registers SQLAlchemy models onTenantBase.metadata/ControlBase.metadata. This is the only import-time side effect with architectural meaning: model discovery is import-driven (tenant_table_map()walksTenantRouted.__subclasses__(),persistence/models.py:11-29). jdlib.modelsandjdlib.controlare namespace-package portions (no__init__.py);jdlib.authz,jdlib.authn,jdlib.persistence,jdlib.tenancy, andjdlib.persistence.strategieshave empty initializers with no re-exports.
9.2 Initialization order in a real application¶
1. TenancyConfig() # env + validation, no I/O
2. create_async_engine(control_dsn) # CONSUMER creates engines; jdlib has no engine factory
3. async_sessionmaker(...) # consumer
4. DatabaseAuditSink(sessions) # audit
5. PrivilegeAuditAdapter(audit) # PrivilegeAudit port
6. MigrationRunner(control_dsn) # migrations
7. Isolation strategies # consumer supplies engines / secrets
8. TenantRegistry(...) # control-plane authority
9. ControlAccessReader / PermissionCatalog / ResourceTypeRegistry
10. ScopeResolver + DefaultPDP (+ AuthzCache) # request-scoped!
11. ContextFactory(...) # request-time context builder
12. build_authenticator(...) # authn composition
13. install(app, factory, chain, principal_provider, container)
Steps 1–2 are consumer responsibilities: the library contains no engine or
session factory. SharedSchemaStrategy(engine, …) and
SchemaPerTenantStrategy(engine, runner) take prebuilt engines;
DatabasePerTenantStrategy creates engines itself from resolved DSNs with no
pool arguments (create_async_engine(dsn), strategies/database.py:101-120).
9.3 Lazy initialization¶
| Object | Lazy? | Trigger |
|---|---|---|
| Database strategy tenant engine | yes | first session()/provision() for a handle |
| Database strategy admin engine | yes | first provision()/deprovision() |
ContextFactory clock |
yes | clock or SystemClock() at construction |
EnvelopeCodec key bytes |
no | unwrapped in the constructor |
JdlibCache/PDP cache |
created by consumer | README builds a fresh PDP per guard evaluation |
Alembic Config |
yes | each _config() call, in memory |
9.4 Runtime state and lifecycle¶
| Object | State | Reset/cleanup |
|---|---|---|
_CTX ContextVar |
per-task tenant context | reset by context_scope finally |
TenantSession |
wraps an AsyncSession; context stored in session.info["jdlib_context"] |
close(); __aexit__ closes only |
UnitOfWork |
ephemeral per async with |
commit/rollback + close |
ControlPlaneSession |
wraps an AsyncSession |
__aexit__ closes only (never commits automatically) |
DatabasePerTenantStrategy |
OrderedDict engine cache + admin engine |
dispose(); invalidate(handle) |
AuthzCache |
memo + permanent dirty flag | per request (construct per request) |
JwksCache |
JWKs by kid with TTL |
TTL/min_refresh_interval |
TenantRegistry |
none beyond injected collaborators | none needed |
9.5 Async, threading, and event loops¶
- The library is async-first (
AsyncSession,async defthroughout authn/authz/control/persistence request paths). - Exceptions that are synchronous by design:
MigrationRunner(Alembic + sync psycopg) and all CLI commands. - No threads, thread pools,
threading.Lock,asyncio.Lock,asyncio.Semaphore,multiprocessing, or background tasks exist in production source. The only concurrency primitives are: - the
contextvars.ContextVar(§9.4), - PostgreSQL transaction advisory locks in the registry and two
AccessControlmethods, asyncio.sleep()for RLS DDL retry backoff,- database constraints/conditional updates for race safety
(
INSERT … ON CONFLICT DO NOTHING RETURNING, conditional invitation claim, partial unique indexes). - Concurrency safety therefore rests on PostgreSQL plus task-local contexts, not on in-process locks.
9.6 Resource management rules¶
UnitOfWorkowns the tenant transaction: commit on success, rollback on exception, always close.ControlPlaneSessionnever commits implicitly — the caller commits.DatabaseAuditSinkcloses only sessions it created; caller-supplied sessions are flushed, never closed or committed.DatabasePerTenantStrategy.dispose()must be called during application shutdown to dispose tenant and admin engines.TenantSession.__aexit__closes but does not commit (session.py:274-290).
9.7 Runtime lifecycle diagram¶
sequenceDiagram
autonumber
participant P as Process
participant App as Consumer app
participant J as jdlib
participant DB as PostgreSQL
P->>App: import jdlib (eager, no optional deps)
App->>J: TenancyConfig()
App->>App: create_async_engine + async_sessionmaker
App->>J: DatabaseAuditSink / MigrationRunner / strategies / TenantRegistry
App->>J: install(app, ContextFactory, ResolverChain, principal_provider, container)
App->>DB: MigrationRunner.upgrade_control() [sync]
loop every HTTP request
App->>J: TenantMiddleware → authenticate → resolve → context_scope
J->>DB: entitlement + servability reads
J->>App: handler runs with TenantContext
App->>J: UnitOfWork → TenantSession → scoped SQL
J->>DB: tenant-scoped statements
end
P->>App: shutdown
App->>J: strategy.dispose() (engines), session factories disposed
10. Data Architecture¶
10.1 Two planes¶
erDiagram
subgraph CONTROL["jd_control (control plane — global metadata)"]
TENANTS["tenants (id, slug UNIQUE, name, status, parent_tenant_id)"]
PLACEMENTS["tenant_placements (tenant_id PK, strategy, target_handle, region)"]
MIGSTATE["tenant_migration_states (tenant_id PK, current_version, desired_version, status, last_error)"]
RELOC["tenant_relocations (tenant_id, id, phase, from/to …)"]
USERS["users (id, email CITEXT, status …)"]
IDLINKS["identity_links (issuer, subject) UNIQUE"]
OPERATORS["platform_operators (user_id PK, level)"]
SACCOUNTS["service_accounts (tenant_id, id)"]
APIKEYS["api_keys (tenant_id, id, key_prefix UNIQUE, hash, scopes)"]
MEMBERSHIPS["memberships (tenant_id, id, user_id)"]
ROLES["roles (tenant_id, id, name)"]
ROLEPERMS["role_permissions (tenant_id, role_id, permission_code)"]
BINDINGS["role_bindings (tenant_id, id, role_id, principal_*, scope_*)"]
GRANTS["resource_grants (tenant_id, id, resource_*, principal_*, permission_code)"]
INVITES["invitations (tenant_id, id, email, token_id UNIQUE, token_hash, status)"]
AUDIT["platform_audit_events (id, tenant_id?, actor_*, action, metadata, severity)"]
end
subgraph TENANT["tenant plane (unqualified, resolved by search_path)"]
ORGS["organizations (tenant_id, id)"]
TEAMS["teams (tenant_id, id, organization_id)"]
SETTINGS["tenant_settings (tenant_id, key)"]
TAUDIT["audit_events (tenant_id, id)"]
end
TENANTS ||--|| PLACEMENTS : "one placement"
TENANTS ||--o| MIGSTATE : "migration state"
TENANTS ||--o{ RELOC : "relocations"
TENANTS ||--o{ SACCOUNTS : "owns"
SACCOUNTS ||--o{ APIKEYS : "issues"
TENANTS ||--o{ MEMBERSHIPS : "has"
USERS ||--o{ MEMBERSHIPS : "joins"
USERS ||--o| IDLINKS : "links identity"
USERS ||--o| OPERATORS : "may be"
TENANTS ||--o{ ROLES : "defines"
ROLES ||--o{ ROLEPERMS : "grants"
ROLES ||--o{ BINDINGS : "bound at scope"
TENANTS ||--o{ GRANTS : "resource grants"
TENANTS ||--o{ INVITES : "invites"
TENANTS ||--o{ ORGS : "tenant org tree"
ORGS ||--o{ ORGS : "parent"
ORGS ||--o{ TEAMS : "contains"
TEAMS ||--o{ TAUDIT : "tenant rows"
SETTINGS ||--|| TAUDIT : "tenant rows"
There are no foreign keys between the control plane and the tenant plane
(lint rule L3 enforces this: FK {name} crosses planes).
10.2 Control-plane model table (16 tables + alembic_version)¶
| Table | PK | Notable columns / constraints |
|---|---|---|
jd_control.tenants |
id |
slug globally unique, status check, slug format check, nullable self-FK parent_tenant_id, suspension/archive timestamps |
jd_control.tenant_placements |
tenant_id |
one authoritative placement; strategy check; opaque target_handle; region |
jd_control.tenant_migration_states |
tenant_id |
current_version, desired_version, status, last_error |
jd_control.tenant_relocations |
(tenant_id, id) |
from/to strategy+handle+region, phase, phase timestamps, partial unique uq_tenant_relocations_active |
jd_control.users |
id |
CITEXT email with partial unique non-null index uq_users_email, display name, locale, status |
jd_control.identity_links |
id |
FK users.id, issuer, subject, unique (issuer, subject), login timestamps |
jd_control.platform_operators |
user_id |
FK users.id, level, granted-by UUID |
jd_control.service_accounts |
(tenant_id, id) |
tenant FK, name, status, unique (tenant_id, name) |
jd_control.api_keys |
(tenant_id, id) |
composite FK to service account, globally unique key_prefix, hash, nullable TEXT[] scopes, status, expiry/last-used/revocation |
jd_control.memberships |
(tenant_id, id) |
user FK, status, joined/suspended, unique (tenant_id, user_id) |
jd_control.roles |
(tenant_id, id) |
name, description, is_system, unique (tenant_id, name) |
jd_control.role_permissions |
(tenant_id, role_id, permission_code) |
composite FK to role with cascade; permission codes are strings, not FKs |
jd_control.role_bindings |
(tenant_id, id) |
role FK, polymorphic principal_type/principal_id, scope_type/scope_id, creator, UNIQUE NULLS NOT DISTINCT across tenant/role/principal/scope |
jd_control.resource_grants |
(tenant_id, id) |
resource_type/resource_id and principal_type/principal_id are logical (no FKs), permission_code, creator, uniqueness preventing duplicate exact grants |
jd_control.invitations |
(tenant_id, id) |
CITEXT email, scope, role FK, unique token_id, token_hash, status, expiry/accepted/revoked, partial unique pending invitation |
jd_control.platform_audit_events |
id |
nullable tenant_id without FK, actor, action, target, JSONB metadata, ip, request/correlation/trace ids, severity, occurred_at |
jd_control.alembic_version |
version_num |
created by Alembic with version_table_schema="jd_control" |
All control IDs default application-side to uuid7().
10.3 Tenant-plane models¶
TenantBase(DeclarativeBase) # metadata.schema = None (search_path-resolved)
├── TenantOwned # tenant_id: UUID PK, non-null, sorted first
│ └── TenantRouted # marker: eligible for scoping/RLS
├── Timestamped # created_at/updated_at timestamptz, server_default now()
├── SoftDelete # deleted_at timestamptz NULL
├── OwnableByOrg # organization_id UUID NULL
└── OwnableByTeam # team_id UUID NULL
Concrete shipped models:
Organization(TenantBase, TenantRouted, Timestamped, SoftDelete) # "organizations"
Team(TenantBase, TenantRouted, Timestamped, SoftDelete) # "teams"
TenantSetting(TenantBase, TenantRouted) # "tenant_settings"
AuditEvent(TenantBase, TenantRouted) # "audit_events"
ownership_constraints() (models/base.py:56-74) contributes:
CHECK (team_id IS NULL OR organization_id IS NOT NULL) -- ck_…_team_requires_org
FOREIGN KEY (tenant_id, organization_id) REFERENCES organizations(tenant_id, id) ON DELETE RESTRICT
FOREIGN KEY (tenant_id, team_id, organization_id) REFERENCES teams(tenant_id, id, organization_id) ON DELETE RESTRICT
TenantSetting is the only shipped model without Timestamped (it has
updated_at and updated_by only). AuditEvent.metadata_json maps to the SQL
column metadata.
10.4 Value objects (non-ORM)¶
| Type | Location | Fields | Validation |
|---|---|---|---|
Principal |
context.py:42-50 |
kind, id, user_id, tenant_id, api_key_id, scopes, operator_level |
none at construction |
PrivilegeContext |
context.py:53-60 |
kind, capability, justification, actor, issued_at, expires_at |
TTL/justification rules enforced by ContextFactory |
TenantContext |
context.py:63-72 |
see §7.1 | none at construction |
TenantRef / TenantRecord |
context.py:75-87 |
id/slug; record adds status, strategy, target_handle |
slug/UUID normalization in _slug_ref |
TenantContextEnvelope |
context.py:375-384 |
identity + request ids + issued_at + signature |
HMAC + TTL on decode |
Decision / MatchedRule |
authz/pdp.py:18-27 |
allowed, reason, matched |
— |
Binding |
authz/reader.py:16-19 |
role_id, scope_type, scope_id |
— |
ScopeRef / ResourceRef |
authz/scopes.py:15-24 |
type,id / resource_type,resource_id |
non-tenant ScopeRef without id raises ValueError in ScopeResolver |
ResourceTypeDefinition |
authz/resource_types.py:9-15 |
resource_type, model, tenant_owned, tenant_routed, permission_namespace |
validated at register |
Permission |
authz/permissions.py:9-14 |
code, namespace, allowed_scopes, description |
duplicate code → ValueError |
StrategyCapabilities |
strategies/base.py:13-16 |
supports_rls, supports_ddl |
— |
RequestInfo |
tenancy/resolution.py:14-21 |
headers, query, path, host, claims |
header keys must be lowercase (ASGI convention) |
CompiledRawSql |
persistence/rawsql.py:36-39 |
sql, binds |
— |
ParsedApiKey |
authn/apikey.py:23-26 |
key_prefix, secret |
token must have exactly 3 segments |
IdentityClaims |
authn/oidc.py:127-132 |
issuer, subject, email, email_verified |
sub mandatory |
LintIssue |
lint.py:21-25 |
rule, table, message |
— |
JdlibContainer |
integrations/fastapi.py:32-38 |
session_factory, uow_factory, enforcer |
frozen |
ContextConfig etc. |
config.py:10-78 |
pydantic models | see §12 |
10.5 Enumerations¶
| Enum | Location | Members |
|---|---|---|
TenantStatus |
control/enums.py |
provisioning, active, suspended, archived, deprovisioning, deleted |
PlacementStrategy |
control/enums.py |
shared, schema, database |
MigrationStatus |
control/enums.py |
pending, running, success, failed, blocked |
RelocationPhase |
control/enums.py |
pending, provisioning, copying, verifying, flipping, post_flip_verification, completed, failed, rolled_back |
OperatorLevel |
control/enums.py |
support, operator, admin |
PrincipalType |
control/enums.py |
user, service_account |
ScopeType |
control/enums.py |
tenant, org, team |
MembershipStatus |
control/enums.py |
active, suspended, deleted |
InvitationStatus |
control/enums.py |
pending, accepted, expired, revoked |
ApiKeyStatus |
control/enums.py |
active, revoked, expired |
ActorType |
control/enums.py |
user, service_account, platform_operator, system |
AuditSeverity |
control/enums.py |
info, warning, critical |
DeprovisionMode |
strategies/deprovision.py |
archive, purge, destroy |
PrincipalKind |
context.py:30-33 |
user, service_account, platform_operator |
PrivilegeKind |
context.py:36-39 |
operator, system, test |
ResolverName |
config.py:10-15 |
jwt_claim, subdomain, path, header, api_key |
RawSqlMode |
persistence/rawsql.py:31-33 |
tenant_bound, privileged |
All are StrEnum, so their values serialize directly to text in SQL
CHECK constraints (via sql_in).
10.6 Serialization formats¶
| Format | Where | Notes |
|---|---|---|
| SQLAlchemy Core/ORM | everywhere | primary persistence format |
| JSON (JSONB columns) | PlatformAuditEvent.metadata, AuditEvent.metadata, TenantSetting.value |
metadata is a reserved-ish name, hence the metadata_json attribute |
| Compact sorted JSON + HMAC | EnvelopeCodec |
sort_keys=True, separators=(",", ":"), unpadded base64url |
TEXT[] |
ApiKey.scopes |
nullable; NULL means "no narrowing" |
| CITEXT | users.email, invitations.email |
case-insensitive uniqueness via the citext extension created by the control baseline |
| INET | AuditEvent.ip, PlatformAuditEvent.ip |
nullable, no ORM helper is provided |
10.7 Naming conventions¶
Control plane (control/base.py:6-21):
{"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"}
Tenant plane (models/base.py:13-14) uses the same convention object with
unqualified tables. Partial unique indexes are declared explicitly in models
(e.g. uq_organizations_tenant_id_slug where deleted_at IS NULL).
11. Dependency Architecture¶
11.1 Runtime dependencies (all lower-bound-only, pyproject.toml:10-21)¶
| Package | Why | Where used | Coupling | Replaceable? |
|---|---|---|---|---|
sqlalchemy[asyncio]>=2.0.30 |
ORM/Core, async sessions, events, metadata, engines | everywhere (control/models.py, persistence/session.py, authz/*, control/purge.py, strategies) |
very tight (event API, with_loader_criteria, execution_options) |
no |
pydantic>=2.7 |
models, validators, SecretStr |
config.py, jdlib/testing typing |
moderate | no |
pydantic-settings>=2.3 |
env-driven TenancyConfig |
config.py:70-78 |
tight to the settings base class | partially (swap for a hand-written loader) |
alembic>=1.13 |
programmatic migrations, env.py, revisions |
migrations/runner.py, migrations/*/env.py |
moderate (Config, command.upgrade) |
theoretically, at high cost |
asyncpg>=0.29 |
asyncpg DSN/driver | used through DSNs, not imported | driver-level (SQLAlchemy dialect) | yes (any asyncpg-compatible DSN) |
psycopg[binary]>=3.2 |
sync driver for Alembic and tests | _sync_url normalization, integration fixtures |
driver-level | yes |
pglast>=6.0 |
real PostgreSQL AST for raw SQL proof | persistence/rawsql.py |
tight to parse_sql node shapes |
no (semantics depend on it) |
pyjwt[crypto]>=2.8 |
JWT signature/claims verification, JWK parsing | authn/oidc.py |
moderate | yes (another OIDC library) |
argon2-cffi>=23.1 |
Argon2id password hashing for API keys and invitation secrets | authn/apikey.py, authz/access.py |
tight to two helpers | yes |
httpx>=0.27 |
JWKS fetch | authn/wiring.py:20-27 |
light | yes |
Neither asyncpg nor psycopg is imported by any jdlib.* module; they are
referenced only through DSN strings.
11.2 Optional extras¶
| Extra | Contents | Enables |
|---|---|---|
fastapi |
fastapi>=0.111 |
jdlib.integrations.fastapi |
cli |
typer>=0.12 |
jdlib.integrations.cli |
http |
httpx>=0.27 |
the real HTTP transports of jdlib.security.authn.provider / jdlib.security.authz.cerbos (added by phase 2; httpx is already a core dependency, so the extra exists to let a consumer state the requirement explicitly) |
dev |
the above plus httpx>=0.27, pytest>=8.2, pytest-asyncio>=0.23, ruff>=0.5, mypy>=1.10, testcontainers[postgres]>=4.5 |
development, lint, type check, integration tests |
There are no test, lint, typing, or all extras.
11.3 Development/testing dependencies¶
pytest and pytest-asyncio (auto mode), testcontainers[postgres] (the
postgres:16 integration container), ruff, mypy --strict. Starlette is
imported directly by integrations/fastapi.py but is not declared in
pyproject.toml — it arrives transitively through FastAPI
(accuracy note: an undeclared direct import).
11.4 Dependency architecture diagram¶
graph TB
subgraph JD["jdlib"]
CORE["core modules"]
ADAPT["integrations + migrations"]
end
subgraph RT["runtime deps"]
SA["sqlalchemy[asyncio]"]
PD["pydantic / pydantic-settings"]
AL["alembic"]
PGLAST["pglast"]
JWT["pyjwt[crypto]"]
ARG["argon2-cffi"]
HTTPX["httpx"]
ASY["asyncpg / psycopg"]
end
subgraph OPT["optional extras"]
FA["fastapi (+starlette)"]
TYP["typer"]
end
subgraph DEV["dev extra"]
PT["pytest / pytest-asyncio"]
TC["testcontainers[postgres]"]
RF["ruff"]
MY["mypy"]
end
CORE --> SA
CORE --> PD
CORE --> PGLAST
CORE --> JWT
CORE --> ARG
CORE --> HTTPX
ADAPT --> AL
ADAPT --> SA
ADAPT --> FA
ADAPT --> TYP
CORE -.DSNs.-> ASY
ADAPT -.sync driver.-> ASY
PT --> TC
PT --> MY
PT --> RF
11.5 Behavior when a dependency is unavailable¶
- Optional integration missing →
ImportErroronly when importingjdlib.integrations.fastapiorjdlib.integrations.cli; the core package and its 13 exports remain importable (guarded bytests/unit/test_public_api.py:46-59). pytest_asynciomissing → the plugin substitutes a skipping synchronousjdlib_enginefixture (testing/pytest_plugin.py:53-59).- Core runtime dependency missing → immediate
ImportError; there are no lazy fallbacks. - No dependency is vendored, patched, or re-implemented.
12. Configuration Architecture¶
12.1 Configuration model¶
TenancyConfig(BaseSettings) with
SettingsConfigDict(env_prefix="JDLIB_", env_nested_delimiter="__")
(src/jdlib/config.py:70-78). There is no singleton, no get_settings(),
no .env file support, and no custom settings source.
TenancyConfig
├── control_dsn: SecretStr | None = None
├── context: ContextConfig
│ ├── signing_key: SecretStr (required)
│ ├── envelope_ttl: timedelta = 5 minutes
│ ├── operator_ttl: timedelta = 30 minutes
│ └── allow_test_contexts: bool = False
├── resolvers: ResolverConfig = ResolverConfig()
│ ├── order: list[ResolverName] = [JWT_CLAIM, SUBDOMAIN, PATH, HEADER, API_KEY]
│ ├── base_domain: str | None = None
│ ├── path_prefix: str = "/t/" (validator: must start with "/")
│ ├── header_name: str = "X-Tenant-Slug"
│ └── jwt_claim: str = "tid"
├── oidc: OidcConfig | None = None
│ ├── issuer: str (required when present)
│ ├── audience: str (required when present)
│ ├── algorithms: list[str] = ["RS256"]
│ ├── jwks_url: str | None = None
│ ├── jwks_ttl: timedelta = 1 hour
│ ├── leeway: timedelta = 60 seconds
│ └── email_trusted: bool = False
│ + resolved_jwks_url(): jwks_url or issuer.rstrip("/") + "/.well-known/jwks.json"
├── api_keys: ApiKeyConfig = ApiKeyConfig()
│ └── prefix: str = "jd"
└── rls: RlsConfig = RlsConfig()
├── enabled: bool = False
└── app_role: str | None = None (configured but unused by the strategy)
12.2 Environment variables (documented patterns)¶
| Variable | Field | Example |
|---|---|---|
JDLIB_CONTROL_DSN |
control_dsn |
postgresql+psycopg://user:pass@host:5432/app |
JDLIB_CONTEXT__SIGNING_KEY |
context.signing_key |
$(openssl rand -hex 32) |
JDLIB_RESOLVERS__ORDER |
resolvers.order |
list parsed by pydantic-settings |
JDLIB_RESOLVERS__BASE_DOMAIN |
resolvers.base_domain |
example.com |
JDLIB_OIDC__ISSUER |
oidc.issuer |
https://login.example.com/ |
JDLIB_OIDC__AUDIENCE |
oidc.audience |
my-api |
JDLIB_RLS__ENABLED, JDLIB_RLS__APP_ROLE |
rls.* |
true, jdlib_app |
JDLIB_TENANT_DB_<HANDLE> |
EnvSecretProvider (not TenancyConfig) |
handle db-acme ⇒ JDLIB_TENANT_DB_DB_ACME |
JDLIB_TEST_DATABASE_URL, JDLIB_TEST_SCHEMA |
pytest plugin | integration/plugin fixtures |
TESTCONTAINERS_HOST_OVERRIDE |
testcontainers (not read by jdlib) | 127.0.0.1 for Docker Desktop |
12.3 Configuration flow¶
process environment ──(JDLIB_ prefix, __ nesting)──► TenancyConfig (validated)
│
┌───────────────────────┬───────────────────────┼───────────────────────┐
▼ ▼ ▼ ▼
EnvelopeCodec ResolverConfig/ SharedSchemaStrategy API key prefix
(signing key, TTLs) build_chain() rls.enabled → per-tx (ApiKeyConfig)
set_config('app.tenant_id')
ContextFactory receives only config.context; strategies receive only the
pieces they need (SharedSchemaStrategy(rls=…), MigrationRunner(url),
ApiKeyAuthenticator(config=ApiKeyConfig…)).
12.4 Secret handling¶
SecretStrforcontrol_dsnandcontext.signing_key; never rendered inrepr(tests/unit/test_config.py:48-51).- Explicit unwrapping:
config.control_dsn.get_secret_value(),config.signing_key.get_secret_value().encode()(context.py:389-390). - API key and invitation secrets are stored as Argon2id hashes; the plaintext API key secret and invitation secret are never persisted.
- Envelope HMAC key, JWKS, and DSNs are never logged: the library emits no log
lines of its own, and the one structured record it builds
(
security_log_record) puts every value through the shared redaction path (§14.1). - Consumer responsibility: production secret storage/rotation,
SecretProviderimplementations, and key management for the envelope signing key.
13. Error and Exception Architecture¶
13.1 Hierarchy (28 subclasses of JdlibError in this module, 27 exported; 31 package-wide)¶
Exception
└── JdlibError
├── MissingTenantContext
├── TenantNotFound
├── TenantAccessDenied
├── TenantSuspended
├── TenantNotWritable
├── CrossTenantReferenceError
├── UnscopedBulkOperation
├── InvalidReference
├── InvalidResourcePermission
├── UnknownResourceType
├── UnknownPermission
├── RoleScopeMismatch
├── AuthenticationError
│ ├── InvalidToken
│ ├── ExpiredToken
│ └── UnknownPrincipal
├── AuthorizationError
│ ├── PermissionDenied
│ ├── AuthorizationUnavailable
│ └── RoleEscalationBlocked
├── StrategyCapabilityError
├── PoolCapacityError
├── TenantOperationInProgress
├── RelocationPhaseError
├── UnscopedRawSql
├── SchemaLintError
├── ProvisioningError
└── MigrationError
errors.__all__ lists 27 subclasses (not the base class). A 28th,
AuthorizationUnavailable (errors.py:81-91, added by the security programme's
error-response work), is defined alongside them but is not in
jdlib.errors.__all__; it is re-exported through jdlib.security
(security/errors.py:220), mapped to 503/retryable there, and raised by the
authorization enforcement point when a decision could not be taken (§8.22's
neighbours; docs/security/error-responses.md §4). Only TenantNotFound,
CrossTenantReferenceError, and SchemaLintError define custom constructors;
SchemaLintError.issues is the only custom attribute.
13.2 Where each error is raised¶
| Error | Raised by |
|---|---|
MissingTenantContext |
current_tenant, current_principal, current_privilege, scoped_key, TenantSession.__init__/events, TenantAuditRecorder.record, DefaultPDP (missing session) |
TenantNotFound |
ContextFactory._resolve_tenant, TenantMiddleware (unresolved ref), ContextPropagator, RegistryWriteFence |
TenantAccessDenied |
service-account tenant-pin mismatch, entitlement failure, job entitlement re-check |
TenantSuspended |
TenantRegistry.assert_servable failed predicate |
TenantNotWritable |
RegistryWriteFence.assert_writable |
CrossTenantReferenceError |
TenantSession.before_flush (stamp mismatch or tenant_id mutation) |
UnscopedBulkOperation |
do_orm_execute DML gate |
InvalidReference |
AccessControl scope/resource validation for deleted or cross-tenant targets |
InvalidResourcePermission, UnknownResourceType, UnknownPermission, RoleScopeMismatch |
ResourceTypeRegistry, PermissionCatalog, AccessControl scope compatibility |
AuthenticationError + subclasses |
TenantMiddleware (no credentials), EnvelopeCodec (bad/expired token), ContextFactory (inactive principal), ContextPropagator |
AuthorizationError + subclasses |
Enforcer.require denial (PermissionDenied), anti-escalation (RoleEscalationBlocked), missing ScopeLookup/ResourceLookup, operator/system/test context denials, privileged raw SQL without capability |
StrategyCapabilityError |
strategy missing engine/handle/secret/database name, unknown strategy in SessionRouter, verify_rls failures |
PoolCapacityError |
database strategy eviction with all engines checked out |
TenantOperationInProgress |
illegal lifecycle transition |
UnscopedRawSql |
RawSqlValidator and TenantSession.raw_sql path |
SchemaLintError |
assert_clean |
ProvisioningError |
registry create validation, migration schema validation, create normalization failures |
MigrationError |
migration failures surfaced by consumers/strategies |
RelocationPhaseError |
reserved for relocation flows (no shipped caller) |
13.3 Error-to-HTTP status mapping¶
src/jdlib/tenancy/middleware.py:26-42; ordered isinstance match, default 500:
| Exception (first match wins) | Status |
|---|---|
AuthenticationError (incl. InvalidToken, ExpiredToken, UnknownPrincipal) |
401 |
AuthorizationError (incl. PermissionDenied, RoleEscalationBlocked) |
403 |
TenantNotFound |
404 |
TenantAccessDenied |
404 |
TenantSuspended |
423 |
TenantNotWritable |
423 |
CrossTenantReferenceError |
409 |
InvalidReference |
409 |
any other JdlibError |
500 |
Notable consequences (verified): UnknownPermission, UnknownResourceType,
InvalidResourcePermission, RoleScopeMismatch, and MissingTenantContext
map to 500; no WWW-Authenticate header is added; error bodies are
{"error": "<ClassName>", "detail": "<message>"} from both the middleware and
the FastAPI handler.
graph TD
RAISE["JdlibError raised anywhere"] --> MAP["http_status_for(exc)"]
MAP --> S401{"AuthenticationError?"} -->|yes| H401["401"]
MAP --> S403{"AuthorizationError?"} -->|yes| H403["403"]
MAP --> S404{"TenantNotFound / TenantAccessDenied?"} -->|yes| H404["404"]
MAP --> S423{"TenantSuspended / TenantNotWritable?"} -->|yes| H423["423"]
MAP --> S409{"CrossTenantReferenceError / InvalidReference?"} -->|yes| H409["409"]
MAP --> H500["500"]
H401 --> BODY["JSON: {error, detail}"]
H403 --> BODY
H404 --> BODY
H423 --> BODY
H409 --> BODY
H500 --> BODY
13.4 Wrapping and propagation rules¶
- Library code never wraps driver errors into
JdlibError. SQLAlchemy / asyncpg exceptions propagate untouched (e.g.DatabasePerTenantStrategyre-raises the original migration exception after recording failed state). RawSqlValidatoris the one place that normalizes: compiler failures →UnscopedRawSql("raw SQL could not be compiled"), parser failures →UnscopedRawSql("raw SQL could not be parsed"), always chainedfromthe cause.SecretProvider.resolveKeyError→StrategyCapabilityErrorchainedfrom exc.PlacementStrategy(strategy)failures →ProvisioningErrorchainedfrom exc.- Only
JdlibErrorcrosses module boundaries by contract (README.md:389-390); adapters are expected to map driver exceptions. jdlib.integrations.cli._run/_run_syncnormalizeJdlibErrorand SQLAlchemy errors to stderr + exit 1; unexpected exceptions are not normalized.
14. Logging and Observability¶
14.1 What is implemented¶
| Mechanism | Status | Evidence |
|---|---|---|
| Audit events (platform) | Implemented | jd_control.platform_audit_events + DatabaseAuditSink |
| Audit events (tenant) | Implemented | audit_events + TenantAuditRecorder |
| Authentication failure records | Implemented | record_authn_failure → authn.failed |
| Privilege issuance records | Implemented | PrivilegeAuditAdapter → privilege.{operator,system,test} |
| Raw-SQL execution records (privileged only) | Implemented | RawSqlAuditor.record_raw_sql; ordinary tenant-bound raw SQL is not audited |
request_id |
Implemented | generated per request (uuid4().hex) |
correlation_id |
Implemented | from x-correlation-id, defaulting to request_id |
trace_id |
Implemented | strict W3C traceparent parse (jdlib.security.tracing.request_ids_from_headers), carried into logs and audit rows |
W3C traceparent propagation |
Implemented | parsed strictly and re-rendered by format_traceparent; spans are emitted through a caller-supplied OpenTelemetry-compatible tracer, with no SDK dependency in the library |
| Signed cross-hop context | Implemented | EnvelopeCodec (identity + request ids + signature) |
| Structured security logs | Implemented | jdlib.security.telemetry.security_log_record — fixed field set (LOG_FIELDS), redaction-enforced, identifiers filled from the ambient security context (phase 7; docs/security/observability-hardening.md §1) |
| Security metrics | Implemented | SecurityMetrics with a bounded label allow-list (BOUNDED_LABELS, FORBIDDEN_LABELS, KNOWN_METRICS); a smuggled identifier label is refused with MetricLabelError (phase 7, §2) |
| Telemetry failure isolation | Implemented | safe_emit swallows a telemetry error and reports it once, so a collector outage cannot disable security (phase 7, §3) |
| Security spans | Implemented | SecuritySpan / security_span — duck-typed against the OpenTelemetry API; span failures are swallowed in both directions (phase 7, §4) |
| Security audit events | Implemented | jdlib.security.audit vocabulary + envelope, with producers for authorization decisions, privilege issuance, raw SQL and control-plane actions; TenantAuditRecorder.record_security persists them in the caller's transaction (§8.13) |
14.2 What is explicitly absent (revised — phase 7)¶
The pre-hardening text of this section said "no logging at all" and "no metrics"; that is no longer true, and the phase 7 surface is Implemented above. What remains absent:
- No logger configuration and no handler. The library builds a structured
security record and leaves emission to the application's logging setup. The
only
logginguse insrc/jdlibis one lazygetLogger(...).warningcall insecurity/telemetry.py:264-266, which reports a swallowedsafe_emitfailure once. - No metrics backend and no SDK dependency — no Prometheus exposition, no OpenTelemetry SDK import (the span layer is duck-typed and the application supplies any tracer), no health endpoint.
- No log shipping, rotation, sampling or dashboards.
- No debug utilities.
Consequence: operational visibility is built by the consumer from the audit
tables, the structured security records, the metric surface and the CLI --json
output, joined by the request/correlation/trace ids in TenantContext.
14.3 Audit event vocabulary (platform)¶
| Producer | Action(s) |
|---|---|
TenantRegistry |
tenant.created, provisioning/migration success and failure, suspension/resume/archive events, deprovision counts |
AccessControl |
role.created, role.permissions_changed, role.deleted, binding.created, binding.removed, grant.created, grant.revoked, resource.owner_changed, team.moved, invitation.created, invitation.revoked, invitation.accepted |
| authn | authn.failed |
| privilege issuance | privilege.operator, privilege.system, privilege.test |
| raw SQL | consumer-supplied RawSqlAuditor (library ships only the no-op default) |
Tenant-plane AuditEvent.action is free-form; the shipped writers are
TenantAuditRecorder.record (which the consumer calls) and
TenantAuditRecorder.record_security, which writes the security-event envelope
into the same table (§8.13).
The security programme added a frozen vocabulary for security events:
jdlib.security.audit.SecurityEventType holds 36 members — the directive's 34
event names plus the two audit-access events (AUDIT_READ_DENIED,
AUDIT_EXPORTED) — grouped by category (authentication, authorization,
tenancy, privilege, security), with SecurityAuditEvent as the envelope and
producers that include the authorization enforcement point
(authorization_audit_observer), the privilege and raw-SQL adapters, and the
control-plane adapters (jdlib.security.audit.adapters). A test asserts the
vocabulary is exactly the intended list, because evidence mappings and
dashboards key off these strings; docs/security/audit-hardening.md is the
reference, including its integrity evaluation and recorded residual risk.
14.4 Correlation model¶
graph LR
IN["incoming headers"] --> RID["request_id = uuid4().hex"]
IN --> CID["correlation_id = x-correlation-id or request_id"]
IN --> TID["trace_id = traceparent[1] if 32 hex"]
RID --> TC["TenantContext"]
CID --> TC
TID --> TC
TC --> PA["platform_audit_events.request/correlation/trace"]
TC --> TA["audit_events.request/correlation/trace"]
TC --> ENV["EnvelopeCodec (signed, carried to workers)"]
DatabaseAuditSink fills the three columns from current_tenant() when a
context is active and leaves them NULL otherwise
(src/jdlib/control/audit.py:19-24); it never synthesizes ids.
15. Security Architecture¶
15.1 JDLib responsibility¶
| Control | Mechanism | Location |
|---|---|---|
| Fail-closed tenant access | MissingTenantContext everywhere; no default tenant |
context.py:113-117, session.py:265-272 |
| Read scoping | with_loader_criteria per trusted mapper, aliases included |
session.py:117-211 |
| Query-shape rejection | text, literal columns, bare tables, raw aliases, FromStatement, unscoped bulk DML |
session.py:109-224 |
| Write ownership | stamping, tenant_id immutability, cross-tenant rejection |
session.py:226-253 |
| Soft-delete protection | hard delete of SoftDelete models requires a privilege |
session.py:245-253 |
| Raw SQL proof | pglast AST, per-relation bound predicate, no OR/NOT/subquery/CTE | rawsql.py:179-256 |
| Privileged raw SQL | explicit capability (tenant.raw_sql / platform.raw_sql) + audit |
session.py:335-363 |
| Tenant pinning | API keys resolve to principal.tenant_id; equality enforced in ContextFactory and re-checked in jobs |
context.py:240-244, jobs.py:40-68 |
| Key-scope narrowing | principal.scopes intersected (never widened) |
pdp.py:130-138 |
| Anti-escalation | actor must already hold every permission it grants/binds; owner requires the full catalog |
access.py:236-464 |
| Last-owner protection | refuses removing the last owner binding (under advisory lock) |
access.py:466-513 |
| Invitation secrets | Argon2id hashes; lookup id separated from secret; single-use conditional claim | access.py:102-117, 920-1027 |
| Identity verification | OIDC signature/issuer/audience/algorithm/time; API-key prefix scoping; no token material in audit metadata | oidc.py:82-124, apikey.py:61-103 |
| Context signing | HMAC-SHA256 + TTL + future-timestamp rejection | context.py:387-438 |
| Privileged access | PrivilegeContext with capability, justification, expiry, and liveness enforced at every gate (§8.1); for_system/for_test gated by a private sentinel |
context.py:254-372, persistence/session.py:258-276 |
| Write admission | RegistryWriteFence blocks writes (and guarded reads) while unservable |
registry.py:636-652 |
| Schema invariants | 12 lint rules incl. cascade-path and cross-plane FK bans | lint.py:71-194 |
| RLS | ENABLE + FORCE + NOBYPASSRLS verification; transaction-local app.tenant_id; one shared catalogue inspection behind the guard and the evidence |
strategies/rls.py:54-197, shared.py:33-53 |
| Secret redaction | SecretStr config, Argon2 at rest, DSNs never logged; one canonical redaction pattern set behind error responses, audit metadata, telemetry and evidence |
config.py, authn/apikey.py, authz/access.py, security/redaction.py |
| Security audit trail | frozen SecurityEventType vocabulary + SecurityAuditEvent envelope, fail-closed attribution, clock-corroborated timestamps |
security/audit/, models/audit_recorder.py:66-117 |
| Compliance readiness | 19-control register mapped onto 7 framework families; posture over a declared configuration and observed evidence; posture exits non-zero on FAIL |
security/compliance/ (§8.22) |
| CI supply chain | pinned pip-audit / gitleaks (full history) / bandit / CycloneDX / build gates, every one build-failing | .github/workflows/ci.yml, docs/security/ci-security.md (§19.6) |
| Identifier safety | driver-level quoting for schema names; handle regexes; % doubling in Alembic URLs |
schema.py:16-23,67-84, database.py:18-43, runner.py:35-52 |
| Error non-disclosure | unentitled caller for a suspended tenant gets 404, not 423 | context.py:232-252 |
15.2 Consumer responsibility (jdlib does not provide these)¶
- Transport security — TLS termination, HSTS, secure cookies.
- Credential issuance — no user store, password auth, MFA, session issuance, refresh-token rotation.
- API key generation/rotation/revocation service — only parsing, hashing, and verification ship.
- Invitation delivery —
invitereturns a plaintext token; sending it is the consumer's job. - Rate limiting, quotas, bot protection.
- Secret storage/rotation — the consumer implements
SecretProvider, storesJDLIB_CONTEXT__SIGNING_KEY, and callsinvalidate(handle). - Operator authorization —
OperatorAuthorizeris a port with no shipped implementation (README example denies everything). - Membership management, user provisioning, role seeding — not shipped
(
SeedHookis a port; the README quickstart does not seed roles). - Scope/resource existence lookups —
ScopeLookup/ResourceLookupare ports; omitting them makes the relatedAccessControloperations fail closed. - Application resource-type registration — consumers must register their
own
ResourceTypeDefinitions. - DDL/DML hardening, database role separation beyond what RLS helpers and
the
NOBYPASSRLScheck enforce; PostgreSQL superuser/owner caveats are documented for RLS only. - Certification and audit results — the compliance surface maps technical
controls to framework references, collects evidence and reports readiness; it
is not an audit result, and installing the library does not make an
organisation certified or compliant (
docs/security/compliance-evidence.md,docs/compliance/README.md). - Backups, HA, connection proxying, observability pipelines.
15.3 Threat-oriented notes (evidence-based)¶
| Threat | Mitigation | Residual risk (documented) |
|---|---|---|
| Forged tenant context | context is only created by ContextFactory; cross-hop contexts must carry a valid HMAC envelope |
a consumer that constructs TenantContext by hand bypasses the factory (dataclass is public) |
| Cross-tenant raw SQL | AST proof per relation, no OR/NOT/subquery/CTE, no unknown relations | set-operation handling is implicit rather than explicit; platform.raw_sql skips tenant proof by design |
| Bulk DML escape | unscoped bulk rejected; update_where/delete_where append the predicate |
execution-option markers are trusted; a consumer that sets jdlib_scoped=True manually can bypass the predicate (convention-enforced, not capability-secure) |
| Stale authorization | request-scoped cache; invalidation before mutations; cross-hop re-authorization | AccessControl mutations authorize in a separate transaction from the PDP read; fastapi.require has a documented TOCTOU window vs the handler session |
| Privilege abuse | audited, TTL-bounded, capability-bearing; sentinel-gated system/test issuance | NullPlatformAudit raises (good), but a consumer that supplies an async no-op audit sink silently disables privilege records |
| RLS bypass | FORCE + NOBYPASSRLS + superuser/bypass checks in verify_rls |
the strategy does not enforce the runtime role; an engine connected as owner/superuser bypasses policies (tests/integration/test_rls_db.py covers these negatives) |
| Cross-tenant FK/reference | composite tenant FKs, RESTRICT, lint L2/L3/L15 |
ResourceGrant and RoleBinding principal/resource references are logical, validated only through injected lookups |
| Deleted-tenant data leakage | PlatformAuditEvent.tenant_id intentionally has no FK so audit history survives deletion; row data is purged by strategies/purger |
none observed |
15.4 Security-hardening programme (phases 1–11)¶
The security work after a30601d was delivered as a numbered programme. Each
phase has one document under docs/security/, and each document carries its own
Status: line, the tests behind its claims and the gaps it did not close. The
phase numbers are the programme's own; they are unrelated to the foundation
phases in docs/superpowers/plans/.
| Phase | Document | What it added |
|---|---|---|
| 1 | security-core.md |
jdlib.security — SecurityContext, SecurityConfig, the error model, the ports |
| 2 | authentication-hardening.md |
JWKS hardening, algorithm policy, JwtTokenValidator, outbound token provider, ZITADEL adapter, service identities |
| 3 | authorization-hardening.md |
decision model, PDP port, fail-closed Cerbos adapter, AuthorizationPEP |
| 4 | gateway-hardening.md |
gateway adapters and trust rules (Kong 3.6 verified; the Tyk live path is not verified) |
| 5 | error-responses.md |
stable codes, HTTP mapping, safe envelope, WWW-Authenticate, the 403 → 503 change |
| 6 | audit-hardening.md |
security event vocabulary, envelope, emitters, audit access and export, integrity evaluation |
| 7 | observability-hardening.md |
structured logs, bounded metrics, telemetry failure isolation, spans, collector verification |
| 8 | adversarial-review.md |
the adversarial pass, and the privilege-liveness and audit-corroboration fixes (§8.1, §8.13) |
| 9 | compliance-evidence.md |
the control register, evidence, posture and the security CLI (§8.22) |
| 10 | ci-security.md |
the security CI job's five gates (§19.6) |
| 11 | (this revision) | the documentation sweep: CHANGELOG.md, SECURITY.md, the indexes, and this synchronisation (§26.6) |
Verification status is per document and deliberately uneven: Kong 3.6 is the verified gateway while the Tyk plugin path is not infrastructure-verified (§19.7). Nothing in this set is a certification, an audit result, or a substitute for one.
16. Performance and Concurrency¶
16.1 Execution model¶
- Async-first:
AsyncSessioneverywhere on the request path. - Synchronous by design:
MigrationRunnerand the CLI (Alembic + psycopg). - No threads, no multiprocessing, no background tasks, no in-process locks (§9.5).
16.2 Connection pooling¶
| Placement | Pool model | Notes |
|---|---|---|
| shared | one engine/pool supplied by the consumer | no pool parameters set by jdlib |
| schema | one engine/pool; per-transaction SET search_path; RESET search_path on checkin |
search_path is re-asserted on every after_begin |
| database | one engine per handle, cached in an OrderedDict, capped by max_targets=32; idle-only LRU eviction; PoolCapacityError when all cached engines are checked out |
max_targets bounds engines, not connections |
Not present in source: no pool_size, max_overflow, pool_timeout,
pool_recycle, pool_pre_ping, poolclass, connect_args, or
statement_timeout configuration anywhere in production code. The only
timeout-like setting is RLS DDL's SET LOCAL lock_timeout = '5s'. The design
document sketches a PoolConfig (docs/jdlib/07-implementation-design.md:226-238)
but TenancyConfig has no pools field — configured nowhere, implemented
nowhere.
16.3 Concurrency model¶
| Concern | Mechanism | Location |
|---|---|---|
| Cross-request/task isolation | contextvars.ContextVar |
context.py:99-110 |
| Lifecycle serialization | pg_advisory_xact_lock(hashtextextended(key, 0)) on tenant / tenant-slug / tenant-handle |
registry.py:285-304 |
| Owner-count race | advisory lock inside unbind_role |
access.py:466-513 |
| Invitation re-issue/claim | advisory lock in invite; atomic conditional update in accept_invitation |
access.py:851, 920-1027 |
| Grant idempotency under races | INSERT … ON CONFLICT DO NOTHING RETURNING |
access.py:540-624 |
| One active relocation | partial unique index | control/models.py:107-145 |
| Engine-cache invalidation race | generation counter re-check after the eviction await |
strategies/database.py:101-153 |
| DDL lock contention | RLS DDL retry (3 attempts, 5 s lock timeout) | strategies/rls.py:78-105 |
Stateless vs. stateful classes:
| Kind | Examples |
|---|---|
| Stateless (safe to share) | PermissionCatalog, ResourceTypeRegistry, ScopeResolver, RawSqlValidator, MigrationRunner (after construction), DefaultPDP when paired with a per-request cache, ContextFactory, TenantRegistry, AccessControl (aside from injected collaborators) |
| Stateful per request/task | TenantContext (immutable), TenantSession (one session), AuthzCache (must be request-scoped), EnvelopeCodec (stateless apart from the key) |
| Stateful per process | DatabasePerTenantStrategy (engine caches), JwksCache (JWK TTL) |
| Not thread-safe / not lock-protected | DatabasePerTenantStrategy cache/invalidate/dispose/admin-engine lazy init; JwksCache concurrent refresh |
16.4 Caching and memoization¶
| Cache | Scope | Invalidation |
|---|---|---|
AuthzCache |
per request (by contract) | invalidate() is one-way and permanent for the instance |
| Database engine cache | per strategy instance | invalidate(handle), dispose(), idle LRU eviction |
| JWK cache | per JwksCache instance |
TTL or unknown-kid refresh with a 1 s floor |
| No ORM identity caching | — | expire_on_commit=False is the consumer's choice; the README sets it |
16.5 Known hot spots and costs (evidence-based)¶
DefaultPDP.evaluateperforms several control-plane queries per decision (principal, tenant status, membership, scope chain, bindings, role permissions, grants), mitigated only by the request-scoped memo.ScopeResolver.chainwalks parent organizations row by row (one query per ancestor, cycle-safe).RawSqlValidatorparses SQL with libpg_query on every raw call;TenantSessionrejectsTextClausein the ORM path, so this cost applies only to explicitraw_sqluse.install_rls/verify_rlsissue several statements per eligible table.tenant_table_map()re-walks subclasses and re-imports model modules on invocation (called byRawSqlValidatordefaults and the purger).uuid7()allocates 16 random bytes per id (os.urandom) — cheap but not monotonic within a millisecond.
17. Extensibility and Plugin Architecture¶
jdlib is extended almost entirely by implementing a Protocol and injecting
it. There is no plugin discovery, entry-point registry (besides pytest), or
hook system.
17.1 Extension points¶
| Interface | Abstract? | Built-in implementations | Consumer example |
|---|---|---|---|
Authenticator (authn/base.py:10-11) |
Protocol | OidcAuthenticator, ApiKeyAuthenticator, CompositeAuthenticator |
custom SAML/basic auth composed via CompositeAuthenticator |
TenantResolver (tenancy/resolution.py:24-27) |
Protocol | 5 resolvers | host/cookie/query-string tenant resolution |
IsolationStrategy (strategies/base.py:19-37) |
Protocol (not runtime_checkable) |
3 strategies | a Redis/Kafka-sharded or "schema with pooling proxy" placement |
AccessReader (authz/reader.py:28-52) |
Protocol | ControlAccessReader |
cache-backed or replicated authorization reads |
PolicyDecisionPoint (authz/pdp.py:30-39) |
Protocol | DefaultPDP |
OPA/Cedar/Casbin-backed PDP |
PlatformAudit (control/audit.py:27-37) |
Protocol | DatabaseAuditSink, CompositeAuditSink, RecordingPlatformAudit |
shipping audit to a SIEM/S3 |
PrivilegeAudit (context.py:155-164) |
Protocol | PrivilegeAuditAdapter |
custom privilege ledger |
WriteFence (persistence/models.py:32-33) |
Protocol | RegistryWriteFence, NullWriteFence |
an app-specific freeze/quiesce signal |
SecretProvider (persistence/secrets.py:7-8) |
Protocol | MemorySecretProvider, EnvSecretProvider |
Vault/AWS Secrets Manager lookup |
SeedHook (control/registry.py:72-78) |
Protocol | _NoopSeedHook default |
per-tenant role/resource seeding |
ScopeLookup / ResourceLookup (authz/access.py:53-82) |
Protocol callables | none | wire org/team/resource existence into AccessControl |
PrincipalDirectory / TenantDirectory / EntitlementChecker / ServabilityChecker / OperatorAuthorizer (context.py:135-152) |
Protocols | 3 adapters + TenantRegistry |
custom identity/entitlement/operator systems |
RawSqlAuditor (persistence/session.py:26-29) |
Protocol | _NoopRawSqlAuditor |
record all raw SQL (not just privileged) |
RawSqlValidator (persistence/session.py:272) |
concrete, injectable | default RawSqlValidator |
stricter/looser structural rules per app |
TenantBase mixins |
declarative | TenantOwned, TenantRouted, Timestamped, SoftDelete, OwnableByOrg, OwnableByTeam |
application models |
ownership_constraints() |
function | — | composes check + composite FKs |
PermissionCatalog.register |
registry method | 13 built-ins | application permission codes |
ResourceTypeRegistry.register |
registry method | empty | application resource types |
Permission |
dataclass | built-ins | custom namespace/codes |
ResolverChain / build_chain |
composition | 5 resolvers | custom resolution order |
CompositeAuditSink / CompositeAuthenticator |
composition | — | fan-out policies |
TenantMiddleware + install |
composition | — | adding consumer ASGI middleware around the pipeline |
Alembic env + emit_rls_policies |
integration hook | — | consumer migrations that create tables and then emit policies |
17.2 Practical examples¶
Custom authenticator:
from jdlib.authn.base import Authenticator
from jdlib.authn.composite import CompositeAuthenticator
from jdlib.context import Principal, PrincipalKind
from jdlib.tenancy.resolution import RequestInfo
class BasicAuthenticator:
def __init__(self, lookup): self._lookup = lookup
async def authenticate(self, request: RequestInfo) -> Principal | None:
user_id = self._lookup(request.headers.get("authorization", ""))
if user_id is None:
return None
return Principal(kind=PrincipalKind.USER, id=user_id, user_id=user_id)
authenticator = CompositeAuthenticator([BasicAuthenticator(lookup), oidc, api_key])
Custom isolation strategy:
IsolationStrategy (Protocol)
├── built-in: SharedSchemaStrategy
├── built-in: SchemaPerTenantStrategy
├── built-in: DatabasePerTenantStrategy
└── consumer: MyStrategy
├── name = "mystrategy"
├── capabilities = StrategyCapabilities(supports_rls=False, supports_ddl=True)
├── session(tenant) -> AsyncSession
├── provision(tenant) / migrate(tenant, runner) / deprovision(tenant, mode)
└── migration_schema(tenant) -> str
Register it by extending TenantPlacement/PlacementStrategy data and passing
it in the strategies mapping to TenantRegistry; SessionRouter will
dispatch by tenant.strategy (README.md:378-380).
Custom PDP:
class ExternalPDP: # satisfies PolicyDecisionPoint
def __init__(self, client): self._client = client
async def evaluate(self, principal, permission, target, context, *, session) -> Decision:
allowed = await self._client.check(
subject=str(principal.id), action=permission,
tenant=str(context.tenant_id), target=target)
return Decision(allowed=allowed, reason="allowed" if allowed else "no_allow", matched=())
enforcer = Enforcer(ExternalPDP(client))
Custom audit fan-out and privilege bridging:
audit = CompositeAuditSink(DatabaseAuditSink(sessions), ShipToSiem(...))
context_factory = ContextFactory(..., audit=PrivilegeAuditAdapter(audit))
Consumer models:
from sqlalchemy.orm import Mapped, mapped_column
from jdlib.models.base import (
OwnableByOrg, OwnableByTeam, SoftDelete, TenantBase, TenantRouted, Timestamped,
ownership_constraints,
)
class Invoice(TenantBase, TenantRouted, Timestamped, SoftDelete,
OwnableByOrg, OwnableByTeam):
__tablename__ = "invoices"
__table_args__ = ownership_constraints()
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid7)
number: Mapped[str]
(tests/support/invoice.py:20-52 is the executable reference implementation of
this pattern, including the composite FK to a LineItem.)
17.3 Extension limitations¶
- Protocols are not
@runtime_checkable, soisinstancechecks against them are not available. IsolationStrategyhas nohealth_check/invalidate/disposein the protocol, so strategies with caches (like the database strategy) expose extra methods the registry/router never call.- No middleware/hook pipeline around the request path other than composing
TenantMiddlewarewith other ASGI middleware. - No plugin entry points other than the
pytest11plugin.
18. Packaging and Distribution¶
18.1 Build configuration¶
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "jdlib"
version = "0.1.0"
description = "Reusable multi-tenant foundation"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.13", "argon2-cffi>=23.1", "asyncpg>=0.29", "httpx>=0.27",
"pglast>=6.0", "psycopg[binary]>=3.2", "pydantic>=2.7",
"pydantic-settings>=2.3", "pyjwt[crypto]>=2.8", "sqlalchemy[asyncio]>=2.0.30",
]
[project.optional-dependencies]
dev = ["fastapi>=0.111", "httpx>=0.27", "mypy>=1.10", "pytest>=8.2",
"pytest-asyncio>=0.23", "ruff>=0.5", "testcontainers[postgres]>=4.5",
"typer>=0.12"]
fastapi = ["fastapi>=0.111"]
cli = ["typer>=0.12"]
http = ["httpx>=0.27"]
[project.entry-points.pytest11]
jdlib = "jdlib.testing.pytest_plugin"
[project.scripts] # working tree, uncommitted at 6125558
jdlib = "jdlib.integrations.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/jdlib"]
Source: pyproject.toml:1-42 (64 lines at 6125558; 67 in the working tree).
18.2 Packaging facts and gaps¶
| Aspect | Value | Note |
|---|---|---|
| Backend | hatchling | no version pin |
| Wheel contents | src/jdlib only |
tests/, docs/, .github/ are not packaged |
py.typed |
present (src/jdlib/py.typed, empty) |
PEP 561; no explicit package-data rule (hatchling includes package files) |
| sdist target | not configured | hatchling's default applies |
| Entry points | pytest11 at 6125558, plus [project.scripts] jdlib = "jdlib.integrations.cli:main" from 2bc22e3 |
a jdlib console script ships with the package (typer via the cli extra); the CI smoke step runs it |
| Version source | hard-coded in pyproject.toml:7 and __init__.py:27 |
duplicated, not dynamic |
| Optional extras | fastapi, cli, http |
http (httpx>=0.27) was added by phase 2; httpx is also a core dependency |
readme, license, authors, classifiers, keywords, urls |
absent | a LICENSE file does not exist either |
| Lock files | none (uv.lock, poetry.lock, requirements*.txt all absent) |
reproducibility is left to the consumer |
| Dependency bounds | lower bounds only | no upper bounds; resolver picks the latest compatible |
18.3 Tool configuration¶
| Tool | Configuration |
|---|---|
| pytest | asyncio_mode = "auto", testpaths = ["tests"], markers unit / integration / infra / e2e (pyproject.toml:44-52) |
| ruff | line-length = 100, target-version = "py312", select = ["E","F","I","UP","B","SIM"] (pyproject.toml:54-59) |
| mypy | python_version = "3.12", strict = true, files = ["src/jdlib"] (pyproject.toml:61-64) |
| coverage / tox / nox / black / isort / pyright / pylint | absent; bandit is not configured in pyproject.toml either — it runs only in CI (§19.6) |
18.4 Installation¶
pip install jdlib
pip install "jdlib[fastapi]" # FastAPI integration
pip install "jdlib[cli]" # Typer CLI
pip install -e ".[dev]" # from a checkout: tests, lint, type check
18.5 Distribution workflow¶
Not present. There is no publish/release workflow and no twine/uv-publish
configuration. The CI security job now builds a wheel and smoke-installs it
into a clean environment (§19.6), but nothing tags or uploads a release, and
there is no PyPI metadata beyond the name/version. Distribution status on PyPI
is Unknown / Requires Confirmation (the README documents
pip install jdlib, but no publishing automation exists in the repository).
18.6 Versioning policy¶
Version 0.1.0; the working tree now carries a CHANGELOG.md (phase 11) that
records the security-hardening programme as unreleased changes, but there
are no tags documented in-repo, no published release, and no deprecation policy.
Compatibility guarantees: Unknown / Requires Confirmation. The design docs
describe a frozen public API, which is the only observable compatibility
contract
(docs/superpowers/plans/2026-09-21-jdlib-integrations-phase-6.md:364-382).
18.7 Packaging flow¶
graph LR
SRC["src/jdlib (101 .py + 2 .mako + py.typed)"] --> HATCH["hatchling build backend"]
PYPROJ["pyproject.toml<br/>metadata + deps + extras"] --> HATCH
HATCH --> WHEEL["wheel: jdlib/… (py.typed included)"]
HATCH --> SDIST["sdist (hatchling default)"]
WHEEL --> INST["pip install jdlib[fastapi|cli]"]
SDIST --> INST
INST --> EP["pytest11 entry point auto-loads jdlib.testing.pytest_plugin"]
WHEEL -.not included.-> T["tests/ docs/ .github/"]
19. Testing Architecture¶
19.1 Suite shape¶
At a30601d (the pre-hardening baseline):
| Metric | Value |
|---|---|
| Total passing tests | 568 (pytest -q -W error, fresh PostgreSQL 16) |
| Test-function definitions | 527 (268 unit + 259 integration) |
| Unit files | 36 modules, 4,587 lines |
| Integration files | 26 test modules + conftest.py + rls_support.py + strategy_support.py, 8,258 lines |
| Support (fakes/fixtures) | 3 files, 178 lines |
| Parametrized tests | 7 functions; a 4-case matrix fixture yields 24 nominal cases from 6 test definitions |
| Warnings policy | -W error in the verification run; any warning is a failure |
At 4d113e5, re-verified for this revision (§26.6):
| Metric | Value |
|---|---|
| Total passing tests | 1,217 (pytest -q -W error, real PostgreSQL 16 via testcontainers, 71.48 s) |
| Skipped | 6, all in tests/infra/test_tyk_infra.py (§19.7) |
| Test-function definitions | 1,021 (679 unit + 307 integration + 35 infra) |
| Unit | 66 modules (37 top-level + 29 under unit/security/), 11,650 lines |
| Integration | 33 test modules + conftest.py + rls_support.py + strategy_support.py, 9,824 lines |
| Infra | 5 modules, 1,589 lines — live Kong, Cerbos, ZITADEL and OpenTelemetry harnesses |
| Support (fakes/fixtures) | 3 files, 178 lines |
| Warnings policy | -W error in the verification run; any warning is a failure |
Without the JDLIB_INFRA_* settings the infrastructure classes skip themselves
and the same suite reports 1,192 passed, 31 skipped — the 31 skips are Kong (9),
Cerbos (12), ZITADEL (4) and Tyk (6), each naming the variable it wants. The
phase-9 certification gate recorded 1,215 passed / 6 skipped; the two tests for
the console script (§18.2) landed in the working tree afterwards, which is the
difference to the 1,217 above.
19.2 Architecture of the suite¶
graph TB
TS["tests/"] --> U["unit/ (679 defs)"]
TS --> I["integration/ (307 defs)"]
TS --> N["infra/ (35 defs)"]
TS --> S["support/ (fakes + consumer Invoice model)"]
U --> U1["enforcement units: session, rawsql, pdp, guards, context, envelopes, strategies, lint, config, errors, uuid"]
U --> U2["unit/security/: authn, authz, gateway, audit, telemetry, tracing, replies, compliance"]
I --> I1["real PostgreSQL 16 via testcontainers or JDLIB_TEST_DATABASE_URL"]
I1 --> I2["conftest: postgres_url, control_engine, control_metadata, async_engine, session_factory, control_session_factory"]
I2 --> I3["registry/lifecycle, access control, authn, RLS, purge, migrations, strategy matrix, FastAPI/CLI e2e, security-audit persistence, compliance collectors"]
N --> N1["live Kong 3.6, Cerbos, ZITADEL, OpenTelemetry collector; Tyk harness skipped without a coprocess runtime"]
S --> S1["FrozenClock, StaticPrincipals, DictTenants, ServabilityStub, EntitlementsStub, ServiceAccountEntitlementsStub, OperatorsStub, RecordingPrivilegeAudit, Invoice/LineItem"]
19.3 Fixtures and test doubles¶
tests/integration/conftest.py:14-84 provides: postgres_url (session;
JDLIB_TEST_DATABASE_URL or a postgres:16 testcontainer), control_engine
(creates jd_control + jt_test schemas and enables citext),
control_metadata, async_engine (function; search_path=jt_test, tenant
tables created), session_factory, control_session_factory. Additional
fixtures: matrix (4 strategies), rls_app_role (a NOBYPASSRLS role),
rls_engine, tenant_metadata, rls_app_engine, make_strategy.
tests/support/context_fakes.py supplies the port doubles listed in §7.1.
tests/support/invoice.py is a realistic consumer model (composite FK,
partial unique index, ownership constraints) used as a schema fixture.
19.4 Which components are covered¶
| Component | Representative tests |
|---|---|
| Session scoping (core contract) | integration/test_session_isolation.py (39 defs: scoped reads, entity-less counts, table/text/literal/alias/FromStatement rejection, joins, cross-tenant get, stamping, immutability, unscoped bulk, soft/hard delete) |
| Strategy independence | integration/test_strategy_matrix.py (identical assertions for shared, shared-rls, schema, database) |
| Raw SQL proof | unit/test_rawsql.py (24 defs) + unit/test_session.py |
| Authorization | unit/test_pdp.py, unit/test_guards.py, unit/test_permissions.py, unit/test_resource_types.py, integration/test_access_*.py, integration/test_role_action_matrix.py, integration/test_scopes.py, integration/test_pdp_control_reader.py |
| Authentication | unit/test_oidc_*.py, unit/test_apikey_parser.py, integration/test_apikey_auth.py, integration/test_oidc_linking.py, integration/test_authn_composite.py, integration/test_auth_wiring.py |
| Lifecycle | integration/test_registry.py (46 defs incl. races), test_registry_models.py, test_tenant_purge.py |
| Strategies/RLS | integration/test_schema_strategy_db.py, test_database_strategy_db.py, test_rls_db.py, unit/test_strategy_lifecycle.py, unit/test_database_strategy.py |
| Migrations | integration/test_migrations.py |
| Context/envelopes/jobs | unit/test_context_factory.py, test_context_values.py, test_envelope.py, test_jobs.py, integration/test_auth_wiring.py |
| HTTP surface | unit/test_middleware.py, integration/test_fastapi_integration.py |
| Metadata invariants | unit/test_lint.py (15 defs, one per implemented rule) |
| Public API boundary | unit/test_public_api.py (exact __all__, version, no optional imports) |
| Audit | unit/test_platform_audit_sinks.py, integration/test_audit_sinks.py |
| CLI | unit/test_cli.py, integration/test_cli_integration.py |
| Test kit | unit/test_testing_kit.py |
| Security core | unit/security/test_security_context.py, test_security_config.py, test_security_errors.py, test_security_interfaces.py, test_security_public_surface.py (exact security surface), test_error_responses.py |
| Security authn | unit/security/test_jwt_validator.py, test_jwks_hardening.py, test_token_provider.py, test_zitadel_adapter.py, test_service_identity.py; integration/test_authn_composite.py, test_authn_http_integration.py |
| Security authz | unit/security/test_authz_decision.py, test_authz_pep.py, test_cerbos_pdp.py; tests/infra/test_cerbos_infra.py (12 defs) |
| Gateway | unit/security/test_gateway_adapters.py; tests/infra/test_kong_infra.py (9 defs) |
| Security audit | unit/security/test_audit_events.py, test_audit_emitters.py, test_audit_access_export.py, test_audit_adapters.py, test_audit_control_adapters.py; integration/test_security_audit_persistence.py, test_audit_isolation_export.py, test_audit_raw_sql_refusal.py |
| Telemetry and tracing | unit/security/test_telemetry.py, test_tracing.py; tests/infra/test_otel_infra.py (4 defs) |
| Compliance and the security CLI | unit/security/test_compliance_controls.py (9), test_compliance_evidence.py (12), test_compliance_posture.py (31); unit/test_cli_security.py (15); integration/test_compliance_live_collectors.py (6, real PostgreSQL) |
| Adversarial regressions | unit/security/test_adversarial.py, test_adversarial_privilege.py, test_adversarial_service_identity.py, test_adversarial_audit_integrity.py |
| Live infrastructure | tests/infra/ — Kong 3.6 (9), Cerbos (12), ZITADEL (4), OpenTelemetry collector (4), Tyk (6; skipped without a JavaScript/coprocess runtime) |
19.5 Concurrency and security regression tests¶
Provisioning serialization, distinct-tenant parallelism, deprovision
state-change and child-insert races (test_registry.py:493-529, 826-888),
concurrent idempotent grant insert, concurrent invitation claim, owner-count
race under the advisory lock, OIDC identity-link and deleted-user races,
database-pool invalidation during eviction, and real checked-out-pool cap
behavior. Security regressions include all eight historical cross-tenant read
paths, raw-DML tenant_id mutation, the API-key scope-escalation fix, the
non-member 423 existence leak, un-audited privileged contexts, and the
API-key tenant-pin bypass.
19.6 CI¶
.github/workflows/ci.yml defines two jobs, both on ubuntu-latest and
both build-failing:
test(pre-existing):actions/checkout@v4→actions/setup-python@v5(3.12) →pip install -e ".[dev]"→ruff check .→mypy src/jdlib→pytest -q. No matrix, no PostgreSQL service; integration tests rely on the runner's Docker for testcontainers.security(phase 10;docs/security/ci-security.md): five gates, each of which fails the build on its own —pip-auditagainst the installed environment;gitleaksover the full history, so an old leak is still a finding;bandit -lloversrc/jdlib; a CycloneDX SBOM of the installed environment written as a build artefact; and a build + clean-install smoke test (wheel built, installed into a fresh environment, thenimport jdlib,jdlib.securityandjdlib.security.compliancewith the register size printed, so a packaging mistake cannot ship silently). Since2bc22e3the smoke test also runs the installedjdlibconsole script —jdlib security posture --json— and checks that the report parses (§18.2).
The gates are deterministic and deliberately shallow: they are a floor under review, not a substitute for it, and the phase document records what each one does and does not catch.
19.7 Known test gaps¶
- No coverage tooling or threshold is configured; coverage is Unknown / Requires Confirmation.
tests/isolation/(proposed indocs/jdlib/03-isolation-invariants.md:263-293) does not exist; isolation coverage is distributed across the suites above.- Relocation phases have schema support and lint/servability integration but no executed relocation workflow, because relocation is not implemented (§23).
examples/is absent, so no example-driven smoke test exists.- The Tyk plugin path is not infrastructure-verified. All six tests in
tests/infra/test_tyk_infra.pyskip: the stock Tyk 5.3.1 OSS image ships no JavaScript/coprocess runtime, so the adapter's plugin path cannot be exercised against a live gateway here. The adapter itself is unit-tested (27 tests) and the harness ships (tests/infra/tyk/,tests/infra/docker-compose.yml), but a Tyk deployment runs on code no infrastructure test has touched; Kong 3.6 is the verified gateway (§15.4).docs/security/gateway-hardening.md§6 records the blocker and the secondary route-matching observation. tests/infrais environment-gated. The infrastructure classes skip unless their endpoints are supplied —JDLIB_INFRA_KONG_PROXY_URL/_SECRET/_CLIENT_KEY,JDLIB_INFRA_CERBOS_URL,JDLIB_INFRA_ZITADEL_ISSUER, andJDLIB_INFRA_TYK_URL/_SECRET/_API_KEYfor Tyk — and each skip names the variable it wants. The suite's totals therefore depend on what is running (§19.1, §26.6).- The CI
securityjob's gates do not execute the library. They are supply chain and packaging checks (§19.6); a green security job says nothing about runtime behaviour, and the first run on a real GitHub runner is still outstanding (docs/security/ci-security.md§9).
20. Consumer Integration¶
20.1 Integration shape¶
graph TB
subgraph App["Consumer application"]
CFG["TenancyConfig (JDLIB_* env)"]
ENG["create_async_engine + async_sessionmaker"]
WIR["Wiring module: registry, strategies, PDP, adapters"]
APP["FastAPI app + routes"]
JOB["Background job handlers"]
end
subgraph JDL["jdlib"]
MW["TenantMiddleware"]
CF["ContextFactory"]
REPO["TenantRepository / UnitOfWork"]
AC["AccessControl"]
REG["TenantRegistry"]
CLI["Typer CLI (own entry point)"]
end
CFG --> ENG
ENG --> WIR
WIR --> CF
WIR --> REPO
WIR --> AC
WIR --> REG
APP --> MW
MW --> CF
APP --> REPO
JOB --> ENV["EnvelopeCodec / tenant_job"]
CLI -.operators.-> REG
20.2 Minimal lifecycle¶
- Install
jdlib(+ extras). - Configure via
JDLIB_*env orTenancyConfig(...); the context signing key is mandatory. - Create engines — the consumer owns engine/pool configuration; jdlib has no factory.
- Migrate the control plane:
MigrationRunner(url).upgrade_control()orjdlib db upgrade-control. - Provision tenants with
TenantRegistry.create(...)+.provision(id)(orjdlib tenant create/jdlib tenant provision). - Wire
ContextFactory, resolver chain, authenticator, container, andinstall(app, …). - Access data through
UnitOfWork+TenantRepository; guard routes withrequire(...). - Operate through the CLI (own entry point) and
AccessControl. - Propagate context to workers with
EnvelopeCodec.encode/tenant_job(propagator). - Test with
jdlib.testingassertions and thejdlib_*fixtures. - Shut down by disposing engines and calling
strategy.dispose().
For consumers that need the security surfaces, the programme adds a wiring step
between (4) and (7): build a SecurityConfig, install an Authenticator (OIDC
or API key), a PolicyDecisionPoint, and — if an edge gateway asserts identity —
a gateway adapter, then start emitting security audit events and telemetry.
docs/security/README.md indexes the phase documents (each with its own status,
tests and gaps), SECURITY.md states what the library owns versus what a
deployment owes, and jdlib security posture reports readiness against the
control register (§8.22, §21.8).
20.3 Recommended patterns (from README/design docs)¶
- Request-scoped PDP. Never build one
DefaultPDP/AuthzCacheat import time and share it across requests; build a fresh PDP per guard evaluation (README.md:220-222, example atREADME.md:150-159). - Hybrid placement routing. In
uow_factory, branch oncontext.strategyviaSessionRouterso application code above the session factory never changes (README.md:216-218, 267-269). - Privilege audit bridging. Pass
PrivilegeAuditAdapter(audit)toContextFactory, not the rawPlatformAudit(README.md:233-235). - Operator denial by default. The README's
OperatorAuthorizerreturnsFalse— privileged access stays off until a real directory is wired. - CLI secret limitation. Provisioning
databaseplacement needs the database strategy plus aSecretProviderregistered programmatically (README.md:362-365). - RLS role requirements. Pass
rls=config.rlstoSharedSchemaStrategy; the runtime role must beNOBYPASSRLS, not the owner, not a superuser (README.md:300-318).
20.4 Error handling in a consumer app¶
JdlibError inside a route/dependency → FastAPI _handle_jdlib_error → mapped JSON
JdlibError before the route (authn, resolution, context) → TenantMiddleware → mapped JSON
Driver/SQLAlchemy errors → propagate (consumer must map them)
Guarded denial → PermissionDenied → 403
Non-servable tenant → TenantSuspended/TenantNotWritable → 423
Unknown principal → UnknownPrincipal → 401
20.5 Cleanup responsibilities¶
| Resource | Owner | Cleanup |
|---|---|---|
| tenant session/transaction | UnitOfWork |
automatic commit/rollback/close |
| control session | ControlPlaneSession |
caller closes (and commits) |
| tenant/admin engines (database strategy) | strategy | await strategy.dispose() |
| consumer engines | consumer | engine.dispose() |
| authz cache | consumer | per-request instance; nothing to clean |
| JWK cache | consumer | JwksCache per process; TTL-based |
21. End-to-End Workflows¶
21.1 Authenticated HTTP request → scoped read¶
- Start: client sends
Authorization: Bearer <oidc|jd.key.secret>plusX-Tenant-Slug: acme. - Public API:
TenantMiddleware.__call__(installed byjdlib.integrations.fastapi.install). - Validation:
_request_info(lowercased headers, parsed query, empty claims);CompositeAuthenticator.authenticate;ResolverChain.resolve→HeaderResolver→TenantRef(slug="acme"). - Internal processing:
ContextFactory.for_principalperforms principal-active → tenant lookup → service-account pin → entitlement → servability checks and builds the context;context_scopeinstalls it. - Collaborators: authenticator,
AccessReader(via adapters),TenantRegistry.find,RegistryWriteFencepredicate. - External calls: 2–4 control-plane queries (identity, tenants, membership).
- Transformation:
TenantRef→TenantRecord→TenantContext. - Data access: handler opens
UnitOfWork;TenantRepository.list()triggerswith_loader_criteria; SQL includesWHERE tenant_id = $1. - Error handling: any
JdlibErrorbefore the route is mapped to JSON; a denial inside a guard raisesPermissionDenied→ 403. - Result: JSON response; context reset on the way out.
sequenceDiagram
autonumber
participant C as Client
participant MW as TenantMiddleware
participant AU as CompositeAuthenticator
participant CF as ContextFactory
participant AR as ControlAccessReader
participant REG as TenantRegistry
participant H as Handler
participant U as UnitOfWork/TenantSession
participant DB as PostgreSQL
C->>MW: GET /invoices (Bearer …, X-Tenant-Slug: acme)
MW->>AU: authenticate(RequestInfo)
AU->>DB: api_keys/users/identity_links
AU-->>MW: Principal(USER)
MW->>MW: ResolverChain → TenantRef(slug=acme)
MW->>CF: for_principal(principal, ref, request_id, correlation_id, trace_id)
CF->>AR: principal_active / membership_active
CF->>REG: find(TenantRef) + assert_servable
REG->>DB: tenants + migration_state + relocations
CF-->>MW: TenantContext
MW->>H: with context_scope(context)
H->>U: async with UnitOfWork(...) as session
U->>DB: fence.assert_writable (control reads)
H->>U: TenantRepository(session, Invoice).list()
U->>DB: SELECT … FROM invoices WHERE tenant_id = $1
DB-->>H: rows
H-->>C: 200 JSON
U->>DB: COMMIT
MW->>MW: context reset
21.2 Tenant provisioning (schema placement)¶
- Start:
registry.create(slug="acme", name="Acme Inc", strategy=PlacementStrategy.SCHEMA, target_handle="t_acme"). - Validation: strategy normalization; handle regex
^t_[a-z0-9][a-z0-9_]{0,50}$; slug advisory lock; idempotency check (an existing slug with identical strategy/handle/region returns the existing tenant); handle advisory lock rejects a handle already used by another schema tenant. - Internal processing: insert
Tenant,TenantPlacement,TenantMigrationState(status=pending, desired_version="0001_tenant_baseline"). - Collaborators:
ControlPlaneSession,PlatformAudit. - External call: one control transaction; audit row flushed in the same
transaction (
session=…). - Then:
registry.provision(tenant.id)→ tenant advisory lock →SchemaPerTenantStrategy.provision(CREATE SCHEMA IF NOT EXISTS t_acme) → migration staterunning→strategy.migrate→MigrationRunner.upgrade_tenant("t_acme")→ optionalSeedHook.seed→tenant_version("t_acme") == "0001_tenant_baseline"→ statusactive→ audit → commit. - Transformation:
TenantRecordcarriesstrategy+target_handle;SessionRoutermaps that to sessions later. - Error handling: any failure commits
migration_state=failed+last_error, auditstenant.migration.failed, and re-raises; the tenant staysprovisioningand therefore unservable. - Result: an active, servable tenant with its schema migrated.
21.3 Guarded write (FastAPI require + get_uow)¶
- Start:
Depends(require("resource:create"))on a route. - Validation:
requireopens its ownuow_factory(); theRegistryWriteFenceruns (so a relocating tenant yields 423 even for a guarded read); the target defaults totenant_target(). - Internal processing:
Enforcer.require→DefaultPDP.evaluate: catalog validation → principal → tenant → membership → scope chain → bindings → role permissions → key-scope intersection. - Collaborators:
ControlAccessReader,ScopeResolver,PermissionCatalog,AuthzCache. - External calls: the control-plane reads above (memoized per request).
- Transformation:
Decision(allowed, reason, matched); denial raisesPermissionDenied(f"resource:create: {reason}"). - Handler:
get_uowyields a newUnitOfWork; the handler writes viaTenantRepository/TenantSession;before_flushstampstenant_id. - Error handling: denial → 403; cross-tenant stamp →
CrossTenantReferenceError→ 409; unscoped bulk DML →UnscopedBulkOperation→ 500. - Result: the row is committed in the handler's transaction. The guard's decision is not revalidated in that transaction (documented TOCTOU).
21.4 Database-placement request and engine-cache pressure¶
sequenceDiagram
autonumber
participant H as Handler
participant RO as SessionRouter
participant SD as DatabasePerTenantStrategy
participant SP as SecretProvider
participant CA as OrderedDict engine cache
participant DB as Tenant database / admin DB
H->>RO: session(tenant_record)
RO->>SD: session(tenant)
SD->>SD: validate_database_handle(target_handle)
SD->>SP: resolve("db-initech")
SP-->>SD: postgresql+asyncpg://…/initech
SD->>CA: lookup handle
alt cache miss
SD->>CA: evict_idle (first engine with pool.checkedout()==0)
alt all engines checked out
CA-->>SD: none idle
SD-->>H: PoolCapacityError
else idle engine found
CA-->>SD: evicted engine
SD->>DB: engine.dispose()
end
SD->>DB: create_async_engine(dsn)
SD->>CA: insert (LRU order)
end
SD-->>H: AsyncSession
21.5 Async job with signed envelope¶
Covered by the diagram in §8.3. In sequence: request handler encodes a
TenantContext; a queue stores the token; tenant_job reconstructs and
re-validates principal activity, tenant existence, entitlement, and
servability; the handler runs under a fresh context; failures
(InvalidToken/ExpiredToken/UnknownPrincipal/TenantNotFound/
TenantAccessDenied/TenantSuspended) occur before the handler is entered.
21.6 Deprovision (purge/destroy) with child-first ordering¶
registry.deprovision(tenant_id, mode=DeprovisionMode.PURGE).- Requires
archived(or alreadydeprovisioning), commitsdeprovisioning, then releases and re-acquires the advisory lock and re-reads withpopulate_existing=Trueto detect superseded state. - Re-checks children (
parent_tenant_id). - Deletes control access rows in
_ACCESS_ROW_DELETION_ORDER. - Calls
strategy.deprovision:DROP SCHEMA … CASCADE(schema) or terminate backends +DROP DATABASE IF EXISTS(database); shared placement instead requires the configuredTenantPlanePurger. TenantPlanePurger(shared) obtains a system privilege, walkssorted_tablesin reverse for child-first deletion, deletes throughTenantSession.delete_where, commits once, and returns row counts.- Marks the tenant
deletedand audits counts. The tenant, placement, migration state, relocations, global users, and platform audit rows are retained.
21.7 Invitation acceptance¶
sequenceDiagram
autonumber
participant U as Invitee
participant AC as AccessControl
participant PG as jd_control
participant B as Binding writer
participant A as PlatformAudit
participant C as AuthzCache
U->>AC: accept_invitation(token, user_id=…)
AC->>AC: parse inv_<id>_<secret>
AC->>PG: load invitation by token_id
AC->>AC: verify Argon2 hash, status=pending, not expired
AC->>PG: conditional claim UPDATE … WHERE status='pending' RETURNING
AC->>AC: re-validate scope + role/permission compatibility
alt active membership exists
AC->>B: ensure role binding exists
else new membership
AC->>PG: insert membership + role binding
end
AC->>C: cache.invalidate()
AC->>A: invitation.accepted
AC->>PG: COMMIT
AC-->>U: Membership
21.8 Security posture run (jdlib security …)¶
The operator-facing workflow behind the security CLI group (§8.20) and the
compliance package (§8.22) — the same call a pipeline can gate on:
sequenceDiagram
autonumber
participant OP as Operator / CI
participant CLI as jdlib security posture
participant CFG as Declared profile
participant CO as Evidence collectors
participant REG as CONTROL_REGISTRY (19 controls)
participant RP as PostureReport
OP->>CLI: posture --environment production --database-url … --schema …
CLI->>CFG: _declared_config(environment) (BadParameter on an unknown profile)
CLI->>CO: configuration, migration (+ policy-directory) evidence
CLI->>CO: isolation + schema-revision evidence (only with --database-url and --schema)
CLI->>REG: evaluate_posture(config, evidence=…, environment=…)
REG->>RP: one finding per control — outcome, severity, reason, evidence summary
RP-->>OP: table or --json; exit 1 when any control FAILs
jdlib security evidence runs the same collection without the verdict, and
jdlib security compliance reports the register itself (optionally filtered by
--framework) with the posture counts for the declared profile. All three are
read-only; none of them needs a database unless a live collector was asked for.
22. Architectural Decisions¶
For each decision: problem, decision, reason (with evidence), alternatives, trade-offs, consequences. Where the repository does not state a rationale, it is marked as not evident.
22.1 Two data planes instead of one schema per tenant only¶
- Problem: tenant metadata and tenant data have different lifecycles and different access rules.
- Decision: a global
jd_controlschema plus unqualified tenant-plane tables resolved bysearch_path(control/base.py:6-21,models/base.py:13-14). - Reason (evidenced):
docs/jdlib/02-relational-schema.mdfreezes this and lint L3/L19 actively prevent cross-plane FKs and misrouted models. - Alternatives: single schema with a
tenantstable (rejected: RLS/purge and audit survival become harder). - Trade-off: two metadata roots, two migration chains, and two engines in some deployments.
- Consequences:
ControlPlaneSessionis a separate wrapper; the purge path must move between planes;PlatformAuditEvent.tenant_idintentionally has no FK.
22.2 Composite (tenant_id, id) identity and composite tenant FKs¶
- Problem: single-column UUID PKs make cross-tenant references possible at the database level.
- Decision: every tenant-scoped table leads its PK with
tenant_id; FKs between tenant tables includetenant_id(models/base.py:17-27,ownership_constraints()). - Reason (evidenced): invariants I3/I4; lint L1/L2/L4.
- Consequences:
TenantSession.getmust use composite keys; repository and raw-SQL proofs can rely ontenant_idbeing part of identity; consumers write composite FKs themselves.
22.3 Enforce scoping with SQLAlchemy events, not a custom session/mapper¶
- Problem: a bespoke ORM layer would be invasive and easy to bypass.
- Decision: wrap the consumer's own
AsyncSessionwithTenantSessionand registerdo_orm_execute+before_flushper instance (persistence/session.py:109-254). - Reason (evidenced): the design docs require every path to be scoped; event hooks reach ORM selects, relationship loads, and flushes without replacing SQLAlchemy.
- Alternatives: global mapper events (rejected: cross-instance leakage), row-level security only (rejected: not available for schema/database placement, and still needs logical checks).
- Trade-off: untrusted query shapes must be rejected rather than rewritten; the DML gate trusts execution-option markers.
- Consequences: bare tables, raw aliases, text, and
FromStatementare refused; consumers must go through entities (orraw_sql).
22.4 Prove raw SQL structurally with libpg_query¶
- Problem: regex/string inspection of SQL is unsound.
- Decision:
pglast.parse_sql+ per-relation predicate proof (persistence/rawsql.py). - Reason (evidenced):
docs/jdlib/03-isolation-invariants.md:177-233freezes the per-statement proof table;pglastis a declared runtime dependency. - Alternatives: prepared-statement-only access, a query DSL, or regex (rejected as unsound).
- Trade-off: CTEs, subqueries,
OR/NOT, and set operations are rejected even when a human could prove them; one parse per call. - Consequences: the raw-SQL surface is intentionally small.
22.5 Placement as data with a strategy protocol¶
- Problem: code branching per tenant would make hybrid deployments impossible and force application changes.
- Decision:
PlacementStrategyintenant_placements, anIsolationStrategyprotocol, and aSessionRouterthat dispatches ontenant.strategy(README.md:271-298). - Reason (evidenced):
docs/jdlib/06-strategy-matrix.md; the README states "Placement is data, not code". - Trade-off: every strategy must implement six protocol members; the protocol has no health-check member.
- Consequences: application code is placement-agnostic; the registry is strategy-aware; per-strategy quirks (e.g. the version-check nuance in §23) are possible.
22.6 A single servability predicate shared by reads and writes¶
- Problem: divergent "is this tenant usable?" logic caused leaks and availability bugs during development.
- Decision: one helper (
registry.py:124-140) used byassert_servable(raisesTenantSuspended) andRegistryWriteFence(raisesTenantNotWritable) (docs/jdlib/05-tenant-lifecycle.md:73-101). - Consequences: a relocation quiesce blocks reads and writes consistently (a guarded read can return 423).
22.7 PostgreSQL advisory locks for lifecycle serialization¶
- Problem: multi-process idempotency for create/provision/deprovision.
- Decision:
pg_advisory_xact_lock(hashtextextended(key, 0))with tenant/slug/handle key namespaces (registry.py:285-304). - Alternatives:
SELECT … FOR UPDATEon tenant rows (rejected: the row often does not exist yet at create time), a distributed lock service (rejected: external dependency). - Trade-off: lock scope is transaction-scoped; work that must happen outside the lock (database drops) is re-validated after re-acquiring the lock.
22.8 Request-scoped authorization cache with one-way invalidation¶
- Problem: a shared cache risks cross-request staleness; a re-enabling cache risks post-mutation staleness.
- Decision:
AuthzCacheis per request;invalidate()permanently disables it for that instance (authz/cache.py:6-19);AccessControlinvalidates before every attempt; the scope-chain key omits the tenant id. - Reason (evidenced): the README explicitly forbids sharing a PDP/cache across requests.
- Consequences: correctness over hit rate — after any mutation the rest of the request is uncached.
22.9 Additive, allow-only RBAC with scope-checked permissions¶
- Problem: deny rules are hard to reason about and easy to get wrong.
- Decision: effective authority = union of role permissions across the
scope chain + exact-resource grants, intersected (never widened) by API-key
scopes;
Permission.allowed_scopesconstrains where a permission may be bound (pdp.py:77-150,permissions.py). - Reason (evidenced):
docs/jdlib/04-authorization-model.md:62-112. - Consequences: no deny semantics; the PDP is a deterministic pure-ish function over SQL reads.
22.10 AccessControl authorizes mutations from tenant-scope bindings, not the PDP¶
- Problem: mutation-time authorization needs the actor's global authority to create a role, not a target-scoped decision.
- Decision:
actor_permissionsreads tenant-scope bindings only and applies subset/anti-escalation checks (access.py:149-175). - Rationale not evident from the source code beyond the design docs' anti-escalation sections.
- Consequences: mutation checks are cheaper but do not re-verify principal/tenant/membership activity at write time.
22.11 Privileged access is a context, not a flag¶
- Problem: "admin mode" booleans are unauditable and unbounded.
- Decision:
PrivilegeContext(kind, capability, justification, actor, issued_at, expires_at)inside the tenant context; issuance goes throughContextFactoryand is audited;for_system/for_testrequire the module-private issuer sentinel (context.py:254-372). - Consequences: any consumer code reading
current_tenant().privilegecan see why elevated access was granted; expiry is stored but not re-checked byTenantSession(the trust is bounded by TTL and context lifetime).
22.12 Signed minimal envelopes instead of serialized contexts¶
- Problem: a
TenantContextcannot be trusted across a queue boundary. - Decision: HMAC-signed JSON containing only identity + request ids, TTL
validated, then full re-authorization on arrival
(
context.py:375-438,tenancy/jobs.py:24-81). - Consequences: scopes/permissions are never carried; revocation takes effect at the next hop because activity/entitlement/servability are re-checked.
22.13 Programmatic Alembic with no alembic.ini¶
- Problem: two independent chains (control and tenant) cannot be expressed by one ini file, and tenant targets are per-request secrets.
- Decision: in-memory
alembic.Configper call,script_locationset to the plane directory, URL injected with%doubling (migrations/runner.py:35-52); the unusable ini template was removed. - Consequences: consumers cannot shell out to
alembicwith a shipped ini; the CLI exposesdb upgrade-control/db upgrade-tenantsinstead.
22.14 Ownership is org/team placement; exact access is a grant¶
- Problem: the design documents speak of "ownership" ambiguously.
- Decision:
OwnableByOrg/OwnableByTeamwith composite FKs plusResourceGrantfor exact resource permissions;ScopeResolvertreats hierarchy as implicit downward inheritance (models/base.py,authz/scopes.py). - Consequences: there is no
ownscope; per-resource access is explicit and auditable via grants.
22.15 The CLI is an operator subset, not an API surface¶
- Decision: five command groups (
tenant,db,rls,schema,security), shared+schema strategies only,--jsoneverywhere (README.md:350-365,integrations/cli.py). Thesecuritygroup (phase 9) is read-only reporting: it assesses a declared configuration and lists evidence — it never mutates a deployment. - Reason (evidenced): the phase-6 plan records this as an accepted scope deviation.
- Consequences: database placement and all identity/RBAC management are programmatic only.
22.16 No in-process locks anywhere¶
- Decision: concurrency correctness is delegated to PostgreSQL (advisory locks, constraints, conditional updates) and to task-local contexts.
- Rationale not evident from the source code, though it is consistent with the multi-process-first posture of the advisory-lock design.
- Consequences: the database engine cache and the JWK cache have known unprotected windows (§23).
23. Current Limitations¶
23.1 Architectural limitations¶
- Relocation is not implemented as an API.
TenantRelocationandRelocationPhaseexist in the schema and the servability predicate, anddocs/jdlib/05-tenant-lifecycle.md:175-276documents the phases, butTenantRegistryhas norelocate/resume_relocation/rollback_relocation(docs/jdlib/07-implementation-design.md:437-456sketches them). The phase workflow is manual/operational.RelocationPhaseErroris defined but has no shipped caller. - Database-placement version verification nuance. After
DatabasePerTenantStrategy.migrate(which migrates the tenant database throughrunner.upgrade_tenant_url), the registry's post-migration check callsself._runner.tenant_version(strategy.migration_schema(record))(registry.py:329-338), wheremigration_schemareturns"public"and the runner is bound to the control/base DSN. The check therefore verifies the base database'spublicversion rather than the tenant database's. (Observed from source; the shipped test suite exercises per-strategy migration success separately.) migrate()has no lifecycle eligibility check — unlikeprovision, it runs for any status and, on failure, suspends an active tenant (registry.py:367-407).- No engine/pool configuration surface. No
PoolConfig, pool sizing, timeouts,pool_pre_ping, orstatement_timeoutanywhere (§16.2); consumers must configure engines themselves. - Synchronous migrations inside async flows.
MigrationRunneris synchronous and is called fromasyncregistry/strategy methods, so provisioning and migration block the event loop. - One-way cache invalidation makes
AuthzCachepermanently uncached after any attempted mutation (§7.4).
23.2 API limitations¶
TenantContextcan be constructed directly by consumer code; only convention (and cross-hop HMAC) protects the factory invariant.privilege.expires_atis stored but not re-evaluated byTenantSession; an expired privilege inside a long-lived context remains effective.requiresreads the session only fromkwargs["session"]; positional or renamed parameters silently produceMissingTenantContext(authz/guards.py:34-52).fastapi.requireauthorizes in a separate transaction from the handler (documented TOCTOU,integrations/fastapi.py:1-12).AccessControlmethods accept bothactor_idandactor_principalwithout verifying they are the same principal;actor_typeis never passed, soDatabaseAuditSinkrecords non-null actors as"user".accept_invitationdoes not verify the invitation email against the accepting identity.grant_resourcedoes not validate grantee activity;revoke_grantdoes not require holding the granted permission; the last-owner count is not filtered by active principals.bind_rolevalidation and insertion use two control sessions, so a validation-to-write window exists (fail-closed at read time).- Bulk
revoke/role-permission replacement are not exposed as operations (only single-row grant revoke and full role permission replacement exist). - No API-key issuance/revocation, membership management, or user-management
service (
member:manageexists in the catalog but no shipped mutation API). - No
own/all/ancestorscopes, no deny rules, no ABAC conditions. ResolverChainreturns the first match and does not compare conflicts.- The built-in middleware passes
claims={}, so the defaultJwtClaimResolveris inert through that path (§8.2).
23.3 Performance limitations¶
DefaultPDPissues several control-plane queries per decision (mitigated by request-scoped memoization only).ScopeResolver.chainqueries each organization ancestor individually.RawSqlValidatorparses every raw statement; the ORM path already rejects text, so this is confined to explicit raw SQL.DatabasePerTenantStrategycreates engines with default SQLAlchemy pools and no recycling/pre-ping; cache pressure yieldsPoolCapacityErrorrather than waiting.tenant_table_map()re-imports model modules and re-walks subclasses on every call.uuid7()is non-monotonic within a millisecond, so UUID ordering is only meaningful across milliseconds.
23.4 Dependency limitations¶
starletteis imported directly byintegrations/fastapi.pybut is not declared inpyproject.toml(it arrives transitively via FastAPI).- No upper bounds on any dependency; lower bounds only.
pglast>=6.0message text for rejected set operations is version-dependent.- No
uv.lock/poetry.lock; builds are not reproducible from the repository. httpxJWKS fetch has no explicit timeout.JwksCachehas no refresh lock (thundering-herd risk on unknownkid).asyncpg/psycopgare only referenced through DSNs, so a DSN naming a missing driver fails at engine creation time.
23.5 Testing gaps¶
- No coverage tooling or threshold; coverage is unknown.
- No executed relocation workflow test (relocation is unimplemented).
- No
examples/directory and no example-driven smoke test. - CI has no matrix (single Python 3.12) and no dedicated PostgreSQL service (relies on testcontainers + runner Docker).
- The
tests/isolation/layout proposed in the design docs does not exist; isolation tests are spread across suites. - The database-placement registry version-check nuance (§23.1.2) is not covered by a test that asserts the tenant database's version is what the registry checked.
23.6 Documentation gaps¶
pyproject.tomldeclares noreadme,license,authors,classifiers,keywords, orurls; noLICENSE,CHANGELOG,CONTRIBUTING.md, orSECURITY.mdexist.- Design docs
07-implementation-design.mddrift from the shipped tree: it names modules that do not exist (persistence/events.py,authz/ownership.py, anaudit/package,testing/fixtures.py,testing/fakes.py) and registry relocation methods that do not exist; the frozen spec's abbreviated public-API list omits names that the real__all__contains. Source andtests/unit/test_public_api.pyare authoritative. - The spec still refers to "L1–L19" while only 12 rule ids are implemented
(§8.15);
docs/jdlib/03-isolation-invariants.mdalso mentions anL5rule that is not emitted. - No operator runbook (backup/rotation/incident) accompanies the audit and RLS
features. Logging/metrics guidance existed nowhere before phase 7;
docs/security/observability-hardening.mdnow documents the exported telemetry surface and its gaps, and each phase document records its own — but there is still no runbook for gateways, the PDP, secret rotation or incident response.
23.7 Extensibility limitations¶
- Protocols are not
@runtime_checkable; structural checks are unavailable. IsolationStrategylackshealth_check/invalidate/disposemembers, so cache-owning strategies expose methods the framework never calls.- No plugin/hook discovery; only the
pytest11entry point exists. PlatformAudithas no target/IP/severity parameters, so rich audit events require a custom sink rather than the shipped one.
24. Future Architecture (Proposed / Not Currently Implemented)¶
Everything in this section is proposed or documented-but-unbuilt. None of it exists in the current source. The only externally sourced proposals are those recorded in the repository's design documents; each is labeled accordingly.
24.1 Documented in the design docs, not implemented¶
| Proposal | Design reference | Status |
|---|---|---|
TenantRegistry.relocate, resume_relocation, rollback_relocation with the eight RelocationPhases |
docs/jdlib/05-tenant-lifecycle.md:175-276; 07-implementation-design.md:437-456 |
schema + servability support exist; no code |
PoolConfig(...) with health-check intervals |
docs/jdlib/07-implementation-design.md:226-238 |
no config field, no code |
tests/isolation/ dedicated suite layout |
docs/jdlib/03-isolation-invariants.md:263-293 |
suite distributed elsewhere |
| Rules L5, L7, L8, L10, L14, L16, L18 | docs/jdlib/03-isolation-invariants.md |
not emitted by lint.py |
| Hybrid placement helper abstractions | docs/jdlib/06-strategy-matrix.md:163-173 |
expressible today with SessionRouter in consumer code; no helper ships |
| 503 semantics for mandatory-audit failure paths | docs/jdlib/03-isolation-invariants.md:247-261 |
no mapping for audit failure; phase 5's 503 covers a policy decision that could not be taken (AuthorizationUnavailable, §8.20), which is a different path |
24.2 Reasonable next steps implied by the code (suggestions, not requirements)¶
These are not demanded by the repository; they are the smallest changes that would close the limitations in §23.
- Make the registry's post-migration version check placement-aware
(
tenant_version_urlfor database placement) — closes §23.1.2. - Add an
asyncio.Lock(or per-handle lock) around the database engine cache anddispose()— closes the unprotected windows in §16.3. - Add a
poolconfiguration block toTenancyConfigand pass it tocreate_async_engine/ consumer engine helpers. - Populate
RequestInfo.claimsin the middleware from the authenticated principal/identity link so the default JWT-claim resolver works. - Pass
actor_typefromAccessControl(it already receivesactor_principal) so audit records distinguish service accounts. - Add coverage tooling, a CI matrix (3.12/3.13), and a PostgreSQL service container in CI.
- Publish metadata (
readme,license,classifiers) and a build/publish workflow; add a lock file. - Ship an
examples/application (FastAPI + CLI + tests) as an executable contract. - Implement relocation as registry methods with phase transitions, audit events, and servability integration.
The original revision of this list also included re-checking
privilege.expires_at inside the TenantSession hooks; the security programme's
phase 8 delivered that (§8.1), so it is no longer a suggestion.
25. Complete Architecture Diagrams¶
25.1 System context¶
graph TB
User["End user / operator"] --> App["Consumer FastAPI app"]
App --> JDL["jdlib 0.1.0"]
JDL --> PG[("PostgreSQL 15+")]
JDL --> IDP["OIDC issuer"]
App -.pytest.-> Plugin["jdlib.testing.pytest_plugin"]
Plugin --> PG
Ops["Operator"] --> CLI["Consumer-exposed Typer CLI"]
CLI --> JDL
25.2 Package architecture¶
graph TB
JD["jdlib (top-level: 13 exports)"]
JD --> A["authn (6)"]
JD --> B["authz (9)"]
JD --> C["control (7)"]
JD --> D["persistence (15)"]
JD --> E["tenancy (4)"]
JD --> F["models (5)"]
JD --> G["migrations (8)"]
JD --> H["integrations (3)"]
JD --> I["testing (3)"]
JD --> R["config / context / errors / lint / _uuid (5 root modules)"]
25.3 Module dependency graph¶
See the full graph LR in §5.2.
25.4 Public API flow¶
graph LR
U["Consumer"] --> CTX["current_tenant()"]
CTX --> UOW["UnitOfWork"]
UOW --> TS["TenantSession"]
TS --> REPO["TenantRepository"]
U --> REG["TenantRegistry"]
U --> AC["AccessControl"]
AC --> G["requires / authorize → Enforcer → DefaultPDP"]
G --> TS
25.5 Internal component architecture¶
See the graph TB in §8.21.
25.6 Important feature flows¶
- Context creation: §8.1
- Request pipeline: §8.2
- Cross-hop: §8.3
- Provisioning: §8.4
- Raw SQL: §8.7
- Authentication: §8.8
- Decisioning: §8.9
25.7 Sequence diagrams¶
Request (§21.1), provisioning (§8.4), raw SQL (§8.7), OIDC (§8.8), database routing (§21.4), invitations (§21.7), and job propagation (§8.3).
25.8 Data model¶
See the ERD in §10.1 and the table inventories in §10.2–§10.5.
25.9 Dependency architecture¶
See §11.4.
25.10 Runtime lifecycle¶
See §9.7.
25.11 Error/exception flow¶
See §13.3.
25.12 Consumer integration¶
See §20.1.
25.13 Packaging/distribution flow¶
See §18.7.
26. Accuracy and Verification Notes¶
26.1 What was inspected¶
For the original pass at a30601d:
- All 64 Python modules under
src/jdlib(plus 2 Mako templates andpy.typed), including complete import graphs (module-level and function-local). pyproject.toml(56 lines, read in full),.github/workflows/ci.yml,.gitignore,README.md(390 lines, read in full).- All 68 tracked test files: unit inventory (36 modules), integration inventory (26 modules + 3 support modules), fixtures, strategy matrix, and the security/concurrency regression tests.
- All 17 documentation files: 6 in
docs/jdlib/, 1 spec, 9 plans, plusREADME.md. - Absence checks for
examples/, Docker/compose, Makefile/tox/nox, lock files,setup.py/setup.cfg,requirements*.txt,LICENSE,CHANGELOG,CONTRIBUTING.md,SECURITY.md, pre-commit, and agent-instruction files.
Added by the phase 11 synchronisation (§26.6):
- All 101 Python modules under
src/jdlib— the 64 above, plus the 36 modules ofjdlib.security.*and one new tenant migration (migrations/tenant/versions/0002_security_audit_columns.py) — with their docstrings, module-level imports and__all__surfaces. The inventory in §5.3 was generated from a static walk of the package, not from prose. - All 125 tracked test files, enumerated with their definitions and counts
(
pytest,-W error); the security, compliance, CLI andtests/inframodules were read directly, and every count quoted in §19 was produced by a run or a collection. - The ten phase documents in
docs/security/— status lines, evidence tables, certification gates, self-evaluations and recorded gaps — plusdocs/security/README.md,docs/compliance/README.md,docs/operations/README.mdandSECURITY.md. .github/workflows/ci.yml(both jobs, in full),.gitleaks.toml,tests/infra/docker-compose.yml,pyproject.toml(64 lines at6125558),CHANGELOG.md(written here) and the programme's commit map fromgit log --oneline.- The knowledge base (36 files) where its pages carry the phase-11 refresh
banners and point at
docs/security/for current security truth.
26.2 What was verified by execution¶
pytest -q -W error→ 568 passed on a freshpostgres:16testcontainer at commita30601d.ruff check .→ all checks passed;mypy src/jdlib→ no issues in 64 files (strict mode).import jdlib→ version0.1.0, 13 exports, and nofastapi,pytest, ortyperinsys.modules(subprocess check, matchingtests/unit/test_public_api.py).- Import graph: no cycles;
jdlib.models/jdlib.controlhave no__init__.py; other subpackage initializers are empty. .githubcontains exactly one workflow; remote and localmainwere identical at the time of writing.
Re-verified by execution for the phase 11 synchronisation (§26.6):
pytest -q -W error→ 1,217 passed, 6 skipped in 71.48 s, with the local Kong/Cerbos/ZITADEL stack up and itsJDLIB_INFRA_*variables exported. With those variables removed the same suite reports 1,192 passed, 31 skipped — the infrastructure classes skip themselves by design. (The phase-9 certification gate recorded 1,215 counted before the two console-script tests landed.)pytest tests/infra -qalone → 29 passed, 6 skipped; the four compliance and CLI-security modules together → 67 passed.CONTROL_REGISTRY→ 19 controls across 7 framework families (15implemented, 2partial, 1not_implemented, 1not_applicable), withPOSTURE_RULEScovering the registry exactly;SecurityEventType→ 36 members, includingAUDIT_READ_DENIEDandAUDIT_EXPORTED.import jdlib→jdlib.securityis not imported (§9.1), and nothing underjdlib.securityimports a web framework.git log/git status:mainat6125558(the phase-11 documentation commit), with this synchronisation's own edits uncommitted;.github/workflows/holds one workflow file with two jobs. A sibling change in the working tree adds ajdlibconsole script (§8.20).
26.3 Known documentation-vs-source drift (resolved in favor of source)¶
| Design-doc claim | Shipped reality |
|---|---|
persistence/events.py, authz/ownership.py, audit/ package, testing/fixtures.py, testing/fakes.py |
do not exist; responsibilities live in persistence/session.py, control/audit.py, models/audit_recorder.py, control/purge.py, testing/assertions.py, testing/pytest_plugin.py |
TenantRegistry.relocate / resume_relocation / rollback_relocation |
absent |
PoolConfig |
absent |
| Public API list in the frozen spec | src/jdlib/__init__.py:11-25 plus tests/unit/test_public_api.py are authoritative (13 names) |
| "L1–L19" | 12 implemented rule ids (L1, L2, L3, L4, L6, L9, L11, L12, L13, L15, L17, L19) |
AuditSink / PlatformAuditSink names in older text |
shipped names are PlatformAudit and DatabaseAuditSink |
tests/isolation/ layout |
absent; distributed suites |
| 503 audit-failure status | no mapping (500) |
26.4 Explicit "Unknown / Requires Confirmation" items¶
- PyPI publication status and any external deployment of
jdlib. - Whether the design docs' original motivation statements were authored or reviewed by a particular party.
- Exact
pglastversion installed in any given environment, and therefore the exact exception text for rejected set operations. - Whether the design docs' original motivation statements were authored or reviewed by a particular party.
- Whether the
MemorySecretProvider/EnvSecretProviderdefaults are intended for production (README points consumers at custom providers). - Coverage percentage (no tooling configured).
- Whether GitHub-hosted runners can start testcontainers in the CI job (no service container is declared; the job relies on runner Docker).
26.5 Uncertain items deliberately not claimed¶
- No claim is made that any consumer application exists or that the library is deployed anywhere.
- No claim is made about performance characteristics beyond what the code structure implies (no benchmarks exist in the repository).
- No claim is made about SemVer or backward-compatibility policy:
CHANGELOG.mdnow exists (phase 11) and records the unreleased development line, but there are no releases, no tags, and no stated policy.
26.6 Phase 11 synchronisation (this revision)¶
This revision of the document was synchronised with the security-hardening
programme at commit 6125558 (main), whose phase 11 documentation commit
first landed the synchronisation; this revision completes and re-verifies it and
its own edits are uncommitted at the time of writing. Added or amended — each
after reading the source it describes:
- Added: §5.3 (the
jdlib.security.*module inventory), §8.22 (compliance evidence and posture), §15.4 (the programme's phase map), §21.8 (the security posture workflow) and this §26.6. - Amended: §3.1/§3.2 (tree, counts, absent files), §6.2 (public API
additions), §8.1 and §8.12–§8.13 (phase 8 enforcement), §8.20 (the
securityCLI group), §9.1 (import purity), §11.2, §13.1, §14.1–§14.3, §15.1–§15.3, §18.1–§18.7, §19.1–§19.2 and §19.4–§19.7, §20.2, §22.15, §23.2, §23.4–§23.6, §26.1–§26.2 and §26.5, §27.2–§27.9, and the closing note. - The table of contents is unchanged: every addition is a subsection of a section it already lists.
Every claim about the new compliance package, the security CLI group,
RlsInspection/inspect_rls, the CI security job and the phase-8 fixes was
checked against the files cited inline; §5.3 was produced from a static
inventory of jdlib.security.* (docstrings, module-level imports, __all__
lengths), and the counts in §19 and §26.2 come from runs (§26.2).
Drift found while synchronising — recorded, not silently fixed, unless the source was unambiguous:
- §23.4's pre-hardening claim that the JWKS fetch has "no explicit timeout" and
that
JwksCache"has no refresh lock" is no longer true (phase 2 introduced both). The bullets were updated, with the code cited. - §23.2's claim that an expired privilege "remains effective" was superseded by phase 8 and was updated in place (§8.1).
- The phase documents describe the audit vocabulary as "34 directive names plus
the audit-access names"; the shipped enum holds 36 members. Consistent if the
directive lists 34 and the audit-access names are the other two, but
docs/security/audit-hardening.md§1's wording ("exactly the 34 names") reads as if the total were 34. docs/security/ci-security.mdquotes "157 commits scanned" in one evidence table and "155 commits" in its summary table — same scan, two counts.- §26.3's older row about a "503 audit-failure status" predates the phase 5 work,
which introduced a 503 for
AuthorizationUnavailable(SecurityCode.POLICY_UNAVAILABLE) — a different path from audit failure. The row was left as originally written. docs/security/anddocs/compliance/,docs/operations/,docs/threat-model/are new in the working tree; §3.1 lists which files are tracked at this revision.- The working tree at the time of this read also contains concurrent sibling
work this synchronisation observes but does not own: the knowledge-base
refresh (36 files), the
jdlibconsole script (pyproject.toml:41-42,integrations/cli.py:624-627, its test intests/unit/test_cli_security.pyand the CI smoke-test extension), and small edits toREADME.mdanddocs/operations/README.md. Where those touch this document's scope (packaging, CI, the CLI), they are described as in-flight, not as landed.
27. Final Architecture Summary¶
27.1 Architecture at a glance¶
Consumer FastAPI app / CLI / jobs
│ import + wiring (no engine factory inside jdlib)
▼
Public API: TenancyConfig · ContextFactory · TenantContext · current_tenant
UnitOfWork · TenantRepository · TenantRegistry · AccessControl
requires/authorize · JdlibError/errors
▼
Core architecture: ContextFactory checks (principal → tenant → pin →
entitlement → servability) · ContextVar context
TenantSession SQLAlchemy event enforcement
RawSqlValidator (pglast AST proof)
DefaultPDP (6-gate precedence) + request-scoped AuthzCache
TenantRegistry advisory-locked lifecycle + servability
▼
Feature modules: context/envelopes/jobs · resolution/middleware ·
isolation strategies (shared/schema/database) · RLS ·
migrations · audit (platform + tenant) · purge · lint ·
authn (OIDC/API key) · access control · testing kit
▼
Adapters: FastAPI (install/dependencies/JSON errors) · Typer CLI ·
Alembic programmatic runner · PlatformAudit/SecretProvider/
OperatorAuthorizer/ScopeLookup/ResourceLookup (consumer ports)
▼
External dependencies: PostgreSQL 15+ (control `jd_control` schema + tenant
planes) · asyncpg (async) / psycopg (sync) ·
OIDC issuer + JWKS via httpx · libpg_query (pglast) ·
Argon2-cffi · FastAPI/Starlette · Typer
27.2 Core modules¶
context.py (identity/authority/envelopes), errors.py (28 error classes;
27 exported), config.py (pydantic-settings), lint.py (12 metadata
invariants), control/registry.py (lifecycle authority),
persistence/session.py (tenant-scoped session), persistence/rawsql.py
(raw-SQL proof), persistence/uow.py (write admission + transaction),
authz/pdp.py (decisions), authz/access.py (mutations), authn/* (identity),
persistence/strategies/* (placement), migrations/runner.py (schema
evolution), control/audit.py + models/audit_recorder.py (audit), and
security/* (context, config, errors, interfaces, identity, redaction,
responses, telemetry, tracing, and the authn/authz/gateway/audit/
compliance subpackages — §5.3).
27.3 Core abstractions¶
TenantContext · Principal · PrivilegeContext · TenantRecord ·
TenantSession · UnitOfWork · TenantRepository · SessionRouter ·
TenantRegistry · RegistryWriteFence · IsolationStrategy ·
RawSqlValidator · Decision/MatchedRule · AuthzCache ·
AccessReader/ControlAccessReader · AccessControl · EnvelopeCodec ·
ContextPropagator · PermissionCatalog · ResourceTypeRegistry ·
ScopeResolver · MigrationRunner · PlatformAudit · TenantAuditRecorder ·
SecurityContext · SecurityAuditEvent · RlsInspection · SecurityEvidence ·
PostureReport · Control/CONTROL_REGISTRY · TokenValidator ·
PolicyDecisionPoint · GatewayAdapter.
27.4 Main public APIs (13)¶
AccessControl · ContextFactory · JdlibError · TenancyConfig ·
TenantContext · TenantRegistry · TenantRepository · UnitOfWork ·
authorize · current_principal · current_tenant · errors · requires
— plus the de facto submodule surface in §6.2 (FastAPI install, Typer app,
strategies, MigrationRunner, DefaultPDP, ControlAccessReader,
DatabaseAuditSink, resolvers, tenant_job, jdlib.testing assertions and
fixtures).
The security programme adds a second, additive surface (§5.3):
jdlib.security (context, config, errors, interfaces), the
jdlib.security.authn / authz / gateway / audit subpackages, and
jdlib.security.compliance with the security CLI group (§8.20, §8.22). None
of it is pulled in by import jdlib.
27.5 Major features¶
Request-scoped tenant context; five resolver strategies; signed envelopes with
re-authorization; three isolation strategies plus RLS; fail-closed
tenant-scoped sessions and repositories; structural raw-SQL proof; scoped RBAC
(roles/bindings/grants/invitations/ownership); OIDC + API-key authentication;
tenant lifecycle (create/provision/migrate/suspend/resume/archive/deprovision)
with advisory locking and a single servability predicate; programmatic Alembic
for two planes; platform and tenant audit with caller-transaction participation;
tenant-plane purge; schema lint; consumer testing kit; FastAPI and Typer
integrations; and — from the security programme — the request security context,
algorithm-pinned JWT validation and outbound service tokens, fail-closed policy
decisions, gateway identity trust, the security audit vocabulary and envelope,
structured telemetry (logs, bounded metrics, spans), and compliance readiness
reporting through the security CLI (§5.3, §8.22, §15.4).
27.6 Important dependencies¶
sqlalchemy[asyncio] (core), pydantic/pydantic-settings (config),
alembic (migrations), pglast (SQL proof), pyjwt[crypto] + httpx
(OIDC), argon2-cffi (secret hashing), asyncpg (async driver) and
psycopg (sync driver), optional fastapi and typer, and the http extra
(httpx) for the security transports. No telemetry SDK is required — the span
layer is duck-typed.
27.7 Extension points¶
Authenticator · TenantResolver · IsolationStrategy · AccessReader ·
PolicyDecisionPoint · PlatformAudit · PrivilegeAudit · WriteFence ·
SecretProvider · SeedHook · ScopeLookup/ResourceLookup ·
PrincipalDirectory/TenantDirectory/EntitlementChecker/
ServabilityChecker/OperatorAuthorizer · RawSqlAuditor · injectable
RawSqlValidator · model mixins + ownership_constraints() ·
PermissionCatalog.register · ResourceTypeRegistry.register ·
CompositeAuthenticator/CompositeAuditSink · tenant_job ·
TokenValidator/TokenProvider/GatewayAdapter/RateLimiter (the security
core ports) · SecurityEventSink · evaluate_posture(rules=…).
27.8 Main runtime flows¶
- Request: ASGI middleware → authenticate → resolve tenant → context
checks →
context_scope→ handler →UnitOfWork→ scoped SQL → reset. - Provisioning: advisory lock → validate/idempotency → control rows + audit → strategy provision → migrations → seed → version check → activate.
- Decision: guard →
Enforcer→ PDP (6 gates) →DecisionorPermissionDenied. - Cross-hop: encode → queue → decode → re-validate → run under a fresh context.
- Deprovision: lock → state recheck → control-row deletion in order → strategy drop or tenant-plane purge → mark deleted → audit counts.
27.9 Known limitations (summary)¶
Relocation is not an implemented API (§23.1.1); the database-placement
registry version check reads the base database (§23.1.2); migrate() has no
status gate (§23.1.3); no engine/pool configuration exists (§23.1.4);
migrations are synchronous inside async flows (§23.1.5); cache invalidation is
one-way (§23.1.6); fastapi.require has a TOCTOU window (§23.2); audit actor
typing collapses non-null actors to "user" (§23.2); the built-in middleware
does not populate JWT claims for the default resolver (§23.2); privilege
liveness is enforced at every gate since phase 8, but liveness itself is judged
by the process clock (§23.2); IsolationStrategy lacks
health_check/invalidate/dispose (§23.7); the Tyk plugin path is not
infrastructure-verified (§19.7); the compliance surface reports readiness and is
not a certification (§15.4); packaging lacks readme/license/classifiers, a
console script, a publish workflow, and a lock file (§18.2).
End of document. Derived from jdlib at commit a30601d, synchronised with
the security-hardening programme at 4d113e5 (§26.6); every claim is traceable
to the file references given inline. For anything not determinable from the
repository, this document says so explicitly.