This page documents the internal, staff-only portal (
/v1/provisioning/*, excluding the
platform-key M2M endpoint covered in Provisioning API reference).
It is a different surface from Client Provisioning’s
onboard_client() tenant setup — this portal onboards a client’s ML model: uploading their
historical issue data, training a workspace model on it, evaluating it against quality gates,
and hosting it for inference. Every claim below is verified against the code on this branch.Turned off by default
The entire portal is gated behind one flag:/v1/provisioning/client-profiles/*, /v1/provisioning/training-runs/*,
/v1/provisioning/models/*, and /v1/provisioning/services/* route returns 404 Not Found —
never 403. This is deliberate: an unauthenticated probe should not even be able to learn the
portal exists. The check runs as the outermost FastAPI dependency on the router, ahead of auth:
app/routers/provisioning_portal.py)
POST /v1/provisioning/clients — the platform-key M2M tenant-creation endpoint documented in
Provisioning API reference — is a separate router with a
separate guard (require_provisioning_key) and is not affected by
PROVISIONING_PORTAL_ENABLED. Turning the portal off does not turn off tenant provisioning.The seven stages
The portal’s UI/API contract is organized into seven numbered stages. This numbering comes from the build plan’s portal-stage↔API-contract table and is corroborated directly in the router’s own comments (app/routers/provisioning_portal.py: “config form (portal step 03)”,
“portal step 03->04”, “portal stage 04’s live console”, “go-live, step 06->07”).
| Stage | Name | What happens | Endpoints |
|---|---|---|---|
| 01 | Client profile | Create the client record (display name, tenant slug, industry, primary data format) | POST/GET/PATCH /provisioning/client-profiles[/{id}] |
| 02 | Historical data | Register a data source (upload / S3 / database) and upload the file | POST .../data-sources, POST .../data-sources/{id}/upload, POST .../data-sources/{id}/validate |
| 03 | Training config | Choose an embedding model + cluster preset for the training run | GET /provisioning/model-registry |
| 04 | Train model | Submit the training run; watch its live console | POST .../training-runs, GET .../training-runs/{id}, GET .../training-runs/{id}/events (SSE) |
| 05 | Evaluate | Run the evaluation gate suite; approve, override, or reject | POST .../evaluate, GET .../evaluation, POST .../approve, POST .../override, POST .../reject |
| 06 | Host service | Attach an inference service to the approved artifact | POST .../services, GET /provisioning/services/{id} |
| 07 | Go live | Activate the service; view the go-live summary | POST /provisioning/services/{id}/activate, GET .../summary |
RBAC model
Staff, not tenant roles
Staff access is a global, cross-organization concept, entirely separate from a workspace’s tenantmemberships.role (owner/admin/analyst/viewer). A user becomes staff by having a live
row in staff_profiles, granted via:
staff_role: null revokes.) This endpoint itself requires staff:admin — granting staff
access is admin-only, one level above the router’s own “any staff” floor. Four staff roles
exist, ranked lowest to highest:
app/security.py)
The staff JWT claim is set only from a live staff_profiles row at token-issue time
(resolve_staff_role) — never derived from an identity provider’s attributes, request headers,
or any other caller input. A token minted before a grant (or after a revoke) keeps the old
claim until it expires or the user re-authenticates.
require_staff + require_feature — two independent checks
Every route under the portal router carries the router-level dependencyrequire_staff() (any
staff claim required — 403 otherwise). Many routes add a second, independent check on top:
require_staff("admin")/require_staff("ml_eng")— a minimum staff role, checked againstSTAFF_ROLE_ORDER.require_feature("internal.<key>")— the caller’s effective feature set (fromrole_features, keyed offstaff:<role>) must contain the named key.
/v1/me’s features[] renders controls off of), the
staff role check is the hard boundary” (app/store/rbac_seed.py).
The six internal.* feature keys
| Key | Guards | Who has it |
|---|---|---|
internal.provisioning | Access to the portal generally | staff:fde, staff:ml_eng, staff:admin |
internal.eval_approve | D3 approve, D5 reject | staff:ml_eng, staff:admin |
internal.eval_override | D4 override | staff:admin only |
internal.host_manage | E1 create/attach, E5 activate/retire (the service lifecycle) | staff:ml_eng, staff:admin |
internal.host_promote | E3 shadow/canary/promote/rollback (the artifact promotion pipeline) | staff:ml_eng, staff:admin |
internal.retrain_manage | F1 launch a manual/backfill retrain | staff:ml_eng, staff:admin |
internal.host_manage and internal.host_promote are a deliberate split, not a rename of
one boolean: attach/retire changes which service exists at all; promote/rollback changes which
model version live traffic is routed to — different blast radius, so they’re gated separately
(app/store/rbac_seed.py).
The four staff roles’ effective feature sets
| Staff role | Feature keys (in addition to the read-only product views every staff role gets) |
|---|---|
staff:support | internal.cross_org_read only — read-only, no portal access |
staff:fde | internal.provisioning |
staff:ml_eng | internal.provisioning, internal.model_registry, internal.eval_approve, internal.host_manage, internal.host_promote, internal.retrain_manage |
staff:admin | Everything staff:ml_eng has, plus internal.eval_override and internal.cross_org_read |
ROLE_FEATURES in app/store/rbac_seed.py. Only staff:admin can override a
failed evaluation — see Evaluation Gates for what that
means and why it exists.
Per-request overrides beyond the role floor
A few mutations check something the router-level and endpoint-level dependencies can’t see — the request body:app/routers/provisioning_portal.py) — hosting a client on the pro Render
plan additionally requires staff:admin, even though attach itself only needs staff:ml_eng +
internal.host_manage.
Audit trail
Every mutation writes anaudit_log row via repo.write_audit(...), including
before/after snapshots. Staff-global actions (like granting staff access itself) are
audited against the acting admin’s own workspace_id — audit_log.workspace_id is NOT NULL and there is no workspace-agnostic anchor for a staff-global action. This is a
documented, deliberate choice, not an oversight.
Related pages
- Model Lifecycle — the state machines stages 04-07 drive
- Evaluation Gates — what stage 05 actually checks
- Inference & Queue — what “hosting” (stage 06-07) means on FREE vs PROD
- Onboard a client — the operator walkthrough
- Provisioning API reference — full endpoint/request/response detail for both provisioning surfaces