Skip to content

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

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
POST/v1/provider-bundlesCreateProviderBundleplatform platform#manageprovider_bundle.createProviderBundleCreated8 KiB
GET/v1/provider-bundlesListProviderBundlesper-row provider_bundle#observe filterprovider_bundle.read (granted, with post-filter item_count)(none)n/a
GET/v1/provider-bundles/{id}GetProviderBundleprovider_bundle#observe (BEFORE persistence read)provider_bundle.read(none)n/a
PATCH/v1/provider-bundles/{id}PatchProviderBundleprovider_bundle#manageprovider_bundle.update (NAMES-only field list)ProviderBundleUpdated8 KiB
DELETE/v1/provider-bundles/{id}DeleteProviderBundleprovider_bundle#manageprovider_bundle.deleteProviderBundleDeletedn/a
GET/v1/provider-bundles/{id}/cloudsListProviderBundleCloudsprovider_bundle#manage (BEFORE persistence read)provider_bundle.read (granted, with item_count)(none)n/a
GET/v1/provider-bundles/{id}/versionsListProviderBundleVersionsprovider_bundle#observe (BEFORE persistence read)provider_bundle.read (granted, with item_count)(none)n/a
  • body_cap = 8 KiB (MaxProviderBundleRequestBodyBytes in internal/transport/http/v1/providerbundles/wiring.go) is enforced before the JSON decoder runs; an over-cap body surfaces as 413 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.
  • CreateProviderBundle runs the platform gate BEFORE it calls the service, so an unauthorised caller never produces a ProviderBundleCreated outbox row. On success the response carries a Location header pointing at /v1/provider-bundles/{id}.
  • The 201 is ahead of the ReBAC graph. The two grants that make a new bundle readable — the platform parent edge and the creator's bundle_admin grant — are written by the authz-sync consumer draining the ProviderBundleCreated outbox row, not by the create request. Until it has drained, GET /v1/provider-bundles/{id} on the bundle just created answers 403 and ListProviderBundles omits the row. A client reading back immediately should retry on 403 rather 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_found arm 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 reads 403. 404 is 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.limit is clamped at the handler to [1, 200] with default 50.
  • ListProviderBundles.cursor is opaque, HMAC-signed by the server through the CursorCodec port and bound to the calling principal. A tampered cursor surfaces as 400 invalid_cursor; a cursor replayed by a different caller than the one that minted it surfaces as 403 cursor_binding_mismatch.
  • ListProviderBundles layers a per-row provider_bundle#observe check on top of the slug-ordered persistence window, so the response page is the subset the caller is authorised to see. The next_cursor is 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 answers 503 authz_unavailable rather than a 200 with an empty page that would report "you may see nothing" for an outage.
  • GetProviderBundle runs the provider_bundle#observe check before the persistence read, so an unauthorised caller receives 403 without the existence side-channel a "load-then-check" flow would leak.
  • PatchProviderBundle rejects bodies that carry a slug key (even with the same value) at decode time with 400 slug_immutable. The slug is the handle a bundle is addressed by across the installation.
  • PatchProviderBundle rejects bodies that carry a provider key at decode time with 400 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.
  • PatchProviderBundle requires the body to set at least one of display_name, provider_packages, or provider_config_api_version; an empty body surfaces as 400 empty_patch. A provider_packages patch 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, a source named twice, or an entry missing one of its two members is rejected with 400 invalid_provider_bundle. provider_config_api_version patches on its own, without restating the package set.
  • A content patch — one carrying provider_packages or provider_config_api_version — publishes a new version and reports its number in latest_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 leaves latest_version where 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} naming provider_bundle_version, which promotes that one Cloud. See the pinned version for the write side.
  • ListProviderBundleVersions returns the published history, newest first, each row carrying the whole declaration that version froze: its provider_packages, its provider_config_api_version, the created_by subject that published it, and the created_at timestamp. The limit / cursor contract is the one ListProviderBundles uses, with the cursor payload being a version number. created_by is 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 permission GetProviderBundle requires, not the manage the referencing-Clouds roster narrowed itself to. A version row is bundle content — the same packages and apiVersion GetProviderBundle already serves under observe, frozen under an older number — so it discloses nothing about any Cloud and stays answerable to every caller who may read the bundle.
  • DeleteProviderBundle runs 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 forces 409 provider_bundle_referenced carrying the referencing_clouds count, 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.
  • ListProviderBundleClouds names the Clouds that count refers to. The page is ordered by cloud_id, each item carries the Cloud's slug and display_name beside its id so the roster renders without a read per row, and the limit / cursor contract is the one ListProviderBundles uses. A bundle no Cloud references answers 200 with an empty items array, which is what tells an operator the bundle is safe to delete; an id no bundle answers to is a 404, so the two states stay distinguishable.
  • The roster is gated on provider_bundle#manage — the permission PatchProviderBundle and DeleteProviderBundle require, not the observe the other read verbs use. A roster row names a Cloud (id, slug, display name) in whatever Domain holds it, and cloud#observe derives 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#observe does derive from the platform singleton, and every bundle carries a platform:plexsphere parent edge — so gating the roster there would hand a platform auditor, or any per-bundle viewer, the Cloud inventory of every Domain that references the bundle while GET /v1/clouds answers them an empty page.
  • Once gated on manage, the page is served whole, with no per-Cloud visibility filter — which is where it departs from ListProviderBundles. 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, whose referencing_clouds count 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

