Provider / management plane
This is the operator surface an MSP (a managed service provider — an
organization that self-hosts probectl once and resells it to many customer
tenants) or an internal platform team uses to run a
multi-tenant probectl: provisioning and suspending tenants, watching
fleet-wide health across all of them, and — under tight controls — reaching into
a single tenant's telemetry. It lives in ee/provider and activates only when
the license grants provider_plane. Without that license, /provider/* returns a
plain 404: the feature is hidden, not locked behind an upsell wall.
The single most important thing to understand: a provider operator is a different kind of user from a tenant user, in a different security domain, and running a tenant gives an operator zero ability to read that tenant's data. The operator is the landlord: the master key opens the boiler room and the breaker panel, never the tenants' filing cabinets. Everything below enforces that line.
The privilege model
Provider operators are not tenant users. They are a distinct privilege domain — a self-contained authentication world with its own accounts, sessions, and audit, whose credentials mean nothing in the tenant world (and vice versa) — with their own everything:
- Their own accounts (
provider_operators), their own sessions (a separate cookie,probectl_provider_session;SameSite=Strict, meaning the browser refuses to attach it to any navigation that starts on another site, so a malicious page cannot ride an operator's session; a 4-hour absolute TTL plus the default-onPROBECTL_SESSION_IDLE_TIMEOUTinactivity wall, held in memory by design — a restart deliberately re-authenticates this high-privilege domain rather than persisting its sessions), and their own tamper-evident audit chain (provider_audit_events). - Multi-factor auth is mandatory — there is no password-only login. Every
sign-in is email + password (hashed with PBKDF2-HMAC-SHA256, per NIST SP
800-132 — a deliberately slow, salted password hash, so a stolen database of
hashes resists brute force) plus a TOTP code (RFC 6238, the standard
authenticator-app
6-digit code). TOTP secrets are envelope-sealed at rest — encrypted under the
deployment's envelope key rather than stored readable — which is exactly why
the provider plane refuses to even start without
PROBECTL_ENVELOPE_KEY. - Separation of duties — the principle that no single account both grants a
power and wields it. Two roles:
adminmanages operators;operatorruns tenant lifecycle and break-glass. An admin holds both. Disabling an operator revokes their live sessions immediately, not at the next TTL (TTL — time-to-live, the lifespan after which a session or grant self-expires). - No implicit read access to tenant telemetry — and this is enforced in the
database, not just in handler code. Every provider query runs as the
probectl_providerPostgres role (viatenancy.InProvider). That role's only telemetry-adjacent grant isSELECTonagents, through an explicitprovider_fleet_readpolicy (so the fleet view works). It has no grant at all ontests,results, or any other tenant table — so even a buggy or malicious handler physically cannot read tenant telemetry. The integration testTestProviderRoleCannotReadTelemetryproves the role is denied.
Bootstrap → enrollment → login
How the very first operator comes into existence on a fresh deployment, and how every operator after that enrolls:
- Bootstrap the first admin. Set
PROBECTL_PROVIDER_BOOTSTRAP_TOKENon the deployment, thenPOST /provider/v1/auth/bootstrapwith that token to create the first admin. It is single-use — the moment any operator exists, the bootstrap path goes inert, so the token cannot be replayed. The database serializes the zero-operator check, first insert, and mandatoryprovider.bootstrapaudit append in one provider-scoped transaction. Concurrent attempts and later retries lose with the same409 conflict; they create neither an operator nor a success audit. - Enroll. Creating an operator (whether via bootstrap or by an existing
admin) returns a one-time enrollment token — only its hash is stored, and
it is redeemable for 24 hours (
enroll_token_expires_inin the create response). It used to have no expiry at all, so an invitation nobody redeemed stayed a live credential for this privilege domain in whatever email or ticket carried it (DPR-178). An expired token is refused exactly like an invented one; mint a new one by re-creating the operator. The operator exchanges it in two steps:enroll/startbinds the authenticator (the TOTP secret travels exactly once, over TLS, and is sealed at rest), andenroll/completeverifies the first TOTP code, sets the password (minimum 12 characters), and activates the account. - Log in with email + password + TOTP. Failures are deliberately uniform —
nothing distinguishes a wrong password from a wrong code from an unknown email,
so an attacker can't probe which part they got right. The session that comes
back is persisted in Postgres (
provider_sessions, keyed token hash only, 4-hour lifetime, idle timeout), so every control replica behind the ingress honors it and a logout, an idle expiry, or disabling the operator ends it on all replicas at once (DPR-033).
The same flow has named CLI paths: probectl provider bootstrap,
enroll-start, enroll-complete, and login. These four requests contain
credentials, so the CLI deliberately refuses inline --body. Supply the one
JSON object through stdin (--body-file -) or a real, non-symlinked mode-0600
file (--body-file /owner-only/path.json). The login response includes the
provider bearer token used by later probectl provider ..., tenant ..., and
billing ... commands; inject it with PROBECTL_API_TOKEN, not the global
--token argument, so it does not enter the process list or shell history.
logout, provisioning, and abandon-provision are named commands too.
Tenant lifecycle
These are the actions an operator takes on tenants. Every one of them is recorded
on the provider audit stream with the acting operator's identity. The deployment's license lifecycle lands on the same stream as license.loaded and license.state_changed (actor system).
| Action | Effect |
|---|---|
| Provision | Creates the tenant (slug + name). Pooled tenants publish atomically. A siloed/hybrid tenant first appears in the provider inventory as provisioning, outside the routable tenant registry, while its isolated stores are created. A failed attempt stays non-routable and does not consume the tenant band; posting the same slug, name, model, and residency resumes the same tenant ID. Only the atomic active publication consumes the license's tenant band, and that final transition rechecks the band under a database lock. Provisioning past the band fails loudly with tenant_band_exhausted; a suspended tenant still occupies a slot and an offboarded one does not. On a deployment running the degraded single-tenant profile (PROBECTL_DEPLOYMENT_PROFILE=single with ClickHouse row-policies and the bus strict-lane off), provisioning a second tenant is refused at creation time with 409 single_profile_tenant_cap — the same one-tenant ceiling the boot-time posture self-check enforces, applied at runtime so a tenant created after boot can no longer slip past it and crash-loop the next restart; set PROBECTL_DEPLOYMENT_PROFILE=multi-tenant (or regulated), or enable every PROBECTL_*STORE_TENANT_SCOPING plane plus PROBECTL_INGEST_STRICT_TENANT_LANES=true, before adding another tenant. A siloed tenant's Postgres schema is created by the migration login, so a control plane without PROBECTL_MIGRATE_DATABASE_URL refuses siloed provisioning with 409 silo_ddl_login_required, naming that setting. Attempt, failure category, and completion are separately recorded on the provider audit stream. |
| Configure | Rename the tenant. |
| (on publication) | Every tenant is published with its system roles — admin (every permission), editor (reads plus test/alert/incident writes) and viewer (reads) — so the first administrator can be granted immediately with probectl-control bootstrap-admin -tenant <uuid> -email … or through SCIM group mapping (DPR-035). Seeding is idempotent and bootstrap-admin repeats it, so tenants created before this rule are healed on their first grant. |
| (after publication) | Install the tenant's IR public key (probectl audit ir-keygen <uuid> offline, then probectl-control ir-key-install <uuid> on the control plane) so break-glass into it can be requested; until then a request is refused with 409 ir_key_unavailable (DPR-036, audit.md). |
| Suspend | The tenant's users are rejected at the API (tenant_suspended, via the core lifecycle gate in requirePermission). Data, agents, and ingestion are left untouched — suspend is a reversible billing/lifecycle state, never destruction. Only an active tenant can be suspended. |
| Resume | Reactivates a suspended tenant — only a suspended one. Each control-plane replica re-reads a suspended tenant's status within its 15-second status cache, so the tenant's users are let back in everywhere without a restart; a failed status read keeps them refused rather than guessing "active". |
| Offboard | Marks the tenant offboarding: API access stops and the band slot frees. Offboarding never silently destroys data — the actual data export and verifiable deletion is a separate compliance flow (deliberately core/free). It is one-way: an offboarding tenant can be neither resumed nor suspended, and a transition the tenant's current state does not allow answers 409 conflict. |
A tenant, operator or grant id in a path that is not a well-formed UUID is the
caller's mistake: it answers 400 bad_request ("malformed identifier"), never an
internal error.
Provisioning failure audit data is deliberately bounded and phase-aware:
silo_provision_failed means an isolated datastore leg failed before registry
publication; registry_publish_failed means the final atomic publication failed.
Cancellation, deadline, and tenant-band failures use canceled,
deadline_exceeded, and tenant_band_exhausted. Raw backend error text is never
copied into the provider audit stream.
Break-glass: the only path to tenant telemetry
Since operators have no standing access to tenant data, "break-glass" is the one, narrow, heavily-controlled way in. The name is the fire-alarm cover: access exists for genuine emergencies, but using it means visibly shattering the glass — it cannot be done quietly, and everyone can see it was done. A grant is explicit, time-bounded, tenant-consented, operator-bound, and audited on every single access:
- An operator requests access to a tenant: a reason (required) and a TTL
(capped by
PROBECTL_PROVIDER_BREAKGLASS_MAX_TTL_MINUTES, default 4 hours). The grant starts in statepending. The tenant's IR public key must already be in the keyring (probectl audit ir-keygenthenprobectl-control ir-key-install, seeaudit.md); otherwise the request is refused with409 ir_key_unavailable, the response says exactly which file and commands are missing, and nothing is recorded. - The tenant decides — not the operator. A tenant admin (holding the
directory.writepermission) approves or denies it in the tenant app under Admin → Break-glass requests (operator, reason, scope, state, requested and expiry times, audited reads, Approve/Deny; the card is absent on deployments without a provider plane) or via the consent endpoints (GET /provider/v1/consentlists the tenant's pending requests and active grants, each with its state) or the CLI (probectl provider consent,decide-consent <id> --body '{"decision":"approve"}',revoke-consent <id>) — always authenticated by the tenant session, not an operator session or a bearer token. The CLI reads that session cookie from an owner-only file (--session-cookie-file, envPROBECTL_SESSION_COOKIE_FILE) and refuses to run without one. The consent check resolves the tenant first, then requires that RBAC permission, then applies the tenant's ABAC deny policies to the user's current subject attributes. A policy/attribute-store failure denies the decision rather than treating it as an empty policy set. A tenant can only ever see and decide its own grants. This is the consent that makes the whole mechanism legitimate. Separation of duties (AUD-13): an approval is refused (403 separation_of_duties) when the verified tenant identity deciding the grant is itself a provider operator — the requesting operator, or any other — so an operator who also holds a tenant-admin account can never approve their own access. - Only an
activegrant unlocks the read — meaning consented, unexpired, and unrevoked — and only for the operator who requested it. The surface today is the latest-results read model (GET /provider/v1/breakglass/{id}/results). - Every access writes a provider audit record before the data is returned —
an access that cannot be audited is simply not allowed to happen. Revocation,
denial, or expiry ends access immediately, and the grant's
use_countshows exactly how many audited reads it carried. An operator holds at most one usable grant per tenant; once a grant has expired, a new request can be approved (the expired grant stays on record as expired). - The tenant can revoke what it consented to (AUD-13). An approved grant
stays on the tenant's Break-glass requests card, marked active, with a
Revoke action for as long as it is usable.
POST /provider/v1/consent/{id}/revoke, authenticated by the tenant session (the samedirectory.writetenant admin), ends an active or pending grant for that tenant. It is recorded on both the provider break-glass stream and the tenant's own tamper-evident audit chain, and fails closed if the tenant side cannot be recorded. Operators keep their own revoke route (POST /provider/v1/breakglass/{id}/revoke); neither side can silently keep an access the other has ended.
License degrade
The provider plane follows the same expiry ladder as the rest of the editions
system (see editions.md). In short: active → full function;
grace (within 30 days past expiry) → full function, with the console bannering
the deadline; read_only (past grace) → GETs keep working, but every mutation
returns license_read_only (no new tenants, operators, or grants). Running
telemetry is never touched — expired is not the same as broken observability.
MSP consumption and export
The MSP tier uses the existing local usage meters (agents, tests,
results_ingested, ingest_bytes, flow_events, and ai_calls) as its
consumption basis. The path is deliberately one-way and operator-driven:
- tenant-scoped streams update meters inside this self-hosted deployment;
- an authenticated provider operator requests
GET /provider/v1/usage/export?format=csv|jsonl; - the operator sends that file through the commercial process agreed with probectl and may independently map the same data into its own customer pricing.
There is no background uploader, billing beacon, vendor callback, or implicit
telemetry read. If the operator does not run an export, nothing leaves the
deployment. See metering.md for meter semantics and the stable
export columns.
The console
The console lives at /provider in the web app and is a deliberately
visually-separate surface — its own shell, a loud "PROVIDER PLANE — operator
domain, no tenant context" banner, no tenant indicator, and no entry in the
tenant navigation (it is marked offNav in the surface registry — the web
app's machine-checked list of every screen and where it is reachable). The separation
is intentional: an operator should never be able to confuse "I'm running the
platform" with "I'm inside a tenant." Its source lives in ee/web/provider
(the editions boundary applies to frontend code too — the @ee Vite alias,
pointing at ee/web, is the web seam). When the API returns 404 (unlicensed), the
console honestly renders "Provider plane not enabled." The screens: MFA login;
tenant inventory with lifecycle actions and a provision form; a
fleet-across-tenants table (counts and versions only — no telemetry);
break-glass request/list/revoke with per-grant audited-use counts; an
Activity card (admins) that pages the plane's own audit stream newest-first
— every bootstrap, login, lockout, operator change, tenant lifecycle step,
break-glass request/consent/access/revoke and provisioning outcome, with
actor/action/target filters (DPR-037; the same rows the WORM export and the
SIEM feed carry, and reading never appends to the stream); usage,
fairness, and governance cards (each documented on its own page —
metering.md, fairness.md,
governance.md); and operator management with one-time
enrollment tokens for admins. MSPs resell the service under the probectl banner.
Engineering eval smoke
The provider journey has one deliberate precondition: a deployment must attach
the provider plane through the edition seam. A community or otherwise
unlicensed build must keep returning a plain 404 for /provider/*. Think of
that as the door not existing in that build, not as a locked door with a sales
message behind it.
For an engineering evaluation, use a disposable local stack and an MSP-tier
eval license issued for that evaluation. Do not weaken internal/license, do
not make core import ee/, and do not use PROBECTL_ALLOW_KEYLESS_DEV for a
provider-plane smoke. The minimum runtime preconditions are:
PROBECTL_LICENSE_FILEpoints at an offline-signed license whose feature set includesprovider_plane.PROBECTL_ENVELOPE_KEYis set, because provider TOTP secrets are sealed at rest.PROBECTL_PROVIDER_BOOTSTRAP_TOKENis set for the first operator bootstrap.PROBECTL_AUDIT_WORM_DIRpoints at a durable absolute directory: the plane refuses admission until provider/break-glass audit rows have a signed WORM export (the signing key is generated atPROBECTL_WORM_SIGNING_KEY_FILEon first start unlessPROBECTL_WORM_SIGNING_KEYis injected).PROBECTL_IR_PUBLIC_KEY_DIRpoints at the operator-owned IR public keyring (absolute path; created empty on first start). Break-glass attribution is sealed to<tenant-uuid>.pemin that directory, and a grant for a tenant whose key is absent is refused with409 ir_key_unavailableuntil the key exists. Mint the pair withprobectl audit ir-keygen <tenant-id>and install the public half withprobectl-control ir-key-install <tenant-id>(it reads stdin, so it works throughkubectl exec -ion the shell-less image) — seeaudit.md.- The database migrations have run, including the provider tables and
probectl_providerrole grants.
The shipped deploys wire the last two for you: Compose with
deploy/compose/provider.yml layered over probectl.yml + license.yml
(evaluation-grade, on the persistent volume), and the Helm multitenant profile
(values-multitenant.yaml, on the shared object-lock mount). A missing setting
is reported by name at startup instead of a crash loop.
The quick smoke has two halves:
GOCACHE=/private/tmp/probectl-gocache go test ./internal/control -run TestProviderPlaneMountSeam -count=1
GOCACHE=/private/tmp/probectl-gocache go test ./ee/provider -run 'TestProviderLifecycle|TestProviderOpenAPIMatchesRoutes' -count=1
cd web && npm test -- src/test/provider-console.test.tsx
The first command proves the hidden-unlicensed contract from core: without an
attached provider handler, /provider/* is indistinguishable from any unknown
route; with an attached handler, core dispatches without knowing any ee/
types. The second command proves the provider-enabled onboarding path:
bootstrap, MFA enrollment/login, tenant lifecycle, license-band enforcement,
audit records, and provider OpenAPI parity. The web command proves the console
renders both states: "Provider plane not enabled" when the API is hidden, and
the operator onboarding/lifecycle surfaces when the provider API is available.
API
The provider API is /provider/v1/*, documented in ee/provider/openapi.json.
GET /provider/v1/audit (admin) pages the provider audit stream —
?after=<seq> oldest-first, or ?order=desc&before=<seq> newest-first, with
actor/action/target substring filters and limit up to the audit export
page maximum; the CLI equivalent is probectl provider audit. The spec is
with a route-vs-spec parity self-test (TestProviderOpenAPIMatchesRoutes) that
mirrors the core OpenAPI gate — so the spec can't drift from the handlers. Core
mounts the whole surface as an opaque http.Handler via
Server.WithProviderPlane, handed in from the attachEE seam
(cmd/probectl-control/ee_attach.go, //go:build !probectl_core). Core never
imports the provider package directly; it only ever sees an http.Handler —
Go's standard "thing that answers HTTP requests" interface, so core forwards
requests without knowing any provider types exist — which
is what keeps the "core never imports ee/" rule intact.
Configuration
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_PROVIDER_BOOTSTRAP_TOKEN |
(none) | single-use first-admin bootstrap |
PROBECTL_PROVIDER_BREAKGLASS_MAX_TTL_MINUTES |
240 |
break-glass TTL cap (5–1440) |
PROBECTL_TRUSTED_PROXIES |
(none) | ingress/load-balancer CIDRs whose X-Forwarded-For the operator login limiter keys on; otherwise every operator behind the ingress shares one lockout key (DPR-039, see configuration.md) |
PROBECTL_ENVELOPE_KEY |
(none) | required for the provider plane (TOTP secrets are sealed at rest) |
Plus an MSP-tier license (PROBECTL_LICENSE_FILE) granting
provider_plane — see editions.md.