Skip to main content

Onboarding a New Client

Runbook for the dev/ops team who provision clients. It admits a new customer or hauler org into the Orbit whitelist so its people can sign in. This is the operational face of the Spec 13 machinery โ€” no new code, just the ordered steps that otherwise live only as tribal knowledge.

The whitelist is app-layer authorization on top of Entra External ID sign-in: the gatekeeper (resolve_org_for_email) maps a verified email โ†’ Organization โ†’ scoped user, and an unmatched email is a 403 (default-deny). Onboarding = getting the right rows into organizations + allowed_users + allowed_domains.

This is the client-team's counterpart

Once you finish these steps, the client's own org-admin follows the Getting Started with Orbit quickstart to sign in and invite their teammates. Point them there rather than walking them through the SQL below.

1. Prerequisite reference rows must exist firstโ€‹

The org's linkage target has to be present before you create the org, because migration 0019's CHECK forbids an unscopeable org (a customer needs tenant_id, a hauler needs vendor_id).

  • Customer org โ†’ the tenants row (and any sites you'll pin with site_code). Both are seeded by migration 0010 (public.tenants, public.sites) โ€” e.g. the MARS tenant and its ARK / GNV sites.
  • Hauler org โ†’ the haulers row; organizations.vendor_id must equal haulers.id. Also seeded by 0010 (wm / rsg / gfl / wc).
No self-serve tooling for brand-new reference rows โ€” stated honestly

There is no admin UI and no orbit subcommand to create a brand-new tenant / site / hauler. A new pilot beyond the seeded set means either editing migration 0010's seed (for a from-scratch rebuild) or a manual INSERT into the target DB. Do this step deliberately and record the ids โ€” everything downstream keys on them.

2. Create the whitelist rowsโ€‹

Three rows admit an org: the organizations row, its first allowed_users org-admin (the invite-chain root), and an allowed_domains fallback. The canonical primitive is seed_sandbox.py::seed_orgs, which mirrors migration 0020 exactly.

organizations carries: name, org_type (customer | hauler, frozen DB CHECK), the linkage id (tenant_id for customer, vendor_id for hauler), optional site_code (customer site pin), and domain. Emails are stored lower-cased so the gatekeeper's case-insensitive lookup hits without a functional index.

The idempotent INSERT pattern from seed_orgs โ€” guard the org on name, ON CONFLICT DO NOTHING on the natural unique columns:

INSERT INTO public.organizations (name, org_type, vendor_id, tenant_id, site_code, domain)
SELECT 'Acme Foods', 'customer', NULL, 'ACME', NULL, 'acme.com'
WHERE NOT EXISTS (SELECT 1 FROM public.organizations WHERE name = 'Acme Foods');

INSERT INTO public.allowed_users (email, org_id, status, role)
SELECT 'orbit-admin@acme.com', o.id, 'active', 'org-admin'
FROM public.organizations o WHERE o.name = 'Acme Foods'
ON CONFLICT (email) DO NOTHING;

INSERT INTO public.allowed_domains (domain, org_id)
SELECT 'acme.com', o.id
FROM public.organizations o WHERE o.name = 'Acme Foods'
ON CONFLICT (domain) DO NOTHING;

The first org-admin is seeded status='active', role='org-admin' โ€” that role is what unlocks step 4's invite endpoint. Both status (invited | active) and role (org-admin | member) are frozen DB CHECKs from migration 0019.

3. Org-admin's first login (self-service email OTP)โ€‹

The admin logs in with "Sign in with Microsoft" and email OTP โ€” no password, no pre-provisioned Entra account.

Entra External ID is configured isSignUpAllowed=true with appRoleAssignmentRequired dropped (resolved 2026-08-03), so the app-layer whitelist is the sole authorization control. On return, the backend verifies the RS256 token and yields the verified email; the gatekeeper resolves it against the allowed_users row you seeded in step 2 โ†’ the org-admin user. A whitelisted admin lands in the app; a non-whitelisted self-signup gets a 403 โ†’ the Request-Access page.

MOCK_USERS is not an authorization surface

MOCK_USERS is the internal HS256 persona list for Wasteology / demo identities. It is not an authorization path for external users โ€” never add a client there to grant access. The only lever for external access is an allowed_users (or allowed_domains) row.

4. Org-admin invites teammatesโ€‹

The rest of the org grows through POST /api/v1/invitations (create_invitation), because there is no internal admin UI. The admin sends {email, role}.

  • org_id comes from the caller's token, never the body โ€” an admin can only invite into their own org.
  • The email is normalized (strip + lower-case) exactly as the gatekeeper looks it up, stored status='invited'.
  • role is validated in Python (member | org-admin, else 422).
  • A duplicate returns a structured 409.

Alternatively, the allowed_domains row from step 2 already admits any @domain address automatically as role member (the gatekeeper's domain fallback) โ€” so an org whose people all share one email domain may need no per-person invites at all.

5. Environment differences โ€” sandbox seeds, prod does notโ€‹

Sandbox/dev can be bootstrapped with tooling; prod cannot, by design. In prod the whitelist rows are inserted by hand (as the admin), and work orders are created through the API.

EnvironmentOnboarding path
Sandbox / devuv run orbit db seed --env sandbox runs seed_sandbox.py (which calls seed_orgs); uv run orbit sandbox reset rebuilds sandbox. Both re-lay the same five idempotent pilot orgs.
Prodorbit db seed and orbit sandbox reset both refuse --env prod unconditionally. Onboard a prod org by running the step-2 SQL by hand against orbit_prod; make prod work orders via POST /api/v1/work-orders (the API is the only prod fixture path).

6. The orbit_rw GRANT gotcha (prod only)โ€‹

If onboarding a client ever requires a new table the app reads, that table needs an explicit GRANT ... TO orbit_rw in the same migration, or external logins 500 in prod (not 401/403).

This is not hypothetical

Migrations 0019 / 0020 created the whitelist tables but omitted the grant, and every external login 500'd in prod (permission denied for table allowed_users) until 0021 fixed it. Sandbox โ€” a single owner role โ€” never saw it.

For today's onboarding (only pre-granted tables involved) this step is a no-op, but keep it on the checklist for any schema change onboarding touches. The rule: read-only reference tables โ†’ GRANT SELECT; tables the app writes โ†’ GRANT SELECT, INSERT, UPDATE, DELETE. ALTER DEFAULT PRIVILEGES does not cover it (it only covers tables created by the role that ran the ALTER).

7. Verify onboarding workedโ€‹

Two checks confirm success: the gatekeeper resolves the seeded admin, and a real invited address completes an end-to-end OTP login.

  • Gatekeeper resolution. SELECT the allowed_users row for the seeded org-admin email; confirm org_id, role='org-admin', status='active'.

    SELECT au.email, au.org_id, au.role, au.status, o.name, o.org_type
    FROM public.allowed_users au
    JOIN public.organizations o ON o.id = au.org_id
    WHERE au.email = 'orbit-admin@acme.com';
  • End-to-end OTP. Invite a real mailbox and drive the "Sign in with Microsoft" OTP flow through to a 200 with live data.

Seeded orbit-admin@<domain> addresses are placeholders, not real mailboxes

They prove the row exists but can never receive an OTP, so they cannot validate the login leg. Use a real invited address for the end-to-end check. Prod validation must run on the deployed SWA origin โ€” prod CORS_ORIGINS is a single origin, so a localhost driver 400s on /api/v1/*.

Quick checklistโ€‹

  1. Prerequisite tenants/sites (customer) or haulers (hauler) row exists โ€” ids recorded.
  2. Three whitelist rows created (organizations + first org-admin in allowed_users + allowed_domains), emails lower-cased.
  3. Org-admin signs in via email OTP โ€” lands in the app.
  4. Org-admin invites teammates (or domain fallback auto-admits @domain).
  5. Sandbox seeded via orbit db seed; prod rows inserted by hand.
  6. orbit_rw GRANT present for any new table (no-op today).
  7. Gatekeeper resolves the admin and a real mailbox completed OTP end-to-end.