OperationParameterTypeRequiredNotes
GetProviderBundle / PatchProviderBundle / DeleteProviderBundle / ListProviderBundleClouds / ListProviderBundleVersionsid (path)string (uuid)yesUUIDv7. Non-zero. Malformed or zero → 400 invalid_provider_bundle_id. The ProviderBundleID parameter component is shared across all five operations.
ListProviderBundles / ListProviderBundleClouds / ListProviderBundleVersionscursor (query)stringnoOpaque 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 / ListProviderBundleVersionslimit (query)integerno[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 shape ProviderBundleCloudRef (cloud_id, slug, display_name, provider_bundle_version), and ProviderBundleVersionList (the paged history) with its item shape ProviderBundleVersion.
  • Embedded: CloudProvider (closed enum: aws, azure, gcp, hetzner, openstack), CloudProviderPackage (one declared package: source and version), both shared with the /v1/clouds surface.

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.

CodeStatusWhereMeaning
invalid_provider_bundle_id400path-id familyMalformed UUID or zero UUID.
invalid_provider_bundle400Create / PatchAggregate 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_immutable400PatchBody carried a top-level slug key.
provider_immutable400PatchBody carried a top-level provider key.
empty_patch400PatchBody had no patchable fields set.
invalid_body400Create / PatchBody could not be read or did not parse as the typed request shape.
invalid_cursor400List / ListClouds / ListVersionsHMAC verification failed, or the cursor was minted by another paged operation on this surface.
invalid_limit400List / ListClouds / ListVersionsOut of [1, 200].
unauthenticated401every operationRequest carries no authenticated principal.
cursor_binding_mismatch403List / ListClouds / ListVersionsCursor was minted for a different caller than the one presenting it.
provider_bundle_not_found404path-id familyNo provider bundle with the given {id}.
provider_bundle_slug_conflict409Createprovider_bundles_slug_unique violation.
provider_bundle_stale409PatchA competing writer overtook the read the patch was derived from. Re-read the bundle and retry.
provider_bundle_referenced409DeleteClouds 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_conflict409Create / PatchA 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_large413Create / PatchBody exceeded the 8 KiB Cloud Inventory ceiling.
internal500every operationServer-side failure path.
provider_bundles_not_provisioned501every operationThe composition root left the surface unwired in this build (Deps.Service or Deps.Authz nil); the detail names the dark operation.
authz_unavailable503every operationThe 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 the ProviderBundleCreateRequest / ProviderBundlePatchRequest / ProviderBundleResponse / ProviderBundleList / ProviderBundleCloudList / ProviderBundleVersionList schemas.
  • ../../../internal/transport/http/v1/providerbundles/ — the transport-tier implementation: the handlers, the closed Problem.code taxonomy, the body-cap and limit-clamp constants, and the per-row visibility filter on ListProviderBundles.
  • ../../../schema/authz.zed — ReBAC schema; the definition provider_bundle block declares the manage and observe permissions this surface gates on.
  • ./clouds.md — the sibling Cloud Inventory surface, including the provider_bundle_id reference 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 — the PermissionDenied shape returned on 403.