Skip to content

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)

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
  • 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, distinct org_id informationally).
  • The supervisor and research roles — parked to docs/design/60-parking/full-role-matrix.md. Phase I uses only admin / engineer / executive (plus viewer deny-by-default).

Where the user/org table lives — the big decision

Section titled “Where the user/org table lives — the big decision”

Three options:

OptionProsCons
A. Only in Auth0 (use app_metadata and roles, no DB)Zero new infra; Auth0 dashboard is the management UIapp_metadata is awkward for org/module scoping; querying “which users belong to module 1” requires Management API calls; rate-limited
B. SQLite on the JetsonOne DB to back up; one source of truthCloud 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; smallNew piece of infra to operate
D. Hybrid: Auth0 holds identity, Cloudflare D1 holds the org/role/profile, Jetson has a read-only mirrorEach piece does what it’s best atMore 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)
// 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.

Capabilityviewerengineerexecutiveadmin
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.

  1. Add custom claims to the access token carrying the user’s role, org_id, and module_scope, computed via an Auth0 Action that looks up email in our users.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/rolesingular string ("admin" | "engineer" | "executive"). The claim name is exactly role (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 or null. Reserved for the parked supervisor role; emit null for every Phase I user.
    // Auth0 Action: onExecutePostLogin
    exports.onExecutePostLogin = async (event, api) => {
    const email = event.user.email;
    const userRow = await fetchFromCloudflare(email); // D1 / Worker
    if (userRow && userRow.active) {
    api.accessToken.setCustomClaim('https://lbzfai.com/role', userRow.role); // singular string
    api.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.

  2. 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.

  3. Roles created in Auth0: admin, engineer, executive for Phase I. (supervisor and research created 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).

  4. Email-verified gateemail_verified=true required; otherwise the Action does not set the role claim and the user lands as viewer.

  5. 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 check

Phase 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.

Until we have a “manage users” UI, adding a new user is a PR:

  1. Edit users.json in the repo
  2. Open PR; reviewed by Sophia/Andrew
  3. Merge to main → Cloudflare auto-deploys → user can log in on next session
  4. 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.

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.

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}).

  • 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 (engineer can override cycles, executive cannot — executive is “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: 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 executive access? — owner: Mariana. (supervisor and research are parked; no Phase I assignment needed.)
  • OPEN: ITBA team — do they each get admin on the ITBA Jetson, or only Sophia + Andrew? — owner: Andrew + ITBA leads. Defaulting to admin on the ITBA Jetson, no role on the LBZF Jetson.
  • OPEN: when does users.json graduate to D1? Threshold: >25 users or first request for a self-service “invite a teammate” UI. Owner: Sophia / Andrew.
  • OPEN: privacy posture on display_name storage — 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.
This doc depends onOwner bucketWhat we need
Frontend role-gating UIAgent CImplements role-aware render; uses same role names
Dashboard middlewareThis bucket (dashboard-api.md)Reads X-LBZF-Identity + Tailscale headers
Cloud proxy / Worker routeThis bucket (lbzfai-jetson-integration.md)Adds X-LBZF-Identity header, signs with shared secret
Auth0 tenant configExisting infraAuth0 Action edited, API registered, roles created
This doc impliesOwner bucketAsk
Frontend reads https://lbzfai.com/role claim (singular string) to render role-aware shellsAgent CRead 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 endpointthis bucketPlumbing; audit_log DB table is parked (see 60-parking/audit-log.md)
  1. 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.
  2. users.json in 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.
  3. 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.
  4. module_scope is in the claim shape but unused in Phase I. It only does work when supervisor thaws from 60-parking/full-role-matrix.md.
  5. 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.
  6. No account-recovery story. Auth0 owns it for the user account, but if users.json accidentally drops Sophia’s role, the deploy is the breakglass. Need a runbook.
  • Now (May 2026): create users.json with 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.json URL on Workers).
  • Before Argentina (2026-05-15): Auth0 Action live, emitting https://lbzfai.com/role singular-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); thaw 60-parking/full-role-matrix.md if supervisors are hired.
  • Phase III: thaw 60-parking/multi-tenant-auth0.md if a non-LBZF customer signs.

Unblocks: every other backend doc’s auth check; frontend’s role-gated routes.