RoadIntel sign out

Operations

Platform guide

Tenants, users, roles and corridor scope — how the hierarchy is built and who may build it.

1The hierarchy

Two hierarchies, deliberately separate. A user is platform-wide; access is per tenant. One person is one row with one password, and holds a membership in each tenant they may act in — which is what makes a consultant serving three clients one account rather than three.

Platform — the reserved _platform tenant

platform_admin Everything, in every tenant. Grantable by nobody through any route — CLI only. A console that can grant it to anyone is a console where the reserved tenant stops being reserved.
platform_support Read-only across every tenant. Enforced on the HTTP method, not per route, so a write added later is refused by default rather than open until someone remembers.

Tenant — one per client

admin The tenant's owner. May grant admin, sub_admin, operator, viewer. Ignores corridor scope entirely, so a mis-written row cannot lock a tenant out of its own corridors.
sub_admin Delegated admin, normally narrowed to corridors. May grant only operator and viewer, and only within its own corridors.
operator Does the work — capture, ingest, survey, runs, work orders.
viewer Reads.

The second dimension is corridor scope, which narrows a membership, not a role:

scope_corridorsMeans
NULLevery corridor in the tenant
["NH16"]exactly those
[]none at all — can sign in, sees nothing
NULL and [] must never collapse into each other. If they did, a mis-set scope would read as full access. Every membership written before the column existed is NULL, and it has to keep meaning "all".

2Cold start you

