User / org model and Auth0 integration
Bucket: Backend (Agent B) Status: Reviewed — 2026-05-12 (Phase B seams applied: 5-role matrix and multi-tenant Organizations parked; JWT claim names canonicalized; the executive role replaces the round-1 short form) Owner: Sophia · Reviewers: Andrew, Agent C (frontend / role enforcement on UI), Mariana (for org/role sign-off)
Context
Section titled “Context”Andrew’s 5/10 directive describes the back end as covering “user profiles, org associations, APIs wired into the front end.” lbzfai.com currently signs users in via Auth0 SPA and then does nothing — there is no concept of “Ronald” vs “Mariana” vs “Sophia” downstream. This doc specs the user / org data model, where it lives (cloud vs Jetson), how Auth0 identities map to LBZF roles, and how it composes with the Jetson-local dashboard’s auth (see dashboard-api.md).
The decision being made: where does the user/org table live, how do Auth0 identities map to roles, and how does that travel from lbzfai.com login → Jetson dashboard authorization.
- A single users + orgs schema that lbzfai.com and the Jetson dashboard agree on
- An identity-to-role mapping that can be edited without a redeploy (config-driven, not source-coded)
- A single-tenant LBZF deployment for Phase I (ITBA twin runs its own hardware + its own user list)
- Auth0 stays the identity provider (Andrew’s directive); we do not run our own user store
- Three effective roles in Phase I (
admin,engineer,executive) — the full 5-role matrix is parked
Non-goals
Section titled “Non-goals”- Replacing Auth0 with anything else
- A self-service user signup flow — invites only, controlled by admins
- Full SCIM provisioning to Auth0 from our DB — out of scope until we have >10 users
- Role-based feature flags (Phase II)
- ABAC / fine-grained permissions; we ship coarse RBAC
- Multi-tenant Auth0 Organizations — parked to
docs/design/60-parking/multi-tenant-auth0.md. Phase I is single-tenant LBZF; ITBA’s twin is a separate Jetson with its own user list (same Auth0 tenant for convenience, distinctorg_idinformationally). - The
supervisorandresearchroles — parked todocs/design/60-parking/full-role-matrix.md. Phase I uses onlyadmin / engineer / executive(plusviewerdeny-by-default).
Proposed approach
Section titled “Proposed approach”Where the user/org table lives — the big decision
Section titled “Where the user/org table lives — the big decision”Three options:
| Option | Pros | Cons |
|---|---|---|
A. Only in Auth0 (use app_metadata and roles, no DB) | Zero new infra; Auth0 dashboard is the management UI | app_metadata is awkward for org/module scoping; querying “which users belong to module 1” requires Management API calls; rate-limited |
| B. SQLite on the Jetson | One DB to back up; one source of truth | Cloud proxy can’t validate role assertions without round-tripping to Jetson through Tailscale; Jetson goes offline → lbzfai.com can’t render anything |
| C. Cloud DB (Cloudflare D1 or Workers KV) | Sits next to lbzfai.com; reachable from both lbzfai.com and the cloud proxy; small | New piece of infra to operate |
| D. Hybrid: Auth0 holds identity, Cloudflare D1 holds the org/role/profile, Jetson has a read-only mirror | Each piece does what it’s best at | More moving parts |
Pick: D (hybrid), with one carve-out — for Phase I, the D1 step can be a checked-in JSON file deployed as part of the Cloudflare Worker. It is a hand-edited file (10 rows) until we need a UI. This keeps the operational footprint small and preserves the architecture for when we need it.
Auth0 (identity) ├─ user_id (sub) e.g. "auth0|abc123" ├─ email e.g. "ingenierialbarton@gmail.com" ├─ name e.g. "Ronald Gonzalez" └─ email_verified
Cloudflare D1 / users.json (profile + org + role) └─ users[] ├─ auth0_sub Auth0 user_id ├─ email denormalized for matching when sub unknown (invite-by-email) ├─ display_name ├─ org_id 1 = LBZF, 2 = ITBA (informational in Phase I — see parking/multi-tenant-auth0.md) ├─ role admin | engineer | executive (Phase I; supervisor + research parked) ├─ module_scope null in Phase I (used by supervisor role when it thaws) └─ active
Jetson SQLite (read-only mirror) └─ users_cache table, refreshed every 5 min from D1 via Tailscale-egress fetch (or every dashboard request — Phase I scale doesn't need caching)Schema (D1 / JSON)
Section titled “Schema (D1 / JSON)”// users.json — committed to the repo, deployed with the Cloudflare Worker{ "orgs": [ {"id": 1, "code": "LBZF", "display_name": "Louis Barton Zona Franca", "country": "CO"}, {"id": 2, "code": "ITBA", "display_name": "Instituto Tecnológico de Buenos Aires", "country": "AR"} ], "users": [ { "auth0_sub": "auth0|...sophia...", "email": "sophiamann@evasglobal.com", "display_name": "Sophia Mann", "org_id": 1, "role": "admin", "module_scope": null, "active": true }, { "auth0_sub": null, "email": "ingenierialbarton@gmail.com", "display_name": "Ronald Gonzalez Suarez", "org_id": 1, "role": "engineer", "module_scope": null, "active": true }, { "auth0_sub": null, "email": "<mariana-email-TBD>", "display_name": "Mariana Saker", "org_id": 1, "role": "executive", "module_scope": null, "active": true }, { "auth0_sub": null, "email": "<andrew-email-TBD>", "display_name": "Andrew Kent", "org_id": 1, "role": "admin", "module_scope": null, "active": true } // ITBA users get role=admin (Sophia+Andrew on the ITBA Jetson) or are added later as // the `research` role thaws per 60-parking/full-role-matrix.md ]}Lookup: auth0_sub if present (immutable from Auth0), else fall back to email (verified). New users that don’t appear in this file get role=viewer, org_id=null — locked out by every endpoint.
When we outgrow JSON, this becomes two D1 tables (orgs, users) with the same shape.
Roles — Phase I permission matrix (3 effective roles)
Section titled “Roles — Phase I permission matrix (3 effective roles)”Canonical role list across the whole project: admin / engineer / executive / supervisor / research, plus viewer (deny-by-default). Phase I exercises only the three in-bold roles below; supervisor and research are parked per 60-parking/full-role-matrix.md.
| Capability | viewer | engineer | executive | admin |
|---|---|---|---|---|
| View own org’s modules | ❌ | ✅ | ✅ | ✅ |
| View live cycle events | ❌ | ✅ | ✅ | ✅ |
| View aggregated efficiency | ❌ | ✅ | ✅ | ✅ |
| Trigger Excel export | ❌ | ✅ | ❌ | ✅ |
| Override / verify cycles | ❌ | ✅ | ❌ | ✅ |
| View operator names | ❌ | ✅ | ❌ (aggregates only) | ✅ |
| Create operators | ❌ | ✅ | ❌ | ✅ |
| Configure cameras / workstations | ❌ | ❌ | ❌ | ✅ |
| Deploy new CV model version | ❌ | ❌ | ❌ | ✅ |
viewer exists so an authenticated stranger (Auth0 has no signup gating today) is recognized but allowed nothing. The frontend renders a “you don’t have access — please contact admin” page.
The full 5-role matrix (with supervisor per-module scoping and research cross-org reads) lives in 60-parking/full-role-matrix.md and thaws when there is a real human attached to either role.
Auth0 configuration changes required
Section titled “Auth0 configuration changes required”-
Add custom claims to the access token carrying the user’s
role,org_id, andmodule_scope, computed via an Auth0 Action that looks upemailin ourusers.json(or D1 once we move). The JWT itself encodes the role — the Jetson and lbzfai.com don’t need to re-fetch.Canonical claim names (the seam):
https://lbzfai.com/role— singular string ("admin"|"engineer"|"executive"). The claim name is exactlyrole(singular). It is NOT a non-namespaced name, NOT a plural array, NOT a nested object.https://lbzfai.com/org_id— integer (1 = LBZF, 2 = ITBA).https://lbzfai.com/module_scope— array of module ids ornull. Reserved for the parkedsupervisorrole; emitnullfor every Phase I user.
// Auth0 Action: onExecutePostLoginexports.onExecutePostLogin = async (event, api) => {const email = event.user.email;const userRow = await fetchFromCloudflare(email); // D1 / Workerif (userRow && userRow.active) {api.accessToken.setCustomClaim('https://lbzfai.com/role', userRow.role); // singular stringapi.accessToken.setCustomClaim('https://lbzfai.com/org_id', userRow.org_id);api.accessToken.setCustomClaim('https://lbzfai.com/module_scope', userRow.module_scope);api.accessToken.setCustomClaim('https://lbzfai.com/display_name', userRow.display_name);}};Custom-claim names must be fully-qualified URIs per Auth0’s namespacing rules — that’s why they’re
https://lbzfai.com/…. Every consumer (the lbzfai.com SPA, the Worker if/when it proxies, the Jetson middleware) reads from exactly these three claim names. -
Register an API in Auth0 dashboard:
audience=https://api.lbzfai.com. SPA requests an access token for this audience; the Jetson and cloud proxy validate the JWT against Auth0’s JWKS. -
Roles created in Auth0:
admin,engineer,executivefor Phase I. (supervisorandresearchcreated when their parking-folder docs thaw.) Each becomes an Auth0 Role; users get assigned via the Auth0 management UI for now (and via Action lookups for emails we know). -
Email-verified gate —
email_verified=truerequired; otherwise the Action does not set the role claim and the user lands asviewer. -
Allowed Callback / Logout / Web Origin URLs — already includes
localhost:4321,lbzfai.sophiainesmann.workers.dev,lbzfai.com. Add the Tailscale MagicDNS hostname for the Jetson if we ever want direct Auth0 login on the Jetson dashboard (currently we don’t — Jetson uses the proxy or Tailscale identity).
How identity travels (Phase I, per ADR-005 Tailscale-only)
Section titled “How identity travels (Phase I, per ADR-005 Tailscale-only)”Browser at lbzfai.com │ ▼ Auth0 SPA login → JWT with custom claims: https://lbzfai.com/role (singular: "admin" | "engineer" | "executive") https://lbzfai.com/org_id (1 = LBZF, 2 = ITBA) https://lbzfai.com/module_scope (null in Phase I) │ ▼ Astro static page reads claim, renders the role-aware shell: role=admin → /app/admin role=engineer → /app/ingeniero (with "Open dashboard via Tailscale" link) role=executive → /app/ejecutivo (mocked aggregates in Phase I) role=viewer → /app/cuenta ("access pending — contact Sophia") │ ▼ User clicks "Open dashboard" → Tailscale-direct to Jetson │ ▼ Tailscale-authenticated browser → https://<jetson>.<tailnet>.ts.net/... │ ▼ Tailscale Serve injects Tailscale-User-Login header │ ▼ Jetson middleware: └─ Look up Tailscale-User-Login in users.json (or its cached mirror) └─ Resolve to role + org_id + module_scope └─ Endpoint-level role checkPhase I has one identity path to the Jetson (Tailscale-direct). The Worker-validated-JWT → X-LBZF-Identity header path is preserved in 60-parking/cloudflare-tunnel-from-jetson.md for Phase II.
Org model and ITBA (Phase I: single-tenant LBZF + parallel ITBA twin)
Section titled “Org model and ITBA (Phase I: single-tenant LBZF + parallel ITBA twin)”Phase I: LBZF is org_id=1. ITBA gets org_id=2 as a label, but their Jetson is a separate physical box with its own SQLite. There is no cross-org rendering on lbzfai.com in Phase I — ITBA users on the tailnet tag:itba-dev reach the ITBA Jetson directly; LBZF users on tag:lbzf-pereira reach the LBZF Jetson directly. The shared users.json is the only cross-Jetson resource; the data layers are independent.
Cross-org admin is Sophia + Andrew (they appear in both Jetsons’ user lists with role=admin). The “Auth0 Organizations + cross-org enforcement” architecture is parked to 60-parking/multi-tenant-auth0.md.
lbzfai.com → Jetson invitations
Section titled “lbzfai.com → Jetson invitations”Until we have a “manage users” UI, adding a new user is a PR:
- Edit
users.jsonin the repo - Open PR; reviewed by Sophia/Andrew
- Merge to
main→ Cloudflare auto-deploys → user can log in on next session - If new user, Auth0 admin sends them an invitation email (manual via Auth0 dashboard for now)
This is intentionally lo-fi for Phase I; the PR review is the access-control approval workflow.
Account deletion / off-boarding
Section titled “Account deletion / off-boarding”active=false in users.json; an Auth0 Action also sets the role claim to viewer on next login so even if the JWT hasn’t rotated, the next refresh locks them out. Hard delete is via Auth0 dashboard.
Audit log
Section titled “Audit log”Phase I: every write endpoint on the Jetson logs {ts, request_id, actor="<email> (<role>)", action, target_table, target_id} to journald (journalctl -u lbzf-dashboard). The DB-side audit_log table with before/after JSON is parked to 60-parking/audit-log.md. The lbzfai.com Worker logs every page render to Cloudflare Workers logs ({request_id, email, role, path, status, latency_ms}).
Alternatives considered
Section titled “Alternatives considered”- No user table at all — Auth0 alone via
app_metadata(Option A): rejected because querying “all engineers” requires Management API calls and rate limits are real; also the Action source-of-truth would be split across Auth0’s UI and our code. - Jetson-only user table (Option B): rejected because lbzfai.com cannot render anything when Tailscale is partitioned; this matters for the Argentina demo on possibly-unstable hotel Wi-Fi.
- Full Cloudflare D1 from day 1 (Option C without the JSON middle step): fine but premature — we have ~10 users. JSON file in repo + Worker = same architecture, half the moving parts.
- Mapping by email only, ignoring
auth0_sub: works until someone changes their email; better to capture sub once seen. - Role hierarchy via inheritance (admin > engineer > viewer): tempting but the matrix has non-monotonic permissions (
engineercan override cycles,executivecannot —executiveis “read-only at a higher level,” not “engineer minus things”). Hierarchical roles would be wrong. - Non-namespaced or plural-array role claim: rejected; the canonical claim is
https://lbzfai.com/role(namespaced, singular string). Every consumer agrees on this exact name. See the “Auth0 configuration changes required” section.
Open questions
Section titled “Open questions”- OPEN: Mariana’s preferred login email — owner: Sophia/Armando. Likely her LBZF address; needs Auth0 invite.
- OPEN: Andrew’s login email for the app — owner: Sophia. Probably the InspiritAI / Brown alum address he used in our setup.
- OPEN: who at LBZF beyond Mariana and Ronald gets
executiveaccess? — owner: Mariana. (supervisorandresearchare parked; no Phase I assignment needed.) - OPEN: ITBA team — do they each get
adminon the ITBA Jetson, or only Sophia + Andrew? — owner: Andrew + ITBA leads. Defaulting toadminon the ITBA Jetson, no role on the LBZF Jetson. - OPEN: when does
users.jsongraduate to D1? Threshold: >25 users or first request for a self-service “invite a teammate” UI. Owner: Sophia / Andrew. - OPEN: privacy posture on
display_namestorage — operator names are Confidencial; do user names of internal staff get the same protection? Probably no, but worth confirming with Mariana. - OPEN: do we want service accounts / API tokens (long-lived, scoped) for Ronald’s eventual analysis scripts? Not Phase I.
Cross-bucket dependencies
Section titled “Cross-bucket dependencies”| This doc depends on | Owner bucket | What we need |
|---|---|---|
| Frontend role-gating UI | Agent C | Implements role-aware render; uses same role names |
| Dashboard middleware | This bucket (dashboard-api.md) | Reads X-LBZF-Identity + Tailscale headers |
| Cloud proxy / Worker route | This bucket (lbzfai-jetson-integration.md) | Adds X-LBZF-Identity header, signs with shared secret |
| Auth0 tenant config | Existing infra | Auth0 Action edited, API registered, roles created |
| This doc implies | Owner bucket | Ask |
|---|---|---|
Frontend reads https://lbzfai.com/role claim (singular string) to render role-aware shells | Agent C | Read exactly that claim name; do not look for any non-namespaced or plural variant |
Operator names treated as Confidencial in API responses (executive cannot see operator names) | This bucket (dashboard-api.md) | Per-endpoint filter |
| journald write-trail populated on every write endpoint | this bucket | Plumbing; audit_log DB table is parked (see 60-parking/audit-log.md) |
What’s weak in this doc
Section titled “What’s weak in this doc”- Two real auth surfaces (Auth0 SPA on lbzfai.com, Jetson middleware reading Tailscale-User-Login) each have to agree on the role list. The Phase II Worker-proxy adds a third surface; the parked design names the same claims.
users.jsonin the repo is a security tradeoff. It’s not secret (emails and roles are not credentials), but a contributor with merge rights becomes a de facto admin grantor. We rely entirely on PR review for access control until we replace it.- The Auth0 Action depends on a network call to Cloudflare during login. If Cloudflare is degraded, the Action silently fails and the user lands as
viewer(locked out). We should ship a fail-open mode — but that means a degraded Cloudflare = unrestricted access. There’s no clean answer; document and pick. module_scopeis in the claim shape but unused in Phase I. It only does work whensupervisorthaws from60-parking/full-role-matrix.md.- ITBA “tenant” is informational only in Phase I. No cross-org enforcement code exists; the LBZF Jetson and the ITBA Jetson are independent databases. Real multi-tenant lives in
60-parking/multi-tenant-auth0.md. - No account-recovery story. Auth0 owns it for the user account, but if
users.jsonaccidentally drops Sophia’s role, the deploy is the breakglass. Need a runbook.
Rollout
Section titled “Rollout”- Now (May 2026): create
users.jsonwith Sophia, Armando, Andrew, Mariana (TBD email), Ronald, ITBA leads; ship it bundled into the Cloudflare Worker; configure Auth0 Action to read it (or call back to a/users.jsonURL on Workers). - Before Argentina (2026-05-15): Auth0 Action live, emitting
https://lbzfai.com/rolesingular-string claim; demo “Sophia logs in → sees admin UI” vs “an unknown email logs in → sees ‘access pending’”. ITBA members can authenticate against the same tenant. - Before Pereira (Jul 2026): Jetson middleware live; Tailscale-identity round-trip end-to-end tested; Ronald logs in via Tailscale and sees the engineer UI; Mariana logs in from lbzfai.com and sees the executive read-only UI (mocked data per ADR-005).
- Phase II: D1 migration; thaw
60-parking/cloudflare-tunnel-from-jetson.md(Worker proxy → real data for Mariana); thaw60-parking/full-role-matrix.mdif supervisors are hired. - Phase III: thaw
60-parking/multi-tenant-auth0.mdif a non-LBZF customer signs.
Unblocks: every other backend doc’s auth check; frontend’s role-gated routes.