Control plane API
The hosted control plane (cmd/orlop-control, a single Go binary that is both
the service and a CLI) handles agent enrollment, short-lived bearer credentials,
per-tenant CA signing, disk placement, and admin sessions for the dashboard. The data plane is
the per-tenant orlop-server; once an agent is enrolled it reads and writes its
files directly against that server over mTLS, keeping the control plane out of
the data path.
See design-auth.md for the certificate model,
design-identity.md for host-issued JWT identity,
database-backends.md for Postgres vs. SQLite, and
control-plane-runbook.md for operator tasks.
A machine-readable OpenAPI spec for the provisioning surface the Go SDK
(orlop/client) exercises lives at
openapi/orlop-control.yaml — implement a client
in any language from it. The versioning and SDK↔server compatibility policy is
in Versioning and compatibility below.
Conventions
Section titled “Conventions”These apply to every endpoint below, so the per-endpoint sections stay short.
Base URL and TLS
Section titled “Base URL and TLS”All requests go to the control plane’s HTTPS origin, e.g.
https://control.orlop.example. The control-plane API is ordinary
net/http/JSON over TLS (terminated at the edge in a hosted deploy). The data
plane is a separate binary protocol on orlop-server and is not described here.
Authentication
Section titled “Authentication”The control plane accepts several credential shapes. Each endpoint in the route table names the one it requires.
| Credential | How it is sent | Where it comes from |
|---|---|---|
| API token | Authorization: Bearer <orlop_…> |
POST /v1/tokens |
| Enroll token | Authorization: Bearer <token> |
orlop-control token issue / POST /v1/agents/{id}/enroll-token (single-use) |
| Admin session cookie | HttpOnly cookie set at /admin/session?token=… |
orlop-control user seed |
| Service token | Authorization: Bearer $ORLOP_CONTROL_PLANE_TOKEN |
shared static token, set by the operator |
| Host JWT | Authorization: Bearer <jwt> |
a host-issued, signed JWT (see design-identity.md) |
| Agent identity | client mTLS cert, or an agent_fingerprint body field |
the leaf minted at enrollment |
Bearer parsing is case-insensitive on the Bearer keyword and tolerates
trailing whitespace.
Request and response format
Section titled “Request and response format”Request bodies, where present, are JSON (Content-Type: application/json).
Successful responses are JSON with Content-Type: application/json.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
200 OK |
success, JSON body |
201 Created |
success, resource created (POST /v1/tokens) |
204 No Content |
success, no body (POST /auth/logout, DELETE /v1/entities/...) |
400 Bad Request |
malformed request |
401 Unauthorized |
missing/invalid/expired credential (invalid_token, invalid_client) |
403 Forbidden |
authenticated but not allowed (access_denied: suspended tenant/user, tenant not allowed, missing agent scope) |
404 Not Found |
unknown resource |
409 Conflict |
mount or capacity conflict (wrong_agent, already_mounted, lease_live, insufficient_capacity). lease_live means the acquire would displace a mount lease that is still live for a different enrollment; its body carries the incumbent’s bound_at and lease_expires_at, and the caller may retry with {"force": true} to take over |
410 Gone |
revoked allocation or lost lease (revoked, lease_lost) |
429 Too Many Requests |
rate limited (rate_limited) |
503 Service Unavailable |
transient; retry. POST /agent/enroll adds Retry-After: 60 when CA material or server placement is not yet ready |
500 Internal Server Error |
server_error |
Error shape
Section titled “Error shape”Errors use an OAuth-style body. error_description is omitted when empty.
{ "error": "access_denied", "error_description": "tenant_suspended" }Every response also carries X-Request-ID, the same identifier written to the
control-plane request log.
The Go SDK returns non-2xx responses as *client.APIError, including the
operation, method, path, status, machine-readable code, message, request ID,
response headers, and a bounded (64 KiB) raw response body. Use errors.As for
the full response and errors.Is(err, client.ErrNotFound) for stable branches.
APIError.Retryable() classifies only 429, 500, 502, 503, and 504;
when RetryAfter is non-zero, wait at least that long and add jitter before
retrying. The SDK deliberately does not retry automatically: callers must
decide whether replaying the operation is safe and must bound attempts.
Versioning and compatibility
Section titled “Versioning and compatibility”The control-plane API is versioned by a single major number, carried by the
/v1/... path prefix. It is independent of the orlop release version: the
API was byte-identical across v0.1.0 and v0.2.0, and stays at major 1 until a
breaking change.
Skew is detectable, not silent. Every response carries an Orlop-API-Version
header naming the major the server implements, and the Go SDK sends the same
header on every request. The SDK compares them and returns a typed
client.APIVersionError on a major mismatch — so an incompatible pairing
surfaces as an explicit “version skew” error instead of an opaque 4xx. A server
that predates the header (no Orlop-API-Version) is treated as compatible, for
back-compat.
Compatibility policy.
| Client major | Server major | Result |
|---|---|---|
N |
N |
Supported. Within a major, the server only adds endpoints/fields; clients must ignore unknown response fields. |
N |
M (≠ N) |
Unsupported. The SDK returns APIVersionError; upgrade one side to a matching major. |
Concretely today: every released orlop SDK and server (v0.1.0 through v0.2.x)
speaks API major 1, so any combination of them is supported. A future breaking
change ships under /v2 with Orlop-API-Version: 2 and is called out as a
breaking change in the release notes (see upgrade-safety.md).
Other-language clients should: send Authorization: Bearer <service token>,
optionally send Orlop-API-Version: 1, and check the response header to detect
skew the same way the Go SDK does.
Endpoints
Section titled “Endpoints”The set of mounted routes depends on configuration:
GET /healthzis always mounted.- The dashboard, API-token,
/v1/entities,/v1/admin,/v1/tenants, journal, and/agent/enrollroutes are mounted only whenDATABASE_URLis set. (/v1/tenants/{owner}/usageand/v1/admin/purge-sweepmount on the database alone but return503until an agent CA is configured, since they call the data plane.) /agent/enrollandPOST /control/sign-server-certadditionally need an agent CA configured (a filesystem CA atORLOP_SECRETS_DIR, or the in-DB CA viaORLOP_SECRETS_BACKEND=postgres).GET /v1/whoamiis mounted only whenORLOP_IDENTITY_AUDIENCEis set, and is independent ofDATABASE_URL.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /healthz |
public | liveness; returns {"status":"ok"} |
| GET | /admin/session |
admin session token (?token=) |
sets the orlop_admin_session cookie and redirects to the dashboard |
| POST | /auth/logout |
admin session cookie | clears the admin cookie; 204 |
| POST | /agent/enroll |
enroll token | mint a one-hour agent leaf cert (see below) |
| GET | /v1/whoami |
host JWT | echo the verified tenant/subject (see below) |
| GET | /me |
admin session cookie | dashboard: current user |
| GET | /allocations |
admin session cookie | dashboard: list the user’s disk allocations |
| GET | /allocations/{id}/usage |
admin session cookie | dashboard: per-allocation usage |
| POST | /allocations/{id}/revoke |
admin session cookie | revoke an allocation |
| POST | /allocations/{id}/mount |
agent identity | acquire the exclusive mount lease. Refuses to displace a live lease held by a different enrollment with 409 lease_live unless the body sets "force": true |
| POST | /allocations/{id}/mount/refresh |
agent identity | extend the mount lease |
| DELETE | /allocations/{id}/mount |
agent identity | release the mount lease |
| POST | /allocations/{id}/unmount |
admin session cookie | owner-forced unmount |
| POST | /v1/tokens |
admin session cookie | mint a long-lived orlop_… API token (shown once; 201) |
| GET | /v1/tokens |
admin session cookie | list API tokens |
| DELETE | /v1/tokens/{id} |
admin session cookie | revoke an API token (204) |
| GET | /v1/journal |
admin session cookie | tenant write journal (paged) |
| POST | /v1/journal/revert |
admin session cookie | revert a (path, seq) |
| GET | /v1/journal/stream |
admin session cookie | journal SSE stream |
| POST | /v1/entities |
service token | provision an owner/agent + disk allocation |
| GET | /v1/entities/{type}/{id} |
service token | resolve an entity |
| PATCH | /v1/entities/{type}/{id} |
service token | set an agent’s quota |
| DELETE | /v1/entities/{type}/{id} |
service token | revoke/delete an entity |
| POST | /v1/entities/{type}/{id}/reassign |
service token | reassign an entity |
| POST | /v1/entities/account/{owner}/budget |
service token | set an account’s shared budget |
| POST | /v1/agents/{id}/enroll-token |
service token | mint a per-pod, agent-scoped enroll token |
| POST | /v1/admin/purge-sweep |
service token | erase revoked-but-unpurged allocation data |
| GET | /v1/tenants/{owner}/usage |
service token | per-owner disk usage for the storage meter |
| POST | /control/sign-server-cert |
service token | sign an orlop-server TLS cert from its CSR |
The dashboard, /v1/entities, /v1/admin, /v1/tenants, journal, and
/v1/whoami routes are also registered under an /api/… prefix; the production
edge strips /api before forwarding, so the bare paths above are what the
service actually serves.
Entity provision/resolve responses include virtual_path. This value is the
control plane’s authoritative agent-visible mount path and reflects
ORLOP_MOUNT_PREFIX; pass it as ORLOP_MOUNT_POINT when launching the mount
client. The Go SDK’s MountPath helper only computes the default
/mnt/orlop/agents/<id> fallback for older servers that omit virtual_path.
Embedders that must construct the path before calling the API can use
MountPathWithPrefix.
Revocation note: PUT /control/cert-revocations is served by orlop-server,
not by the control plane. The control plane is the client: a reconcile loop
(~60s) pushes the active leaf-revocation set to each data-plane server over mTLS,
authenticated with its own control-plane cert. It is listed here only to place
where revocation propagation happens; it is not a control-plane HTTP route.
Endpoint detail
Section titled “Endpoint detail”POST /agent/enroll
Section titled “POST /agent/enroll”Trades a bearer credential for a one-hour agent leaf certificate plus the CA
chain and the data-plane address to dial. The bearer is a single-use enroll
token; the agent must already have a provisioned, agent-scoped disk allocation
(the request is rejected with access_denied / agent_scope_required otherwise).
curl -fsS -X POST https://control.orlop.example/agent/enroll \ -H "Authorization: Bearer $ORLOP_ENROLL_TOKEN" | jq .Success (200):
{ "client_cert_pem": "-----BEGIN CERTIFICATE-----\n...\n", "client_key_pem": "-----BEGIN PRIVATE KEY-----\n...\n", "ca_chain_pem": "-----BEGIN CERTIFICATE-----\n...\n", "server_addr": "tenant-acme.orlop.example", "expires_at": "2026-04-30T13:00:00Z"}When the request resolved an allocation, the response also carries
allocation_id and size_bytes. The agent verifies the data-plane server cert
against ca_chain_pem (not the system trust store) and dials server_addr.
On a valid request the control plane:
- Authenticates the bearer and rate-limits per
Authorizationheader. - Confirms the tenant exists and is not suspended.
- Looks up the allocation (if the token carries one) and rejects a wrong-owner or revoked allocation.
- Resolves or lazily places the tenant’s
orlop-servervia the placement scheduler. - Lazily bootstraps the tenant intermediate CA if this is the tenant’s first enroll (subject to the CA tenant policy).
- Requires an agent-scoped allocation, then mints a one-hour leaf bound to the tenant and agent.
- Spends the enroll token (single-use) if the bearer was an enroll token.
- Records an
agent_enrollmentsrow with the cert serial and expiry.
Retryable failures (tenant CA not yet available, or server placement pending)
return 503 with Retry-After: 60 so a sidecar can retry without burning the
(still-unspent) enroll token. Pool exhaustion uses the wire error
no_capacity; other placement failures use server_vm_unavailable, so an
operator is not sent to debug a healthy data-plane VM when the pool is full.
Because the data plane enforces one shared owner-directory quota, placement
debits that account budget once per hosting server. Additional agents on the
same account/server reuse the durable reservation; the last purge releases it.
GET /v1/whoami
Section titled “GET /v1/whoami”Mounted only when ORLOP_IDENTITY_AUDIENCE is set. The control plane acts as a
relying party for a host-issued, signed JWT: it verifies the signature against
ORLOP_IDENTITY_PUBLIC_KEY_FILE, checks iss/aud/exp, maps the
ORLOP_IDENTITY_TENANT_CLAIM value onto a tenant subject, and accepts it only
if that tenant is on ORLOP_IDENTITY_TENANT_ALLOWLIST (fail-closed). This
endpoint echoes the verified identity: a dependency-free way to confirm a host
token is accepted end to end.
curl -fsS https://control.orlop.example/v1/whoami \ -H "Authorization: Bearer $HOST_JWT" | jq .Success (200):
{ "tenant_id": "u_acme", "subject": "host-user-42" }A well-signed token whose tenant is not on the allowlist returns 403
(access_denied / tenant_not_allowed); anything the verifier rejects returns
401 (invalid_token).
Control-plane metrics
Section titled “Control-plane metrics”orlop-control exposes Prometheus metrics on the separate
ORLOP_METRICS_ADDR listener. Capacity gauges are read from the configured
storage backend on every scrape, so they remain correct across restarts and
out-of-band repairs:
| Metric | Type | Labels |
|---|---|---|
orlop_server_pool_free_bytes |
gauge | server_id |
orlop_server_pool_total_bytes |
gauge | server_id |
orlop_allocations_purge_pending |
gauge | none |
orlop_enroll_total |
counter | outcome (including no_capacity, server_vm_unavailable, and tenant_ca_unavailable) |
Alert on low free/total ratio and on a purge-pending gauge that remains nonzero or grows across sweeper intervals.
Service environment variables
Section titled “Service environment variables”| Variable | Meaning |
|---|---|
PORT |
HTTP listen port. Default 8080. |
ORLOP_METRICS_ADDR |
Separate Prometheus listener. Default :9090; set to an empty value to disable. |
DATABASE_URL |
Storage backend. Accepts a postgres://… DSN or a sqlite:… URL; the scheme selects the backend. Without it, the dashboard, /v1/entities, journal, and enroll routes are not mounted. See database-backends.md. |
ORLOP_SECRETS_DIR |
Filesystem secrets root holding CA material (the default CA backend). |
ORLOP_SECRETS_BACKEND |
postgres keeps the CA (root key + tenant intermediates) in the shared DB instead of on disk; any other value uses the filesystem backend at ORLOP_SECRETS_DIR. /agent/enroll mounts whenever a CA is configured by either backend, so ORLOP_SECRETS_DIR is not strictly required. postgres requires a Postgres DATABASE_URL. |
ORLOP_SECRETS_ENC_KEY |
Hex-encoded 32-byte AES key; encrypts CA values at rest. Recommended with ORLOP_SECRETS_BACKEND=postgres. |
ORLOP_SECRETS_ALLOW_PLAINTEXT |
1 to allow storing the CA root key unencrypted in Postgres. Without it (and without ORLOP_SECRETS_ENC_KEY), postgres backend boot fails closed. |
ORLOP_TRUST_DOMAIN |
SPIFFE trust domain. Default orlop.example. |
ORLOP_ORG_NAME |
X.509 Organization. Default ORL. |
ORLOP_COOKIE_DOMAIN |
Domain for the admin session cookie. |
ORLOP_CONTROL_PLANE_TOKEN |
Shared service token gating the /v1/entities, /v1/admin, /v1/tenants, and /control/sign-server-cert routes. Empty ⇒ those routes reject every request (fail closed). |
ORLOP_API_TOKEN_TTL |
Expiry for newly minted orlop_… API tokens (e.g. 2160h). 0 (default) ⇒ never expire. |
ORLOP_MOUNT_LEASE_TTL |
Renewable mount-liveness window. Default 60s; minimum 4s. This is not a token or key lifetime: a healthy client refreshes automatically at half the remaining expires_at window returned by the server. --mount-lease-ttl overrides the environment value. Increase it to tolerate longer control-plane outages; decrease it so a crashed client’s leaked lease becomes claimable sooner. |
ORLOP_PURGE_SWEEP_INTERVAL |
Built-in reconciliation cadence for revoked-but-unpurged allocations. Default 10m; the control plane also runs one pass immediately at startup. Set to 0 only when an external controller deliberately owns the schedule. The authenticated POST /v1/admin/purge-sweep endpoint remains available for an immediate manual drain. |
ORLOP_INITIAL_GRANT_BYTES |
Disk granted at provision when the request specifies no explicit size. Default 1 GiB. |
ORLOP_MOUNT_PREFIX |
Agent-visible prefix used to compute entity virtual_path values. Must be an absolute POSIX path. Default /mnt/orlop; an entity for agent <id> reports <prefix>/agents/<id>. |
ORLOP_SERVER_FQDN |
The only name POST /control/sign-server-cert will issue a server cert for. Default orlop-server. |
ORLOP_SERVER_CERT_TTL |
Validity of a self-provisioned server cert (e.g. 2160h). Default 90 days. |
ORLOP_IDENTITY_AUDIENCE |
Enables the host-issued JWT identity verifier and mounts GET /v1/whoami; pins the JWT aud. The other ORLOP_IDENTITY_* knobs apply only when this is set. |
ORLOP_IDENTITY_PUBLIC_KEY_FILE |
PKIX/SPKI PEM public key the host JWT is verified against (RSA, ECDSA P-256, or Ed25519). Required when the audience is set. |
ORLOP_IDENTITY_ISSUER |
Optional; when set, must equal the JWT iss. |
ORLOP_IDENTITY_TENANT_CLAIM |
Claim mapped onto the tenant subject. Default tenant. |
ORLOP_IDENTITY_TENANT_ALLOWLIST |
Comma-separated, fail-closed list of tenant ids that may be provisioned via the JWT path. Required when the audience is set. |
ORLOP_CA_TENANT_ALLOWLIST |
Comma-separated tenant ids that may have a CA intermediate lazily bootstrapped at first enroll, on top of the dynamic prefixes. An unrecognized tenant’s enroll is refused with 403 access_denied / tenant_not_allowed. |
ORLOP_CA_ALLOW_DYNAMIC_TENANTS |
Allow lazy bootstrap of server-derived per-user (u_) and per-agent (a_) tenants. Default true. |
ORLOP_CA_ALLOW_DYNAMIC_TENANTS accepts true/false/1/0/yes/no/on/off
(case-insensitive). Unset uses the default, but a set-but-unrecognized value
fails boot rather than silently falling back: a typo on a security toggle must
not quietly leave the permissive default in force. Set it to false to restrict
bootstrap to ORLOP_CA_TENANT_ALLOWLIST only.
Mount leases, credentials, and keys have separate lifecycles. The mount lease
above is a continuously renewed liveness heartbeat. Agent enroll tokens last
about 10 minutes and are single-use; agent mTLS leaf certificates last one hour,
and the mount client renews them before expiry. orlop_… API tokens do not
expire by default unless ORLOP_API_TOKEN_TTL is configured. A client cannot
extend the server’s mount-lease policy, but it automatically follows whichever
TTL the server returns.
For a rolling upgrade, update all mount clients before setting the TTL at or below 30 seconds. Older clients use a fixed 30-second refresh interval; the default 60-second server TTL remains backward-compatible while clients roll.
The datagateway-era names ORLOP_DATAGW_SERVER_FQDN, ORLOP_DATAGW_SERVER_CERT_TTL,
ORLOP_DATAGW_SERVICE_TOKEN, ORLOP_DATAGW_MAX_SESSIONS, and
ORLOP_DATAGW_MAX_INFLIGHT remain accepted as fallback aliases of the
corresponding ORLOP_* variables for backward compatibility.
orlop-control with a subcommand runs the CLI instead of the service. All
subcommands read DATABASE_URL from the environment when --database-url is not
passed.
| Command | Purpose |
|---|---|
orlop-control migrate up |
Apply all pending migrations. Works for both Postgres and SQLite (the SQLite backend applies its schema on open). |
orlop-control ca init --root |
Bootstrap the org root CA (run on an offline operator machine). |
orlop-control ca init --tenant <id> |
Bootstrap a tenant intermediate. |
orlop-control ca list |
List loaded tenant intermediates. |
orlop-control ca mint-server-cert --tenant <id> --fqdn <host> --out-dir <dir> [--ttl 2160h] |
Mint a TLS server cert for orlop-server. |
orlop-control user seed --tenant <id> --email <e> [--base-url <url>] |
Idempotently create tenant + user and mint an admin session; prints a one-shot URL to register the cookie. |
orlop-control user suspend --email <e> |
Suspend a user; outstanding access tokens stop validating on next use. |
orlop-control server register [--data-addr <h:p>] [--ops-addr <h:p>] [--total-bytes N] [--status S] |
Register a data-plane server in the placement pool so /agent/enroll has somewhere to place disks. |
orlop-control token issue --agent <id> [--owner <uuid>] [--size <bytes>] [--control-plane <url>] [--mount-point <path>] [--json] |
Provision an agent’s disk (idempotently) and mint a short-lived (~10m), single-use enroll token; prints a ready-to-mount env block. |
token issue is the standalone enroll path: it prints ORLOP_AGENT_ID,
ORLOP_MOUNT_POINT, ORLOP_CONTROL_PLANE, and ORLOP_ENROLL_TOKEN, which feed
orlop mount --from-env. For retry-safe Kubernetes mounts, use a projected
ServiceAccount token via ORLOP_SA_TOKEN_PATH plus an agent-scoped mint endpoint
in ORLOP_REFRESH_URL; every process attempt then obtains a fresh one-shot
token. Possession of DATABASE_URL is the operator credential
for token issue, server register, user seed, and ca init.
Local development
Section titled “Local development”Bring up storage, migrate, bootstrap the CA, and start the service. The example
uses Postgres; substitute a sqlite:… DATABASE_URL to run with no external
database (see database-backends.md).
export DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/orlop_controlexport ORLOP_SECRETS_DIR=/tmp/orlop-control-secretsexport ORLOP_TRUST_DOMAIN=orlop.localexport ORLOP_ORG_NAME="ORL Dev"
orlop-control migrate uporlop-control ca init --rootorlop-control ca init --tenant acmeorlop-control # start the serviceSeed an admin session (prints a one-shot URL to open in a browser):
orlop-control user seed \ --tenant acme \ --email operator@acme.example \ --base-url http://127.0.0.1:8080Register a data-plane server so enrollment has a placement target, then issue an enroll token and mount:
orlop-control server register --data-addr localhost:8443 --ops-addr localhost:8443orlop-control token issue --agent demo --control-plane http://127.0.0.1:8080# → exports ORLOP_ENROLL_TOKEN etc.; run `orlop mount --from-env`For full-stack mTLS, the orlop-server cert name must match the
server register --data-addr value, and its client CA must be the org root (the
shared client CA, with the agent presenting its tenant intermediate in the
chain). A server can self-provision that cert via POST /control/sign-server-cert.