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_corridors | Means |
|---|---|
NULL | every corridor in the tenant |
["NH16"] | exactly those |
[] | none at all — can sign in, sees nothing |
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.
-
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.
-
Register the reserved tenant
export ROADINTEL_DB_URL="$(python -m app.dburl)" PYTHONPATH=. python scripts/manage_tenants.py create --id _platform --name Kologic
_platformfails 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_elseis still rejected. The row must exist because memberships reference itON DELETE RESTRICT. -
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 _platformmakes 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_platformholds the memberships you would need to undo it. -
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
invitedtoactive, 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.
| Step | Console | API |
|---|---|---|
| Register | Tenants → New tenant | POST /platform/tenants |
| Add corridors | tenant → Corridors | POST /platform/tenants/{id}/corridors |
| Invite their admin | tenant → 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."}
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.
| Route | Does |
|---|---|
GET /admin/users | list this tenant's people |
POST /admin/users | invite (or re-invite) |
POST /admin/users/{id}/role | change role and/or scope |
POST /admin/users/{id}/reset | mint a password-reset token |
POST /admin/users/{id}/status | suspend / reinstate |
GET /admin/users/{id}/sessions | their live sign-ins |
GET /admin/auth-events | this tenant's audit trail |
GET / POST /admin/tokens | machine 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.
| Attempts | Result |
|---|---|
| List corridors | sees ["NH16"] only |
GET /corridors/NH65 |
404 — not 403, which would confirm NH65 exists |
Invite an operator | 200 |
Invite an admin |
403 role_above_grantor |
Invite with ["NH16","NH65"] |
403 — cannot widen |
| Invite with no scope | 200, invitee inherits ["NH16"] |
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.
| Action | Route |
|---|---|
| Every tenant, with counts (also support) | GET /platform/tenants |
| Tenant detail (also support) | GET /platform/tenants/{id} |
| Rename / suspend / reinstate | PATCH /platform/tenants/{id} |
| Every user, across every tenant (also support) | GET /platform/users |
| Grant / revoke in any tenant | POST /platform/users/{id}/membership |
| Force a password reset | POST /platform/users/{id}/reset |
| Suspend a user platform-wide | POST /platform/users/{id}/status |
| Sign a user out everywhere | DELETE /platform/users/{id}/sessions |
| A tenant's audit trail | GET /platform/tenants/{id}/audit |
| Usage counts | GET /platform/tenants/{id}/usage |
| Revoke a machine token | DELETE /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.
/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-loginIt 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} # thenIn 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.
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.
OperationalError: no such table: tenants
— the CLI does not create the schema. On Postgres apply the
migrations first; on SQLite import the app once.
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.
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_adminwho 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.