Skip to content

jdlib — Isolation Invariants

Companion to the design spec (rev 2). These invariants are the security contract. Each is executable: the test column is mandatory for the phase that introduces the mechanism. jdlib.testing re-exports them so consuming applications can enforce the same contract over their own resource tables.

1. Invariants

I1 — No tenant context, no tenant data

Statement: any access to a tenant-plane model without an active TenantContext raises MissingTenantContext; no default tenant exists.

Enforcement: TenantSession constructor requires a context; with_loader_criteria and before_flush handlers look up the contextvar and fail closed if absent. Control-plane access is framework-internal only.

Tests: session construction without context raises; repository call without context raises; a context cleared mid-request raises on next access; background task started without an envelope raises.

I2 — Tenant A context cannot read or write Tenant B data

Statement: every tenant-plane read, write, relationship load, bulk operation, and raw SQL path is scoped to ctx.tenant_id.

Enforcement: automatic read criteria; automatic write stamping; scoped bulk helpers; raw_sql boundary rule.

Tests (per strategy in the matrix): seeded tenants A and B; every read path returns only A rows; get(B_id) returns None; update/delete by B_id affects zero rows and raises CrossTenantReferenceError on references; relationship loads (project.resources, resource.team) never cross; bulk update/delete scoped; raw SQL without tenant boundary rejected.

I3 — Tenant-scoped rows always carry tenant ownership

Statement: tenant_id is NOT NULL, immutable after insert, and present in the primary key of every tenant-scoped table.

Enforcement: schema (PK (tenant_id, id), NOT NULL), before_flush stamping, immutable mapper setting on tenant_id.

Tests: metadata lint L1/L5; insert without tenant_id stamped; attempt to mutate tenant_id raises; direct SQL insert lacking tenant_id fails (NOT NULL).

I4 — Cross-tenant references are rejected

Statement: a row in tenant A cannot reference a row in tenant B, at any layer.

Enforcement: composite FKs (tenant_id, ...) in all strategies, plus write-time validation for logical references (RoleBinding.scope_id, ResourceGrant.resource_id, ownership columns).

Tests: composite FK violation raises integrity error; framework maps it to CrossTenantReferenceError; logical-reference validation rejects a scope or resource id that does not resolve inside the active tenant; metadata lint L2 and L3.

I5 — Tenant switching requires a new authorized context

Statement: a context and its session are immutable; changing tenant means a new authenticated, authorized context.

Enforcement: TenantContext is frozen; sessions bind one tenant_id; no setter.

Tests: mutating any context field raises; creating a session for tenant B while holding A's context is impossible through public API; two sequential requests with different resolved tenants get separate sessions.

I6 — Privileged access is explicit and audited

Statement: cross-tenant reads/writes, operator data access, and system operations only occur with a factory-issued privileged context and always emit a PlatformAuditEvent (and AuditEvent when a tenant is in scope).

Enforcement: private constructor + capability token; a PrivilegeContext descriptor (kind, capability, justification, actor, issued_at, expires_at) is attached to privileged contexts; every privileged context creation and use audited; operator tenant-data access is a distinct, time-boxed capability with a justification field.

Tests: application code cannot construct a privileged context through public API; operator context creation without capability raises; audit row written for each privileged operation; audit write failure rolls back the operation.

I7 — Background work carries tenant identity explicitly

Statement: every asynchronous boundary transfers a signed TenantContextEnvelope; no contextvar is assumed to survive; workers reload statuses and re-evaluate authorization.

Enforcement: envelope verification (HMAC + TTL); status/membership reload; execution-time authorization; queue payloads include envelope fields.

Tests: worker with no envelope fails closed; tampered signature rejected; expired envelope rejected; suspended membership between enqueue and execution denies at execution; envelope carrying an invented permission field has no effect.

I8 — External state is tenant-scoped

Statement: caches, queues, object storage keys, search/vector indexes never mix tenants.

Enforcement: documented convention and scoped_key(namespace, *parts) producing jdlib:v1:{tenant_id}:{namespace}:{part}:... where every part is RFC 3986 percent-encoded (so separators can never appear inside a part, and ("a:b", "c") cannot collide with ("a", "b:c")); event envelopes carry tenant identity; extension points receive the context.

Tests: scoped_key output format and collision cases; contract tests in jdlib.testing that consumer cache adapters must pass; events emitted by framework services include tenant identity.

I9 — Database-per-tenant still validates tenant context

Statement: physical separation never removes logical checks. tenant_id is stamped, predicates applied, and context validated in every strategy.

Enforcement: uniform code paths; router validates context tenant against the target handle before opening a session.

