jdlib — Authorization Model¶
Companion to the design spec (rev 2). Freezes the evaluation algorithm, the status precedence chain, anti-escalation rules, ownership transfer semantics, platform operator capabilities, and the role × action test matrix.
1. Principals¶
| Kind | Source | Tenant relationship | Notes |
|---|---|---|---|
user |
OIDC (issuer, subject) → IdentityLink |
active Membership required |
Global identity |
service_account |
API key | pinned by api_keys row |
Tenant-owned machine principal |
platform_operator |
OIDC + platform_operators.level |
capabilities, never membership | Not a role_bindings principal |
role_bindings.principal_type ∈ {user, service_account}. Platform operators do
not receive bindings; their authority is capability-based and evaluated
separately.
Authorization ERD:
User / ServiceAccount
│ principal
▼
RoleBinding ── role_id ──▶ Role ──▶ RolePermission ──▶ Permission catalog (code)
│
│ scope_type + scope_id
├──▶ tenant (scope_id NULL)
├──▶ Organization (tenant plane)
└──▶ Team (tenant plane)
ResourceGrant ──▶ exact resource (resource_type, resource_id)
ResourceTypeRegistry validates resource_type
2. Status precedence chain¶
All gates must pass, in order; the first failure denies with that reason:
1. Principal status user.status = active | service_account.status = active
2. Tenant status tenants.status = active
3. Membership status membership.status = active (users only)
4. Binding validity role exists; scope resolves inside the tenant
5. Permission permission ∈ effective permissions (section 3)
- Suspending a membership denies immediately even if bindings remain.
- Suspending a user denies in every tenant.
- Suspending a tenant denies every principal.
deletedacts as permanent suspension.- Dangling bindings (scope deleted) are invalid at gate 4 and removed by the lifecycle service with an audit event.
Gate ordering vs. disclosure: entitlement is established before statuses
are disclosed. Unknown tenant, non-member, and unauthorized probe all return 404;
only a principal entitled to know the tenant exists (active or suspended member,
operator with platform.tenant.read) receives TenantSuspended (423). The
decision gates below still run in the order shown; the HTTP layer just refuses
to confirm existence to strangers.
3. Effective permissions algorithm¶
For principal P, tenant T, target X (resource | team | org | tenant):
1. Run the precedence chain (section 2).
2. scope_chain(X) =
{tenant T}
∪ ancestors(org(X)) # org of team/resource, including itself, up to root
∪ {team(X)} # only if X is a team or a team-owned resource
3. bindings = role_bindings
WHERE tenant_id = T
AND (principal_type, principal_id) = P
AND (scope_type='tenant' AND scope_id IS NULL
OR (scope_type, scope_id) ∈ scope_chain(X))
4. role_perms = ∪ role_permissions(role_id of bindings)
5. grants = resource_grants
WHERE tenant_id = T AND resource_type = type(X) AND resource_id = id(X)
AND (principal_type, principal_id) = P
6. base_authority = role_perms ∪ {grant.permission_code for grant in grants}
7. if P is a service_account authenticated by an API key with scopes IS NOT NULL:
effective = base_authority ∩ key.scopes # scopes constrain grants too
else:
effective = base_authority
8. allowed = requested_permission ∈ effective
API-key scope rule (frozen): scopes narrow all authority, including
resource grants. An API key must never exercise a permission outside its
declared scope: effective_permissions ⊆ key.scopes whenever
scopes IS NOT NULL. When scopes IS NULL, the service account's base
authority is unchanged.
| Service-account binding | Resource grant | key.scopes |
Result |
|---|---|---|---|
project:read |
– | {project:read} |
read allowed |
project:read |
project:delete on R |
{project:read} |
read allowed, delete denied (grant constrained) |
project:read |
– | {project:read, project:update} |
read allowed, update denied (scope cannot widen bindings) |
project:read |
project:delete on R |
NULL | both allowed (no scopes = no narrowing) |
project:read |
– | {} (empty) |
nothing allowed |
Properties:
- Inheritance is downward only: tenant bindings see everything in the tenant; org bindings see the org's teams and their resources; team bindings see only that team's resources plus resources explicitly owned by the team.
- A team binding does not see resources owned by the organization but no team (there is no upward inheritance).
- Grants are exact-resource and do not cascade to child resources; ownership determines visibility, grants patch exceptions.
- Additive, allow-only. Absence of allow = deny. No deny rules exist in v1.
Examples (tenant A, org X → team Y → resource R):
| Binding / grant | project:read on R |
|---|---|
tenant A → role with project:read |
allowed |
org X → role with project:read |
allowed |
team Y → role with project:read |
allowed |
team Z (sibling) → role with project:read |
denied |
grant on R → user → project:read |
allowed |
org X → role without project:read |
denied |
| membership suspended | denied (gate 3) |
| user has no binding, holds a grant on R only | allowed for R, denied for any other resource |
Ownership truth table (frozen). Columns are the binding's scope:
| Resource ownership | Tenant binding | Org X binding | Team Y binding |
|---|---|---|---|
tenant-owned (org_id NULL, team_id NULL) |
✓ | – | – |
org X (org_id X, team_id NULL) |
✓ | ✓ | – |
org Z (org_id Z, team_id NULL) |
✓ | – | – |
team Y (org_id X, team_id Y) |
✓ | ✓ | ✓ |
team Z (org_id X, team_id Z) |
✓ | – | – |
Explicit rule: a team binding does not inherit access to
organization-owned resources where team_id IS NULL. An org binding does not
reach another org's resources, even within the same tenant.
Resource type registry (frozen contract): ResourceGrant,
AuditEvent.target_type, the authorization layer, and the RLS generator accept
only resource_type values declared in the ResourceTypeRegistry:
ResourceTypeDefinition(
resource_type: str,
model: type,
tenant_owned: bool,
tenant_routed: bool,
permission_namespace: str,
)
Inconsistent definitions are rejected at startup (tenant_routed=True requires
tenant_owned=True; duplicate names are rejected). Unknown types raise
UnknownResourceType. For every grant, the permission_code must belong to the
registered permission_namespace of the resource type (e.g. project grants
accept only project:*; project + invoice:approve raises
InvalidResourcePermission). The registry is the single source of truth for
resource existence, tenant ownership, tenant routing, permission namespace, RLS
generation, authorization, and the consumer isolation kit.
4. Anti-escalation rules¶
Enforced inside AccessControl, not per endpoint:
permission_codevalues are validated against the code catalog; unknown codes are rejected (UnknownPermission).- A principal may create or update a role only with permissions it currently
holds; otherwise
RoleEscalationBlocked. - A principal may create a binding only for a role whose permissions are a
subset of its own effective permissions in that tenant (owners hold the full
catalog by definition of the seeded
ownerrole). - System roles (
is_system = true) cannot be renamed, edited, or deleted. - A role with existing bindings cannot be deleted until bindings are removed.
- Changing a role's permissions is re-validated against rule 2 and emits
role.permissions_changedwith the diff. - The
ownerrole may be assigned only by an existing owner or a platform operator; a tenant must always retain at least one active owner (the last owner cannot be unbound). - Grants (
resource:share) require holding the permission being granted, plusresource:share; truth table (executable test):
| Principal holds | Tries to grant | Result |
|---|---|---|
resource:read, resource:share |
resource:read |
allowed |
resource:read, resource:share |
resource:delete |
RoleEscalationBlocked |
resource:delete |
resource:delete |
denied (missing resource:share) |
| owner (full catalog) | any catalog permission | allowed |
The same subset rule applies wherever authority is transferred: binding
assignment (rule 3), role edits (rule 2), and resource grants (rule 8).
9. Permission ↔ binding-scope compatibility: every permission in an assigned
role must allow the binding's scope. Catalog entries carry
allowed_scopes; violations raise RoleScopeMismatch. Frozen namespace
defaults (custom catalog entries declare their own):
| Permission namespace | Allowed binding scopes |
|---|---|
tenant:* |
tenant |
member:*, role:*, audit:* |
tenant |
org:* |
tenant, org |
team:* |
tenant, org, team |
resource:* |
tenant, org, team |
allowed_scopes governs RoleBinding scope compatibility only. Resource-level
authority is a separate mechanism: ResourceGrant targets one exact resource
and is validated independently through the ResourceTypeRegistry (rule 10);
it never participates in binding-scope compatibility, and there is no
resource binding scope.
Example: RoleBinding(project:read @ team) and
ResourceGrant(project:delete @ project-123) are different grants from
different mechanisms.
Example: a role containing tenant:manage cannot be bound at team scope
(RoleScopeMismatch). Downward inheritance is unaffected: a permission
allowed at tenant scope remains usable against team/org targets.
10. Resource-grant namespace compatibility: the granted permission_code must
belong to the resource type's permission_namespace
(InvalidResourcePermission), and the resource must exist, belong to the
current tenant, and be in an allowed state (I11).
5. Ownership transfer¶
Ownership is explicit (organization_id, team_id on resources; organization
tree for teams).
- Authorization is always evaluated against the current hierarchy.
AccessControl.transfer_resource(resource, to_org=None, to_team=None)andmove_team(team, to_org)update ownership and emitresource.owner_changed/team.movedwithfrom/toinmetadata.- Existing
resource_grantsremain valid unless the operation passesrevoke_grants=True. - Principals depending on the previous scope lose access immediately; principals in the new scope gain it immediately.
- Moving a team to another organization does not modify its bindings; bindings scoped to the old ancestry stop applying, bindings scoped to the team persist.
- Deleting an organization or team is blocked while resources reference it
(
ON DELETE RESTRICT); the lifecycle service requires transfer or explicit cascade with audit. - Request-scoped authorization caches are invalidated by ownership changes and transfers (section 7); there is no cross-request permission cache in v1, so nothing else to invalidate. Any future cross-request cache must invalidate on access-control table changes.
6. Platform operator capabilities¶
Levels are code-mapped capability sets; there is no blanket bypass.
| Capability | support | operator | admin |
|---|---|---|---|
platform.audit.read |
✓ | ✓ | ✓ |
platform.tenant.read (metadata only) |
✓ | ✓ | ✓ |
platform.tenant.manage (lifecycle) |
✓ | ✓ | |
platform.migration.execute |
✓ | ✓ | |
platform.operator.manage |
✓ | ||
platform.tenant.access (read-only, time-boxed, justified) |
✓ | ✓ | |
platform.tenant.access.write |
✓ | ||
platform.raw_sql (cross-target raw SQL; mandatory justification + audit) |
✓ |
Rules:
- Tenant administration is distinct from tenant data access.
platform.tenant.accessopens a privilegedTenantContextcarrying aPrivilegeContextdescriptor (kind='operator', capability,justification,actor,issued_at,expires_at); the TTL defaults to 30 minutes. Creation and every use writeplatform_audit_eventswithcontext.privileged_used.- Tenant data access never inherits tenant permissions: an operator cannot
approve an invoice unless
platform.tenant.access.writeis held; the action is still audited as operator-originated in the tenantAuditEvent. - Support-level operators can read platform audit but not tenant data.
platform.operatorrows cannot be created or changed except byadmin.
Authorization layers (frozen — one layer, not both):
Platform operator request
1. platform capability check # authoritative for operator contexts
2. privileged TenantContext # descriptor, TTL, justification, audit
3. tenant operation
The tenant PDP is not consulted for operator contexts: operators hold no
role_bindings, and the platform layer is the sole authority. Applications that
want extra restrictions can inspect ctx.privilege.kind == 'operator', but the
default policy is platform-layer authorization. Non-operator principals use the
tenant PDP exclusively. No principal is ever authorized by both layers.
7. PDP contract¶
class PolicyDecisionPoint(Protocol):
async def evaluate(self, principal, permission, target, context) -> Decision
@dataclass(frozen=True, slots=True)
class Decision:
allowed: bool
reason: str # allowed | principal_inactive | tenant_suspended |
# membership_inactive | outside_key_scopes | no_allow
matched: tuple[MatchedRule, ...] # role binding / grant provenance
- Guards:
@requires("invoice:approve", scope=ResourceScope())for endpoints;authorize(principal, permission, target)for jobs, CLI, and service code. - Every decision carries its reasoning; denials and allows on sensitive actions are auditable.
- Resolution is memoized per request through a request-scoped
AuthzCache: status gates keyed by principal/tenant, scope chains keyed by target, bindings keyed by(tenant, principal, scope chain), per-role permissions keyed by(tenant, role-id set), and grants keyed by(tenant, principal, resource type, resource id). No cross-request cache in v1. - Cache invalidation (frozen, conservative): any attempted authorization-state
mutation within a request invalidates the request-scoped cache immediately —
role/permission changes, binding changes, grant changes, membership changes,
ownership transfer, and team moves.
AccessControlperforms the invalidation before the mutation; a rolled-back mutation leaves the cache invalidated (fail-safe and acceptable for v1; no cache restoration is attempted). A mutation must never be followed by a stale cached allow. - Tests:
test_authorization_cache_invalidated_after_role_mutation,..._after_binding_mutation,..._after_grant_mutation,..._after_ownership_change, plus direct PDP teststest_evaluate_memoizes_reader_and_scope_callsandtest_cache_invalidate_forces_refetch. Decisionis advisory to the PEP; the framework guards andAccessControlare the only supported enforcement points.
8. Role templates and action matrix¶
Built-in templates seeded per tenant (is_system = true). Applications may add
custom roles; they cannot exceed the catalog.
Framework catalog (apps register additional resource:action codes):
tenant:read, tenant:manage, member:invite, member:manage, role:manage,
org:manage, team:manage, audit:read, resource:read, resource:create,
resource:update, resource:delete, resource:share
| Action → | tenant:read | resource:read | resource:create | resource:update | resource:delete | resource:share | member:invite | member:manage | role:manage | tenant:manage |
|---|---|---|---|---|---|---|---|---|---|---|
owner (tenant scope) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
admin (tenant scope) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
org_manager (org scope) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ||
member (team scope) |
✓ | ✓ | ✓ | ✓ | ||||||
viewer (any scope) |
✓ | ✓ | ||||||||
resource grantee (resource) |
✓ | |||||||||
service_account (binding-defined) |
||||||||||
platform_operator (no binding) |
Service accounts have no implicit permissions: their row shows no action until bindings are created, and then only what the bound role grants. Platform operators show no tenant action by design: tenant data access is an explicit capability (section 6), never an implicit allow.
The integration test matrix exercises each row against each column and asserts allow/deny, including scope placement (tenant vs org vs team) and the precedence chain.