REFORMATION BRIEF R-00 — THE RAILS¶
Authentication & Tenancy Constitution¶
UCCA Platform · 18 March 2026¶
This brief is the constitutional document for the UCCA platform. Every surface, every client, every credential issued by this system inherits from what is defined here. Build it once. Build it right.
SURFACE: engine-db (D1 schema) + ucca-api (Python backend) + ucca-ops (ops console)
DO NOT TOUCH: ucca-site, ucca-ir, ucca-keys, rtopacks-site, ucca-track
Cloudflare Account: e5a9830215a8d88961dc6c80a8c7442a
→ ALEX¶
1. Database Schema — Lock This First¶
Execute against engine-db (0efa8970-0053-4623-8436-4e877af10887).
This is the canonical identity schema. Nothing touches users or tenancy without going through this.
-- Canonical identity store. All users L1–L4 in one table.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- UUID v4
email TEXT UNIQUE NOT NULL,
auth0_sub TEXT UNIQUE, -- NULL for L1/L2 (CF Access only)
display_name TEXT,
avatar_url TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Every L3 client (RTO, TAFE, etc.) is a tenant.
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY, -- UUID v4
slug TEXT UNIQUE NOT NULL, -- e.g. "rtopacks", "tafe-nsw"
display_name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}', -- branding, feature flags
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
suspended_at INTEGER -- NULL = active
);
-- The join table that governs all access.
-- L1/L2: tenant_id IS NULL (global roles).
-- L3/L4: tenant_id IS NOT NULL (scoped roles).
CREATE TABLE IF NOT EXISTS user_tenant_roles (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
tenant_id TEXT REFERENCES tenants(id), -- NULL for L1/L2
role TEXT NOT NULL CHECK (role IN ('L1','L2','L3','L4')),
granted_by TEXT REFERENCES users(id),
granted_at INTEGER NOT NULL DEFAULT (unixepoch()),
revoked_at INTEGER -- NULL = active
);
-- Immutable audit trail. Written on every state-changing action.
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
actor_id TEXT NOT NULL, -- who took the action
impersonating_id TEXT, -- set when L1/L2 drops into L3/L4
tenant_id TEXT,
action TEXT NOT NULL, -- e.g. "user.create", "role.grant"
target_table TEXT,
target_id TEXT,
payload_json TEXT,
ip TEXT,
user_agent TEXT,
ts INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_utr_user ON user_tenant_roles(user_id);
CREATE INDEX IF NOT EXISTS idx_utr_tenant ON user_tenant_roles(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit_log(tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
2. Python Middleware — JWT Validation & Tenant Scoping¶
Add to ucca-api. This middleware runs on every request.
Two rails, validated separately:
Rail 1 — Internal (L1/L2): Request arrives at internal subdomain. Middleware checks Cf-Access-Jwt-Assertion header. Validates against Cloudflare public keys. Extracts email claim. Looks up user in users table. Checks user_tenant_roles for global L1 or L2 role (tenant_id IS NULL). Rejects if not found.
Rail 2 — External (L3/L4): Request arrives with Authorization: Bearer <token>. Middleware validates JWT against Auth0 JWKS endpoint. Extracts sub, tenant_id, role claims. Enforces: every L3/L4 query MUST include WHERE tenant_id = :tenant_id. Middleware injects tenant_id — the route handler never trusts a tenant_id from the request body.
# Pseudocode — implement as FastAPI dependency
async def get_current_user(request: Request) -> AuthContext:
# Rail 1: Internal
cf_jwt = request.headers.get("Cf-Access-Jwt-Assertion")
if cf_jwt:
claims = verify_cf_jwt(cf_jwt)
user = db.get_user_by_email(claims["email"])
role = db.get_global_role(user.id) # L1 or L2, tenant_id IS NULL
return AuthContext(user=user, role=role, tenant_id=None, impersonating=None)
# Rail 2: External
bearer = request.headers.get("Authorization", "").removeprefix("Bearer ")
if bearer:
claims = verify_auth0_jwt(bearer)
# Impersonation: actor claim present = L1/L2 dropping into L3/L4
impersonating = claims.get("actor")
tenant_id = claims["tenant_id"]
user = db.get_user_by_auth0_sub(claims["sub"])
role = db.get_scoped_role(user.id, tenant_id)
return AuthContext(user=user, role=role, tenant_id=tenant_id, impersonating=impersonating)
raise HTTPException(401)
Audit log write — every state-changing action:
def write_audit(ctx: AuthContext, action: str, target_table: str, target_id: str, payload: dict, request: Request):
db.insert("audit_log", {
"id": uuid4(),
"actor_id": ctx.user.id,
"impersonating_id": ctx.impersonating,
"tenant_id": ctx.tenant_id,
"action": action,
"target_table": target_table,
"target_id": target_id,
"payload_json": json.dumps(payload),
"ip": request.client.host,
"user_agent": request.headers.get("user-agent"),
"ts": int(time.time())
})
3. Impersonation Endpoint¶
L1/L2 drops into any L3/L4 user's session without credentials.
# POST /internal/impersonate
# Requires: valid L1/L2 CF Access JWT
# Body: { "target_user_id": "uuid", "tenant_id": "uuid" }
async def impersonate(body: ImpersonateRequest, ctx: AuthContext = Depends(get_current_user)):
assert ctx.role in ("L1", "L2"), "Forbidden"
target = db.get_user(body.target_user_id)
scoped_role = db.get_scoped_role(target.id, body.tenant_id)
# Mint a time-limited JWT (15 min) with actor claim
token = auth0.mint_token(
sub=target.auth0_sub,
tenant_id=body.tenant_id,
role=scoped_role.role,
actor=ctx.user.id, # ← immutable impersonation marker
expires_in=900
)
write_audit(ctx, "impersonation.start", "users", target.id, {
"tenant_id": body.tenant_id
}, request)
return {"token": token, "expires_in": 900}
4. Auth0 Enterprise — Provisioning Checklist¶
Provision in Auth0 dashboard. Do not start app builds until this is live.
- Create Auth0 tenant:
ucca.au.auth0.com - Enable Organizations feature (B2B multi-tenancy)
- Create application:
UCCA Platform (L3/L4) - Enable Passkeys (FIDO2/WebAuthn) as primary auth method
- Configure Magic Link as fallback (Twilio SendGrid delivery)
- Set custom claims action: inject
tenant_idandroleinto JWT on login - Configure Australian data residency
- JWKS endpoint:
https://ucca.au.auth0.com/.well-known/jwks.json - JIT Provisioning action: on first SSO callback, upsert
userstable before session
5. Ops Console — Account Switcher & User Database¶
Add to ucca-ops (ops.ucca.online). This activates the account switcher.
New world block: IDENTITY & TENANCY — place at Level 1 in ops nav.
Pages to scaffold (stubs acceptable for now, real data required):
/w/identity/users/ — All users table. Columns: ID, email, display_name, role, tenant, created_at, status. Search + filter by role and tenant. Click row → user detail.
/w/identity/tenants/ — All tenants table. Columns: slug, display_name, user_count, created_at, suspended_at. Click row → tenant detail with user list.
/w/identity/roles/ — user_tenant_roles table. Filter by user or tenant. Show granted_by, granted_at, revoked_at.
/w/identity/audit/ — audit_log table. Filter by actor, tenant, action, date range. Show impersonating_id column — highlight rows where impersonation was active.
/w/identity/impersonate/ — Search for any L3/L4 user. Select tenant context. Click "Drop In" → calls /internal/impersonate → opens scoped session in new tab.
Account switcher (persistent in ops header): Dropdown showing current context. Options: [L1 — Global], then all active tenants. Selecting a tenant sets the active tenant_id context for the ops session. Visually distinct when impersonating — amber banner: IMPERSONATING: [user email] in [tenant slug].
6. Seed Data¶
After schema is applied, seed engine-db with:
-- Tim as L1
INSERT INTO users (id, email, display_name) VALUES
('00000000-0000-0000-0000-000000000001', 'tim@ucca.online', 'Tim Rignold');
INSERT INTO user_tenant_roles (id, user_id, tenant_id, role, granted_by) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000001', NULL, 'L1', '00000000-0000-0000-0000-000000000001');
-- RTOpacks as first tenant
INSERT INTO tenants (id, slug, display_name) VALUES
('00000000-0000-0000-0000-000000000100', 'rtopacks', 'RTOpacks');
OPS STUB TASK¶
Add to ops console task queue:
R-00 · THE RAILS
Schema applied to engine-db ✓
Python middleware live ✓
Auth0 tenant provisioned ✓
Impersonation endpoint live ✓
Ops identity world live ✓
Seed data applied ✓
→ TIM¶
What this brief does:
R-00 builds the foundation that every future surface sits on. It does four things:
-
Creates the user and tenant database — one canonical table for all users L1 through L4, one table for all client organisations (RTOs, TAFEs), and a join table that controls who can see what. Every RTO client that ever joins the platform gets a row in
tenants. Every learner gets a row inusers. No exceptions. -
Locks the two security rails — your internal ops (L1/L2) stays behind Cloudflare Access as it is now. Everything external (L3 RTO admins, L4 learners) goes through Auth0 Enterprise with Passkeys as the primary login. These two rails never cross.
-
Builds the impersonation engine — you can drop into any learner's session from ops, see exactly what they see, debug their problem, and leave a clean audit trail showing it was you, not them. This is how good SaaS platforms handle support.
-
Activates the account switcher in ops — the Identity & Tenancy world in ops becomes the live user database and tenant manager. Every user on the platform is visible here. Every tenant. Every role grant. Every impersonation event. If it doesn't exist in ops, it doesn't exist.
Why this happens before anything else:
The first RTO that logs into their dashboard is an L3 tenant. Their first learner is an L4 user. If the rails aren't in place before that happens, you're rebuilding on a live product. This brief makes sure you never have to do that.
Auth0 cost note: Auth0 Enterprise pricing is usage-based. At zero paying customers the cost is negligible. The budget conversation happens when you have RTOs onboarded — at which point the cost is a line item against revenue, not a startup burn.
REFORMATION BRIEF R-00 · UCCA Inc · 18 March 2026 "The rails are the product."