Appearance
Provider Bundles HTTP API
This is the reference for the /v1/provider-bundles HTTP surface. A provider bundle is the shared provider configuration a Cloud can point at instead of repeating the same declaration inline: one set of Crossplane provider packages plus the one <group>/<version> those packages serve their ProviderConfig under, authored once and addressed by a stable slug. A bundle keeps a version history: a content patch publishes a new immutable version, and each referencing Cloud pins one of them and resolves against that declaration on read. A published version therefore reaches a Cloud only once a Cloud write moves its pin. The delete still refuses while any reference exists.
The page maps each operation to its OpenAPI schema, ReBAC gate, audit emission, outbox event, and the closed Problem.code taxonomy. The wire-contract origin is api/openapi/plexsphere-v1.yaml; this doc is a map, not a duplicate contract. The aggregate itself, its invariants, and the two modes a Cloud states its provider configuration in are described in the Cloud Inventory context reference.
The gates resolve against the provider_bundle definition in schema/authz.zed, which parents onto the platform:plexsphere singleton rather than a Domain: a bundle is platform-wide catalogue state that Clouds in any Domain resolve against. manage folds in the per-bundle bundle_admin relation and platform#manage; observe adds auditor and viewer on top of those and also reads platform#read, so a platform auditor reads every bundle and never reaches the mutation surface. The definition declares no read permission, so both read verbs gate on observe, the lowest-privilege read-equivalent it carries. The full derivation is in the provisioning ReBAC reference. For the cursor-paginated list idiom and the canonical Problem.code style this surface inherits, see ./clouds.md, the sibling aggregate's surface.
Operations
| Method | Path | Operation ID | ReBAC gate | Audit relation | Outbox event | Body cap |
|---|---|---|---|---|---|---|
| POST | /v1/provider-bundles | CreateProviderBundle | platform platform#manage | provider_bundle.create | ProviderBundleCreated | 8 KiB |
| GET | /v1/provider-bundles | ListProviderBundles | per-row provider_bundle#observe filter | provider_bundle.read (granted, with post-filter item_count) | (none) | n/a |
| GET | /v1/provider-bundles/{id} | GetProviderBundle | provider_bundle#observe (BEFORE persistence read) | provider_bundle.read | (none) | n/a |
| PATCH | /v1/provider-bundles/{id} | PatchProviderBundle | provider_bundle#manage | provider_bundle.update (NAMES-only field list) | ProviderBundleUpdated | 8 KiB |
| DELETE | /v1/provider-bundles/{id} | DeleteProviderBundle | provider_bundle#manage | provider_bundle.delete | ProviderBundleDeleted | n/a |
| GET | /v1/provider-bundles/{id}/clouds | ListProviderBundleClouds | provider_bundle#manage (BEFORE persistence read) | provider_bundle.read (granted, with item_count) | (none) | n/a |
| GET | /v1/provider-bundles/{id}/versions | ListProviderBundleVersions | provider_bundle#observe (BEFORE persistence read) | provider_bundle.read (granted, with item_count) | (none) | n/a |
body_cap = 8 KiB(MaxProviderBundleRequestBodyBytesininternal/transport/http/v1/providerbundles/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as413 request_body_too_large.- Both read verbs stamp the same audit relation,
provider_bundle.read. The service emits nothing on the read path, so the row is written by the transport layer next to the ReBAC decision; an auditor asking who read the bundle inventory filters on one relation instead of two. CreateProviderBundleruns the platform gate BEFORE it calls the service, so an unauthorised caller never produces aProviderBundleCreatedoutbox row. On success the response carries aLocationheader pointing at/v1/provider-bundles/{id}.- The
201is ahead of the ReBAC graph. The two grants that make a new bundle readable — theplatformparent edge and the creator'sbundle_admingrant — are written by the authz-sync consumer draining theProviderBundleCreatedoutbox row, not by the create request. Until it has drained,GET /v1/provider-bundles/{id}on the bundle just created answers403andListProviderBundlesomits the row. A client reading back immediately should retry on403rather than treat it as a permanent denial. The window is the outbox consumer's poll interval and widens under backlog. - The
404 provider_bundle_not_foundarm on the three per-bundle verbs is narrower than it reads. Because the gate runs before the persistence read, and an id no bundle ever answered to carries no tuples, an unknown id denies at the gate and the caller reads403.404is reached only by a caller the graph already grants on that id — a row deleted while its tuples had not yet been purged. That is the withholding the ordering exists to perform: for an id the caller holds no grant on, "absent" and "forbidden" are deliberately indistinguishable. ListProviderBundles.limitis clamped at the handler to[1, 200]with default50.ListProviderBundles.cursoris opaque, HMAC-signed by the server through theCursorCodecport and bound to the calling principal. A tampered cursor surfaces as400 invalid_cursor; a cursor replayed by a different caller than the one that minted it surfaces as403 cursor_binding_mismatch.ListProviderBundleslayers a per-rowprovider_bundle#observecheck on top of the slug-ordered persistence window, so the response page is the subset the caller is authorised to see. Thenext_cursoris set whenever the persistence layer returned a full page regardless of how many rows the per-row filter dropped, so a thin authorised cohort still pages forward. An unreachable authorization backend fails every row check, so the list answers503 authz_unavailablerather than a200with an empty page that would report "you may see nothing" for an outage.GetProviderBundleruns theprovider_bundle#observecheck before the persistence read, so an unauthorised caller receives403without the existence side-channel a "load-then-check" flow would leak.PatchProviderBundlerejects bodies that carry aslugkey (even with the same value) at decode time with400 slug_immutable. The slug is the handle a bundle is addressed by across the installation.PatchProviderBundlerejects bodies that carry aproviderkey at decode time with400 provider_immutable. Provider is the compatibility key every stored Cloud reference was admitted against, and nothing re-checks a reference once it is stored, so a provider change would silently repoint Clouds at packages of another family.PatchProviderBundlerequires the body to set at least one ofdisplay_name,provider_packages, orprovider_config_api_version; an empty body surfaces as400 empty_patch. Aprovider_packagespatch replaces the whole set as one unit: the packages the body names become the bundle's packages and every package it omits is dropped. An empty array, asourcenamed twice, or an entry missing one of its two members is rejected with400 invalid_provider_bundle.provider_config_api_versionpatches on its own, without restating the package set.- A content patch — one carrying
provider_packagesorprovider_config_api_version— publishes a new version and reports its number inlatest_version. The two content fields publish ONE version between them even when the body names both, because two versions for one patch would leave an intermediate declaration in the history as a pinnable state the operator never asked for. A rename-only patch publishes nothing and leaveslatest_versionwhere it is. - Publishing moves no Cloud. Every referencing Cloud keeps resolving the version it pins until a Cloud write moves that pin — a
PATCH /v1/clouds/{id}namingprovider_bundle_version, which promotes that one Cloud. See the pinned version for the write side. ListProviderBundleVersionsreturns the published history, newest first, each row carrying the whole declaration that version froze: itsprovider_packages, itsprovider_config_api_version, thecreated_bysubject that published it, and thecreated_attimestamp. Thelimit/cursorcontract is the oneListProviderBundlesuses, with the cursor payload being a version number.created_byis empty for the versions the schema migration backfilled from bundles that predate the history — there is no publishing principal to attribute those to.- The history is gated on
provider_bundle#observe, the permissionGetProviderBundlerequires, not themanagethe referencing-Clouds roster narrowed itself to. A version row is bundle content — the same packages and apiVersionGetProviderBundlealready serves underobserve, frozen under an older number — so it discloses nothing about any Cloud and stays answerable to every caller who may read the bundle. DeleteProviderBundleruns the referenced-bundle guard inside the same transaction as the row delete; at least one Cloud that still takes its provider configuration from the bundle forces409 provider_bundle_referencedcarrying thereferencing_cloudscount, so the operator knows the size of the re-pointing job before retrying. The bundle's own package rows never block the delete: that foreign key cascades.ListProviderBundleCloudsnames the Clouds that count refers to. The page is ordered bycloud_id, each item carries the Cloud'ssluganddisplay_namebeside its id so the roster renders without a read per row, and thelimit/cursorcontract is the oneListProviderBundlesuses. A bundle no Cloud references answers200with an emptyitemsarray, which is what tells an operator the bundle is safe to delete; an id no bundle answers to is a404, so the two states stay distinguishable.- The roster is gated on
provider_bundle#manage— the permissionPatchProviderBundleandDeleteProviderBundlerequire, not theobservethe other read verbs use. A roster row names a Cloud (id, slug, display name) in whatever Domain holds it, andcloud#observederives from the Cloud's own grants and its consuming Projects with no platform derivation at all, so a permission held on a bundle grants nothing on any Cloud.provider_bundle#observedoes derive from the platform singleton, and every bundle carries aplatform:plexsphereparent edge — so gating the roster there would hand a platform auditor, or any per-bundleviewer, the Cloud inventory of every Domain that references the bundle whileGET /v1/cloudsanswers them an empty page. - Once gated on
manage, the page is served whole, with no per-Cloud visibility filter — which is where it departs fromListProviderBundles. A filtered page would under-report how far a promotion of this bundle can reach: a caller shown two of five holders would re-point two Clouds and still be refused the delete, whosereferencing_cloudscount is unfiltered for the same reason. Narrowing the gate rather than filtering the page keeps the two consistent while restricting the roster to the caller who can act on it.
Path and query parameters
| Operation | Parameter | Type | Required | Notes |
|---|---|---|---|---|
| GetProviderBundle / PatchProviderBundle / DeleteProviderBundle / ListProviderBundleClouds / ListProviderBundleVersions | id (path) | string (uuid) | yes | UUIDv7. Non-zero. Malformed or zero → 400 invalid_provider_bundle_id. The ProviderBundleID parameter component is shared across all five operations. |
| ListProviderBundles / ListProviderBundleClouds / ListProviderBundleVersions | cursor (query) | string | no | Opaque HMAC-signed continuation, bound to the caller AND to the operation that minted it. Tampered → 400 invalid_cursor; presented to another of the three paged operations → 400 invalid_cursor; replayed by another caller → 403 cursor_binding_mismatch. |
| ListProviderBundles / ListProviderBundleClouds / ListProviderBundleVersions | limit (query) | integer | no | [1, 200], default 50. Out-of-range → 400 invalid_limit. |
Schemas
The OpenAPI spec is the authoritative source for field shapes. The schemas this surface uses are:
- Request:
ProviderBundleCreateRequest,ProviderBundlePatchRequest. - Response:
ProviderBundleResponse(single),ProviderBundleList(paged),ProviderBundleCloudList(the paged roster) with its item shapeProviderBundleCloudRef(cloud_id,slug,display_name,provider_bundle_version), andProviderBundleVersionList(the paged history) with its item shapeProviderBundleVersion. - Embedded:
CloudProvider(closed enum:aws,azure,gcp,hetzner,openstack),CloudProviderPackage(one declared package:sourceandversion), both shared with the/v1/cloudssurface.
ProviderBundleCreateRequest requires all five fields: display_name, slug, provider, provider_packages, and provider_config_api_version. There is no partial bundle: a Cloud resolves its whole provider configuration through the reference, so a bundle missing either half of that configuration would leave the referencing Cloud with nothing to install.
ProviderBundlePatchRequest declares three optional fields, display_name, provider_packages, and provider_config_api_version, of which at least one must be set. slug and provider are absent from the schema because both are immutable for the lifetime of the bundle; the handler rejects a body that names either rather than dropping the key silently.
The bundle response carries the resolved id, display_name, slug, provider, the provider_packages array naming the Crossplane provider packages the bundle declares (each entry an OCI source and a pinned version), the provider_config_api_version every declared package serves its ProviderConfig under, the latest_version naming the newest declaration the bundle has published, and the created_at / updated_at lifecycle timestamps. provider_packages holds between one and sixteen entries, names each source at most once, and is rendered in canonical source-ascending order whatever order the request stated it in. The shape is shared by every read surface (CreateProviderBundle, GetProviderBundle, ListProviderBundles, PatchProviderBundle) so clients only need one binding.
The optimistic-concurrency counter the aggregate carries stays off the wire. A patch is gated on the version the server itself read, not on one the client echoes back, so there is no version field to send and no conditional header to set. When a competing writer commits between that read and the write, the patch is refused with 409 provider_bundle_stale instead of reverting the winner's change; the same patch applied to a fresh read succeeds.
latest_version is a different number and does cross the wire. It is the operator-facing content version: it starts at 1, a content patch publishes the next one, and it is the value a Cloud attaching without an explicit provider_bundle_version pins. The two counters move independently — a rename bumps the concurrency counter and leaves latest_version alone.
ProviderBundleVersion is one published declaration: version (the number a Cloud pins), provider_packages, provider_config_api_version, created_by, and created_at. A published version is immutable; a content patch appends the next one and rewrites none of the existing rows, which is what lets a Cloud pinned to an older version keep resolving the same declaration.
Error taxonomy
All error responses use the shared Problem envelope (application/problem+json). The 403 path uses the richer PermissionDenied shape carrying the ReBAC denial reason, relation_path, and request correlation_id.
| Code | Status | Where | Meaning |
|---|---|---|---|
invalid_provider_bundle_id | 400 | path-id family | Malformed UUID or zero UUID. |
invalid_provider_bundle | 400 | Create / Patch | Aggregate invariant rejected the body (empty display_name, malformed slug, unknown provider, an empty package set, a source named twice, an entry missing its source or version, or a malformed provider_config_api_version), or a persistence-level CHECK constraint caught a row smuggled past the aggregate. |
slug_immutable | 400 | Patch | Body carried a top-level slug key. |
provider_immutable | 400 | Patch | Body carried a top-level provider key. |
empty_patch | 400 | Patch | Body had no patchable fields set. |
invalid_body | 400 | Create / Patch | Body could not be read or did not parse as the typed request shape. |
invalid_cursor | 400 | List / ListClouds / ListVersions | HMAC verification failed, or the cursor was minted by another paged operation on this surface. |
invalid_limit | 400 | List / ListClouds / ListVersions | Out of [1, 200]. |
unauthenticated | 401 | every operation | Request carries no authenticated principal. |
cursor_binding_mismatch | 403 | List / ListClouds / ListVersions | Cursor was minted for a different caller than the one presenting it. |
provider_bundle_not_found | 404 | path-id family | No provider bundle with the given {id}. |
provider_bundle_slug_conflict | 409 | Create | provider_bundles_slug_unique violation. |
provider_bundle_stale | 409 | Patch | A competing writer overtook the read the patch was derived from. Re-read the bundle and retry. |
provider_bundle_referenced | 409 | Delete | Clouds still take their provider configuration from the bundle; the body carries the referencing_clouds count. The extension is absent on the racing arm, where a Cloud starts referencing the bundle between the guard's count and the DELETE and the foreign-key violation reaches the same code without a count. |
provider_bundle_conflict | 409 | Create / Patch | A uniqueness violation the repository classifier did not attribute to the slug. Every other unique constraint here keys on the bundle id, so the collision is a duplicate id rather than a handle a caller chose. |
request_body_too_large | 413 | Create / Patch | Body exceeded the 8 KiB Cloud Inventory ceiling. |
internal | 500 | every operation | Server-side failure path. |
provider_bundles_not_provisioned | 501 | every operation | The composition root left the surface unwired in this build (Deps.Service or Deps.Authz nil); the detail names the dark operation. |
authz_unavailable | 503 | every operation | The authorization backend is unreachable. Retryable, and deliberately distinct from a denial: a caller told it lacks manage while SpiceDB is down would retry the wrong fix. |
A 403 response carries Problem.code = permission_denied plus the extended PermissionDenied fields documented in ./authz.md.
Cross-references
../../../api/openapi/plexsphere-v1.yaml— OpenAPI 3.1 spec; the*ProviderBundle*operations and theProviderBundleCreateRequest/ProviderBundlePatchRequest/ProviderBundleResponse/ProviderBundleList/ProviderBundleCloudList/ProviderBundleVersionListschemas.../../../internal/transport/http/v1/providerbundles/— the transport-tier implementation: the handlers, the closedProblem.codetaxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter onListProviderBundles.../../../schema/authz.zed— ReBAC schema; thedefinition provider_bundleblock declares themanageandobservepermissions this surface gates on../clouds.md— the sibling Cloud Inventory surface, including theprovider_bundle_idreference a Cloud states to enter bundle mode.../../contexts/provisioning/cloud.md— the bounded-context reference for the aggregate, its invariants, and the outbox events these operations append../authz.md— thePermissionDeniedshape returned on 403.