Skip to main content
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:
PROVISIONING_PORTAL_ENABLED=false   # default
When off, every /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:
async def _require_portal_enabled() -> None:
    if not settings.PROVISIONING_PORTAL_ENABLED:
        raise ApiError(404, "not_found", "Not found")
(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”).
StageNameWhat happensEndpoints
01Client profileCreate the client record (display name, tenant slug, industry, primary data format)POST/GET/PATCH /provisioning/client-profiles[/{id}]
02Historical dataRegister a data source (upload / S3 / database) and upload the filePOST .../data-sources, POST .../data-sources/{id}/upload, POST .../data-sources/{id}/validate
03Training configChoose an embedding model + cluster preset for the training runGET /provisioning/model-registry
04Train modelSubmit the training run; watch its live consolePOST .../training-runs, GET .../training-runs/{id}, GET .../training-runs/{id}/events (SSE)
05EvaluateRun the evaluation gate suite; approve, override, or rejectPOST .../evaluate, GET .../evaluation, POST .../approve, POST .../override, POST .../reject
06Host serviceAttach an inference service to the approved artifactPOST .../services, GET /provisioning/services/{id}
07Go liveActivate the service; view the go-live summaryPOST /provisioning/services/{id}/activate, GET .../summary
This 7-stage UI numbering is not the same enum as client_profiles.status. The status column only has six reachable values plus one declared-but-unused one — see Model Lifecycle for the exact state machine. “Evaluate” (stage 05) has no dedicated client status at all: a client profile sits at status=trained for the entire train→evaluate→approve window. The evaluation’s own state lives on the separate training_runs/eval_runs tables. Stage numbering and client_profiles.status are two different axes — don’t conflate them.

RBAC model

Staff, not tenant roles

Staff access is a global, cross-organization concept, entirely separate from a workspace’s tenant memberships.role (owner/admin/analyst/viewer). A user becomes staff by having a live row in staff_profiles, granted via:
POST /v1/provisioning/staff
Authorization: Bearer <staff:admin JWT>
{"user_id": "usr_...", "staff_role": "ml_eng"}
(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:
STAFF_ROLE_ORDER: Dict[str, int] = {"support": 0, "fde": 1, "ml_eng": 2, "admin": 3}
(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 dependency require_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 against STAFF_ROLE_ORDER.
  • require_feature("internal.<key>") — the caller’s effective feature set (from role_features, keyed off staff:<role>) must contain the named key.
Both checks are enforced server-side, in addition to each other — “defense in depth: the feature matrix is the UI’s contract (what /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

KeyGuardsWho has it
internal.provisioningAccess to the portal generallystaff:fde, staff:ml_eng, staff:admin
internal.eval_approveD3 approve, D5 rejectstaff:ml_eng, staff:admin
internal.eval_overrideD4 overridestaff:admin only
internal.host_manageE1 create/attach, E5 activate/retire (the service lifecycle)staff:ml_eng, staff:admin
internal.host_promoteE3 shadow/canary/promote/rollback (the artifact promotion pipeline)staff:ml_eng, staff:admin
internal.retrain_manageF1 launch a manual/backfill retrainstaff: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 roleFeature keys (in addition to the read-only product views every staff role gets)
staff:supportinternal.cross_org_read only — read-only, no portal access
staff:fdeinternal.provisioning
staff:ml_enginternal.provisioning, internal.model_registry, internal.eval_approve, internal.host_manage, internal.host_promote, internal.retrain_manage
staff:adminEverything staff:ml_eng has, plus internal.eval_override and internal.cross_org_read
Source of truth: 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:
if body.plan == "pro" and auth.staff_role != "admin":
    raise ApiError(403, "forbidden", "plan='pro' requires staff:admin")
(E1 create-service, 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 an audit_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.