Tests: strategy matrix runs the identical suite; a session opened for tenant A's target with tenant B's context raises; rows are stamped even in database mode.

I10 — Physical isolation never replaces authorization

Statement: even inside the correct tenant, every operation passes the PEP → PDP flow; no path assumes that being in the right database implies permission.

Enforcement: guards on endpoints and services; TenantRepository does not imply authorization; authorize() required before mutations that are not self-scoped reads.

Tests: a member without invoice:approve in the correct tenant is denied; a service account without a binding is denied; operator without platform.tenant.access is denied even though privileged.

I11 — Polymorphic references are tenant-validated

Statement: every polymorphic reference (RoleBinding.scope_id, ResourceGrant.resource_id, AuditEvent.target_id, ownership columns) validates three things at write time: the referenced object exists, it belongs to ctx.tenant_id, and it is in an allowed state (not deleted/archived when the reference requires an active target).

Enforcement: AccessControl validates through the ResourceTypeRegistry (resource types) and tenant-plane lookups (org/team scopes); a missing target raises InvalidReference ("not found"), a same-tenant soft-deleted target raises InvalidReference ("... is deleted"), and a target owned by another tenant raises CrossTenantReferenceError. Reads treat dangling references as invalid and never as allow.

Tests: grant to a resource id from another tenant rejected; binding to a team of another tenant rejected; binding/grant to a soft-deleted or archived target rejected; unknown resource_type rejected by the registry; dangling reference created out-of-band is ignored by the PDP and reported by a consistency check.

I12 — Resource ownership hierarchy is consistent

Statement: every application resource ownership reference resolves to a hierarchy consistent with its tenant. When team_id IS NOT NULL: organization_id IS NOT NULL, team.tenant_id = resource.tenant_id, and team.organization_id = resource.organization_id.

Enforcement: CHECK (team_id IS NULL OR organization_id IS NOT NULL) plus the composite FK (tenant_id, team_id, organization_id) → teams(tenant_id, id, organization_id) (backed by UNIQUE (tenant_id, id, organization_id) on teams), and central validation in AccessControl for ownership changes. Metadata lint L15 fails the build if the constraint pair is missing.

Tests: test_resource_team_same_tenant; test_resource_team_same_organization; test_team_requires_organization; test_cross_org_team_assignment_rejected; test_cross_tenant_team_assignment_rejected; test_invalid_hierarchy_rejected.

I13 — Raw SQL fails closed

Statement: raw SQL either proves tenant-boundedness structurally or is rejected; cross-tenant/system raw SQL requires a PrivilegeContext and mandatory audit.

Per-statement contract (frozen):

Statement Required proof
SELECT every referenced tenant-routed relation has tenant_id = $n in the AND-conjunction (joins included; each relation bounded separately)
UPDATE target relation has tenant_id = $n in the WHERE AND-conjunction
DELETE target relation has tenant_id = $n in the WHERE AND-conjunction
INSERT tenant_id is explicitly supplied in the column list and its bound value $n is proven equal to TenantContext.tenant_id; INSERT ... SELECT additionally requires every source relation to be bounded

Relation resolution (frozen): the validator resolves each SQL relation to a model through authoritative metadata / ResourceTypeRegistry — never by table name inference. Known TenantRouted relation → tenant-predicate validation; known control-plane or non-tenant relation → rejected in TENANT_BOUND (allowed only under platform.raw_sql); unknown relation → rejected.

Enforcement: RawSqlValidator in TENANT_BOUND mode validates compiled PostgreSQL SQL (SQLAlchemy statement → PostgreSQL dialect compilation → SQL text + positional bind metadata) with a real parser (pglast), and must semantically prove the tenant predicate: for every referenced tenant-routed relation, the WHERE clause must be an AND-conjunction containing <relation>.tenant_id = $n where bind $n is the context tenant id. OR, NOT, and nested boolean wrappers around the tenant predicate are rejected (OR TRUE cannot pass). CTEs, DDL, multi-statements, transaction control (BEGIN/COMMIT/ROLLBACK), SET, COPY, dynamic SQL, and administrative commands are rejected in v1.

PRIVILEGED mode requires an explicit capability: tenant.raw_sql (current tenant only; tenant-predicate validation still applies — the predicate is never waived) or platform.raw_sql (cross-tenant/system; admin-level, mandatory justification and PlatformAuditEvent; skips tenant-predicate validation while structural rejects still apply). A generic "raw SQL" capability is never a platform-wide bypass. Regex, string matching, and SQLAlchemy text() are never used as isolation validators.