From an empty database to a working platform admin. This is the only path that creates an account no route can create: every user-creating route needs tenant:admin, which needs a membership, which needs somebody to have granted one. On a fresh database nobody has.

  1. Apply the migrations (Postgres only)

    All of them, in filename order. They are idempotent, so re-running is safe — applying a subset is not.

    for f in deploy/sql/*.sql; do psql -v ON_ERROR_STOP=1 -f "$f"; done

    On SQLite the schema is created at import; nothing to run.

  2. Register the reserved tenant

    export ROADINTEL_DB_URL="$(python -m app.dburl)"
    PYTHONPATH=. python scripts/manage_tenants.py create --id _platform --name Kologic

    _platform fails the tenant-id regex on purpose — a leading underscore is what keeps it out of the space real tenants draw from — and is exempted by name, so _anything_else is still rejected. The row must exist because memberships reference it ON DELETE RESTRICT.

  3. Create the first platform admin

    PYTHONPATH=. python scripts/bootstrap_admin.py \
        --email ops@kologic.ai --tenant _platform --name "Platform Ops"
      user     u_9e5aaa91c2ae  (ops@kologic.ai)
      tenant   _platform · platform_admin  (no client tenant)
      invite   aWNlV5kxfI_FFHq2SXcJ1BZ8pMoNvR3tYgKdWuE7LxA
      expires  in 48 h

    --tenant _platform makes a pure platform admin with no client membership — the right shape, since a platform admin who is also somebody's tenant admin has a wider blast radius than the role needs. To make someone both, name the client tenant and add --platform.

    The invite is printed once. It is stored only as a hash and there is no mail in this estate — hand it over out of band. And bootstrap a second platform admin before you rely on the first: one lost password is otherwise a lockout with no in-product recovery, because _platform holds the memberships you would need to undo it.
  4. Redeem, and confirm

    Open /login?code=<the code>, or:

    curl -sX POST /auth/redeem -H 'content-type: application/json' \
      -d '{"token":"<the code>","password":"<a strong one>"}'

    Redeeming sets the password, flips the account from invited to active, and signs them in. Single use. If it lapses, re-run the bootstrap — it re-invites rather than duplicating the account.

3Onboard a tenant you

Three moves: register it, give it corridors, invite its admin. After that the tenant runs itself — their admin creates their own people.

StepConsoleAPI
RegisterTenants → New tenant POST /platform/tenants
Add corridorstenant → Corridors POST /platform/tenants/{id}/corridors
Invite their admintenant → Users POST /platform/tenants/{id}/users

A tenant id becomes an S3 key prefix (clips/{tenant}/{session}/…), so it must match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$.

curl -sX POST /platform/tenants/acme/corridors -b jar \
  -H "x-roadintel-csrf: $CSRF" -H 'content-type: application/json' \
  -d '{"id":"NH16","name":"NH16 Vizag",
       "centreline":[[83.2,17.7],[83.3,17.75],[83.4,17.8]]}'

centreline takes the points directly; road_id traces an existing reference route instead.

curl -sX POST /platform/tenants/acme/users -b jar \
  -H "x-roadintel-csrf: $CSRF" -H 'content-type: application/json' \
  -d '{"email":"boss@acme.com","role":"admin"}'
{"user_id":"u_…","email":"boss@acme.com","role":"admin","created":true,
 "invite_token":"UQPXYNezY1VjTY…",
 "note":"Shown once. Not stored, not emailed — hand it over out of band."}
CSRF. Every state-changing call needs header x-roadintel-csrf matching the ri_csrf cookie. The browser console does this for you. The session cookie is Secure, so a client talking over plain http silently drops it and every call looks unauthenticated.

4Build a team you

Done by the tenant's own admin at Access. Identical contract to the platform routes, so there is one rule to learn rather than two.

# an operator
POST /admin/users  {"email":"driver@acme.com","role":"operator"}

# a delegated sub-admin, narrowed to one corridor
POST /admin/users  {"email":"lead@acme.com","role":"sub_admin",
                    "scope_corridors":["NH16"]}

Optional fields: display_name, expires_at (an ISO stamp — the membership lapses on its own, with nothing to run) and scope_corridors.

RouteDoes
GET /admin/userslist this tenant's people
POST /admin/usersinvite (or re-invite)
POST /admin/users/{id}/rolechange role and/or scope
POST /admin/users/{id}/resetmint a password-reset token
POST /admin/users/{id}/statussuspend / reinstate
GET /admin/users/{id}/sessionstheir live sign-ins
GET /admin/auth-eventsthis tenant's audit trail
GET / POST /admin/tokensmachine tokens

Custom roles

The four built-ins are seeded per tenant on first membership. A tenant may define its own from the 28-key permission catalogue — GET /platform/permissions lists it, and five of those keys are enforced by nothing and are marked so rather than quietly omitted. A role the tenant has not defined is refused by a foreign key, so a typo is a database error rather than a silent no-access account.

5Delegation & corridor scope

What a sub_admin scoped to ["NH16"] may and may not do — every row below verified end to end.

AttemptsResult
List corridorssees ["NH16"] only
GET /corridors/NH65 404 — not 403, which would confirm NH65 exists
Invite an operator200
Invite an admin 403 role_above_grantor
Invite with ["NH16","NH65"] 403 — cannot widen
Invite with no scope 200, invitee inherits ["NH16"]
Silence inherits; it does not widen. A scoped granter who omits scope_corridors does not hand out the whole tenant. That is the row worth remembering.
| email          | tenant | role      | scope_corridors      |
| boss@acme.com  | acme   | admin     | NULL (all corridors) |
| lead@acme.com  | acme   | sub_admin | ["NH16"]             |
| heir@acme.com  | acme   | operator  | ["NH16"]   <- inherited

Scope is enforced in two places, and both are needed: the scoped repo narrows corridor-keyed reads, so a route that forgot its guard still cannot return another team's rows; and the request guard refuses any path carrying an out-of-scope corridor id.

6Day to day you

All under Platform, all platform_admin unless marked otherwise.

ActionRoute
Every tenant, with counts (also support)GET /platform/tenants
Tenant detail (also support)GET /platform/tenants/{id}
Rename / suspend / reinstatePATCH /platform/tenants/{id}
Every user, across every tenant (also support)GET /platform/users
Grant / revoke in any tenantPOST /platform/users/{id}/membership
Force a password resetPOST /platform/users/{id}/reset
Suspend a user platform-widePOST /platform/users/{id}/status
Sign a user out everywhereDELETE /platform/users/{id}/sessions
A tenant's audit trailGET /platform/tenants/{id}/audit
Usage countsGET /platform/tenants/{id}/usage
Revoke a machine tokenDELETE /platform/tenants/{id}/tokens/{tid}

Suspending a user is platform-wide; revoking a membership is not. Different blast radii, and the console keeps them apart. Suspending a tenant refuses every request carrying it but does not revoke sessions — reinstating restores access with no new sign-in.

Every cross-tenant read writes an audit row. Platform staff once saw nothing until granted a time-boxed membership in a client, so access was itself data. Reading directly removed that barrier; the audit row is what remains of the guarantee. Do not add a /platform/* route that skips it.

7Emergency login you

Mints a session as a user in a tenant. Deliberately separated from ordinary administration in the console — a red control, not a row action — because impersonation and management are different acts with different consequences.

POST /platform/tenants/{id}/emergency-login

It rotates: a new session is minted and the previous revoked, with rotated_from recording the change, so a cookie stolen before the switch is dead. Use it to reproduce what a client sees — not as a shortcut for support.

8Offboarding you

Suspends the tenant, revokes every membership and every machine token, and purges nothing. Reversible.

POST   /platform/tenants/{id}/export     # first
DELETE /platform/tenants/{id}            # then

In the console both live in the tenant's Settings tab, side by side, because export-before-delete is one workflow and separating them invites doing the second without the first.

There is no delete. A DELETE WHERE tenant_id = 'x' would leave orphaned rows that no screen can see, no export can prove ownership of, and no later purge can reach — tenant_id is a bare column with no foreign key back to the registry. Destroying data is a deliberate out-of-band act.

9Traps & gaps

Stated because a guide that omits its holes is the more dangerous document.

manage_tenants create fails on a truly virgin database

OperationalError: no such table: tenants — the CLI does not create the schema. On Postgres apply the migrations first; on SQLite import the app once.

A partial migration set starts cleanly and then dies — fixed

The readiness check knew eight table names and tenant_roles was not among them, while the local docs told you to apply only 001/004/005/006. That database passed the check, booted, and failed inside the first membership grant with a raw traceback — the exact failure the check exists to turn into a sentence.

bootstrap_admin --tenant _platform always failed — fixed

The script granted admin in whatever tenant you named, and _platform is constrained to hold platform roles only. The one command that creates the first platform admin could not create one — by the natural invocation, and the path you would reach for during a lockout.

Known gaps

  • Object-id-keyed writes are not corridor-scoped. Retracting a batch, saving a survey, updating a work order and saving a capture session are keyed by a child object id, so neither enforcement layer can narrow them: a scoped sub_admin who learns an out-of-scope batch id can still retract it.
  • Machine tokens have never been exercised end to end since the load balancer stopped stripping them.
  • Work orders are the only lifecycle with no database CHECK — a bad status is stored rather than refused.
  • Three permissions have no subsystem behind them — AI reports, billing and camera registration — not merely no guard.

Deeper reference lives in the repository: DOMAIN-MODEL.md (what the entities are), STATE-MACHINES.md (their lifecycles), RBAC-MATRIX.md (the authoritative permission table, generated from the code) and PLATFORM-GUIDE.md.