Tests: test_raw_sql_requires_scope; test_raw_sql_select/update/delete/insert_requires_tenant_boundary; test_raw_sql_select_without_boundary_rejected; test_raw_sql_or_true_rejected; test_raw_sql_nested_boolean_predicate_rejected; test_raw_sql_parameter_binding_is_tenant_bound; test_raw_sql_compiles_before_pglast_validation; test_raw_sql_insert_tenant_value_must_match_context; test_raw_sql_unknown_relation_rejected; test_raw_sql_control_plane_relation_rejected_in_tenant_bound; test_raw_sql_relation_resolved_from_registry; test_raw_sql_cte_cannot_bypass_tenant_scope; test_raw_sql_subquery_cannot_bypass_tenant_scope; test_raw_sql_schema_escape_rejected; test_raw_sql_ambiguous_statement_rejected; test_privileged_raw_sql_requires_capability; test_privileged_raw_sql_tenant_scope_limited; test_privileged_raw_sql_platform_scope_audited.

2. Isolation matrix

Mechanism Shared Schema Database
tenant_id predicate (auto) ✓ ✓ ✓
Write stamping + validation ✓ ✓ ✓
Composite PK / composite FK ✓ ✓ ✓ (redundant but uniform)
Context ↔ target validation ✓ ✓ ✓
RLS (FORCE, NOBYPASSRLS, SET LOCAL) optional n/a n/a
Logical reference validation ✓ ✓ ✓
Envelope signature on async boundaries ✓ ✓ ✓

3. Failure semantics

Condition Result
No context MissingTenantContext
Unknown tenant 404 to caller; platform_audit_events reason
Known tenant, principal not a member 404 to caller; audit reason
Suspended tenant / membership / user TenantSuspended (423) or TenantAccessDenied; disclosed only to entitled principals, 404 to strangers
Tenant-plane mutation during relocation critical phase TenantNotWritable (423); relocation-control operations with the relocation.manage capability are exempt
Cross-tenant FK in shared/schema mode DB integrity error mapped to CrossTenantReferenceError (409)
Cross-tenant reference in database mode CrossTenantReferenceError from write validation
RLS policy violation (should be unreachable) DB error surfaced as CrossTenantReferenceError; alert, never swallowed
Privileged use without token AuthorizationError
RLS app.tenant_id unset or empty zero rows, zero writes (policy predicate evaluates to NULL → no allow)
Mandatory audit event cannot be durably recorded operation fails closed (401/403/503 as appropriate for authn/resolution/privileged paths)

4. Test suite shape

tests/isolation/
├── conftest.py            # tenants A/B seeded; fixture parametrizes strategy × rls
├── test_reads.py          # I1, I2
├── test_writes.py         # I3, I4
├── test_relationships.py  # I2 (lazy, eager, joins, subqueries)
├── test_bulk_ops.py       # I2 (update/delete helpers; unsupported API rejects)
├── test_context.py        # I1, I5
├── test_envelope.py       # I7
├── test_external_keys.py  # I8
├── test_polymorphic.py    # I11 (grants, scopes, dangling references)
├── test_ownership.py      # I12 (team/org/tenant hierarchy consistency)
├── test_api_key_scopes.py # scopes limit bindings and grants; never widen; NULL scopes preserve authority
├── test_raw_sql.py        # I2, I6: tenant-bound vs privileged vs rejected; CTE/subquery/schema escapes
├── test_relocation.py     # operation record, quiesced traffic/writes across ALL writer classes (HTTP/jobs/queues/CLI/service/privileged), snapshot-after-drain, preserved placement until flip, rollback cannot lose writes, concurrency, idempotency
├── test_model_classification.py # L17–L19 (TenantRouted mandate on both planes)
├── test_audit.py          # transactional audit rollback; mandatory platform audit failure; best-effort behavior
├── test_pdp_cache.py      # request-scoped decisions invalidated after authorization mutations
├── test_strategy_matrix.py# I9; pool-leakage test (A → checkout → B → checkout)
├── test_pool_eviction.py  # never evict a pool with checked-out connections
├── test_rls_tenant_routed.py # RLS applies to all tenant-routed tables incl. consumer resources; control plane excluded; unset setting → zero rows/writes
├── test_constraints.py    # NULLS NOT DISTINCT bindings; partial uniques; CHECKs; cross-tenant insert
├── test_authorization.py  # I10 (delegates to the 04 matrix)
└── test_schema_lint.py    # L1–L19

Every test in this directory runs unchanged under {shared, schema, database} × {rls on, rls off} (RLS cases only meaningful for shared). This suite is the strategy-independence proof.