Skip to content

Hooks HTTP API

This is the reference for the hooks HTTP surfaces. It maps the ListHooks operation and the opt-in managed-push family to their OpenAPI schemas, ReBAC gates, audit emissions, 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 /v1/hooks listing surface is strictly read-only / discovery-only. Each row is a projection of an upstream Kubernetes PlexdHook custom resource that a cluster-hosted plexd agent observed and advertised to the control plane — see ../../../internal/policy/hooks for the one-way projection contract. On this listing surface plexsphere records what the agent observed and reports an image_digest_match verdict; it never writes PlexdHook objects back to the cluster, never resolves the digest against a registry, and exposes no create / patch / delete affordance. A reader expecting a write seam on /v1/hooks is looking at the wrong surface — the cluster is the source of truth for hook objects and this catalog only mirrors them.

The opt-in managed-push family under /v1/domains/{domainId}/managed-push is a separate, per-Domain authoritative-write surface. When a Domain enables managed push it attaches a sealed kubeconfig and plexsphere becomes the source of truth for the PlexdHook objects it publishes into that customer cluster via Server-Side Apply, recording a one-shot-rollbackable trail per push. The family is fail-closed: a Domain that has not opted in (or a deployment that has not provisioned the sealing key) leaves every operation on its 501 managed_push_not_provisioned stub. The full treatment — the ubiquitous language, the threat model, the sealing contract, the SSA-authority model, and the exhaustive Problem-code catalogue — lives in the managed-push bounded-context reference.

The cursor + limit pagination idiom and the per-row visibility filter are inherited from the sibling Fleet Nodes surface — see ./nodes.md for the design rationale this surface mirrors.

Operations

MethodPathOperation IDReBAC gateAudit relationOutbox eventBody cap
GET/v1/hooksListHookstop-level platform:plexsphere#read + per-row domain#read filterdiscovered_hook.list (page-level row)(none)n/a
GET/v1/domains/{domainId}/managed-pushGetManagedPushdomain#readmanaged_push.target.read(none)n/a
PUT/v1/domains/{domainId}/managed-pushPutManagedPushdomain#managemanaged_push.attach(none)64 KiB
DELETE/v1/domains/{domainId}/managed-pushDeleteManagedPushdomain#managemanaged_push.detach(none)n/a
POST/v1/domains/{domainId}/managed-push/hooksPushManagedHookdomain#managemanaged_push.push(none)64 KiB
GET/v1/domains/{domainId}/managed-push/hooks/{pushId}GetManagedHookPushdomain#readmanaged_push.push.read(none)n/a
POST/v1/domains/{domainId}/managed-push/hooks/{pushId}:rollbackRollbackManagedHookPushdomain#managemanaged_push.rollback(none)n/a

The managed-push family emits no outbox event — its audit trail is the canonical record of every attach / detach / push / rollback. The read / manage ReBAC gates reuse the permissions the tenancy schema declares on the domain definition (the same gates GetDomain and DeleteDomain authorise against). The 64 KiB body cap on PutManagedPush (the base64 kubeconfig) and PushManagedHook (the five-field PlexdHook spec) refuses the authenticated-DoS vector an unbounded body would expose; a kubeconfig over the cap surfaces as 413 kubeconfig_too_large.

  • ListHooks.limit query parameter is clamped at the handler to [1, 200] with default 50. Unlike the reject-on-out-of-range surfaces, an out-of-range value is silently brought into range so a paging client never breaks on a tuning mismatch.
  • ListHooks.cursor is opaque and HMAC-signed by the server, bound to the per-(caller, pepper) pseudonym (the codec is the CursorCodec port wired at the composition root). A tampered or malformed cursor surfaces as 400 invalid_cursor; a cursor minted by one caller and replayed by another surfaces as 403 cursor_binding_mismatch. A nil codec is tolerated in unit-test composition roots and behaves as an identity passthrough.
  • ListHooks.domain_id, ListHooks.project_id, and ListHooks.image_digest_match are optional filters. Omitting a filter widens the page along that axis.
  • The handler ships behind a fail-closed scaffold gate — until the composition root wires both the HookLister service and the Authorizer, the listing surface returns 501 hooks_not_provisioned so the surface is either fully wired or fully off.

Path & query parameters

OperationParameterTypeRequiredNotes
ListHooksdomain_id (query)string (uuid)noOptional owning-Domain filter. When present, only hooks on the named Domain are returned.
ListHooksproject_id (query)string (uuid)noOptional Project filter. When present, only hooks on Nodes in the named Project are returned.
ListHooksimage_digest_match (query)booleannoOptional drift filter. true → only hooks whose discovered digest matches the declared digest; false → only mismatches. Omitted keeps both.
ListHookscursor (query)stringnoOpaque HMAC-signed continuation from a previous call's next_cursor. Tampered or malformed → 400 invalid_cursor; cross-caller replay → 403 cursor_binding_mismatch.
ListHookslimit (query)integerno[1, 200], default 50. Out-of-range values are clamped, not rejected.

Schemas

DiscoveredHook

One discovered Kubernetes hook custom resource — a read-only discovery projection per Node: what the agent observed in its cluster, with a flag for whether the discovered image digest matches the declared one. The shape is returned by ListHooks.

FieldTypeRequiredNotes
node_idstring (uuid)yesNode the hook was discovered on (UUIDv7).
domain_idstring (uuid)yesOwning Domain (UUIDv7) — the residency pivot the per-row visibility filter authorises against.
namestringyesDiscovered hook resource name.
image_digeststringyesOCI image digest of the discovered hook image in canonical sha256:<64 lowercase hex> form.
image_digest_matchbooleanyestrue when the discovered image digest matches the digest declared in the Node's capability manifest, false when it diverges.

HookList

Page of discovered hooks returned by GET /v1/hooks. The window is computed by the persistence layer in (node, hook-name) order; per-row visibility is layered on top, so the items array is the subset the caller is authorised to see — len(items) < limit is NOT a reliable end-of-stream signal. Consult next_cursor instead. The continuation token is derived from the persistence-layer page regardless of how many rows the per-row filter dropped, so a fully-filtered page still hands the caller a token to keep paging.

FieldTypeRequiredNotes
itemsarray<DiscoveredHook>yesDiscovered hooks in the current page (post per-row visibility filter).
next_cursorstring (nullable)noOpaque HMAC-signed continuation. Absent or null at end-of-stream. A tampered cursor surfaces as 400 on the next call.

ReBAC contract

ListHooks runs two gates. A top-level platform read check authorises entry to the surface and runs before the persistence read so an unauthorised caller never observes the existence side-channel of the discovered-hook set. A per-row domain#read check then authorises each returned row — the BOLA guard that prevents a platform-reader from seeing hooks on a Domain they cannot read.

OperationRelation evaluatedSubjectObjectOn denial
ListHookstop-level readresolved principal projected onto the SpiceDB subject stringplatform:plexsphere403 PermissionDenied (separate schema); a denial audit row is written first with outcome permission_denied
ListHooksper-row readresolved principal projected onto the SpiceDB subject stringdomain:<uuid> for each candidate rowrow filtered out; per-row denial NOT audited individually (page-level audit row carries the cohort count and a discovered_hook.list.authz_errors counter)

Both gates go through the narrow Authorizer port (declared in ../../../internal/transport/http/v1/hooks/wiring.go), which takes a canonical (subject, relation, object, caveatCtx) tuple. The handler projects the resolved authn.Principal onto the SpiceDB subject string at the boundary. The application service stays bounded-context-pure (it knows nothing about ReBAC); the per-row filter is a transport-layer concern, mirroring the Nodes listing handler.

Per-row authz failure mode is fail-closed with observability: ErrPermissionDenied (or any error wrapping it) drops the row silently; any OTHER error class drops the row, emits a slog.WarnContext breadcrumb, and increments the audit row's discovered_hook.list.authz_errors counter so an operator can see drift between the persistence-layer page and the post-filter response.

Error taxonomy

The closed Problem.code set this surface emits, exactly as defined in ../../../internal/transport/http/v1/hooks/errors.go. The Origin column names the layer (handler / service / transport) so a future maintainer knows where to grep when the code changes.

HTTP statusProblem.codeOriginTrigger
400invalid_cursorhandlerList cursor failed CursorCodec.Decode and was not a binding mismatch.
401unauthenticatedhandlerNo resolved principal (or authn.KindUnknown).
403(PermissionDenied)transportTop-level platform:plexsphere#read denial (separate PermissionDenied schema, not Problem). Per-row denials never reach this gate — they are dropped from items instead.
403cursor_binding_mismatchhandlerList cursor was minted for a different caller than the one presenting it (the per-(caller, pepper) HMAC binding rejected the replay).
500internalhandler / serviceHookLister.List returned a non-nil error, or CursorCodec.Encode failed on the next-page cursor. The underlying error text is logged via slog.ErrorContext and NEVER leaks to the wire.
501hooks_not_provisionedhandlerScaffold fail-closed gate: the HookLister service OR the Authorizer is not wired in this build.

There is no out-of-range limit error code on this surface — the handler clamps rather than rejects.

Audit & outbox contract

ListHooks is a read-only surface, so it never writes an outbox event. The application read service emits no audit row of its own; the handler stamps exactly one page-level audit row per successful response via the wired AuditSink. Sink errors are NOT propagated to the caller — a flaky audit backend cannot turn a successful read into a 5xx, and the slog.WarnContext breadcrumb is the operator's tripwire.

OperationOutcomeAudit relationAudit outcomeOutbox event
ListHookssuccessdiscovered_hook.list (page-level row)granted(none)
ListHooks403 platform-gate denialdiscovered_hook.listpermission_denied(none)
ListHooks401 / 400 / 500 / 501(none — those arms exit before the audit emission)n/a(none)

The audit row stamps the canonical platform singleton (platform:plexsphere) as its Object because the list operation is cross-Domain and optionally cross-Project — there is no single aggregate to address. The caveat_context map carries:

  • count — the number of rows in the response after per-row visibility filtering, NOT the persistence-layer page size.
  • persistence_count — present only when the per-row filter dropped at least one row; the pre-filter persistence-page size, so an auditor of a suspected over-broad cross-Domain read can see the drop ratio (a fully-filtered page would otherwise show only count=0).
  • discovered_hook.list.authz_errors — present only when the per-row filter dropped at least one row due to an infrastructural fault (transport flake, schema drift). Absent when every drop was a clean ErrPermissionDenied.

The denial audit row written by the top-level platform gate carries a missing_relation caveat naming the relation the caller lacked.

Managed-push surface

The opt-in managed-push family is a per-Domain authoritative-write surface; this section is a map of its wire contract — the exhaustive catalogue lives in the managed-push bounded-context reference and the authoritative shapes in api/openapi/plexsphere-v1.yaml.

The four request / response schemas the family adds:

  • ManagedPushAttachRequest — the PutManagedPush body: a base64-encoded kubeconfig plus the enabled opt-in flag. The Service validates and seals the kubeconfig; the plaintext never reaches an audit row or a log. Only embedded, in-band credentials are accepted — a token, or client-certificate-data / client-key-data, with the CA inline as certificate-authority-data. A kubeconfig carrying an exec / auth-provider plugin, a file-path credential (tokenFile, client-certificate, client-key), a certificate-authority file path, or a proxy-url is rejected with 422 kubeconfig_invalid; see the validation catalog in the managed-push context reference.
  • ManagedPushTarget — the GetManagedPush / PutManagedPush response: a display-only projection (enabled, the kubeconfig SHA-256 fingerprint, the API-server URL, timestamps) that never carries the kubeconfig ciphertext or plaintext.
  • ManagedHookPushRequest — the PushManagedHook body: the five-field PlexdHook spec (name, image digest, parameters, timeout seconds, sandbox) plus the target namespace.
  • ManagedHookPush — the trail record returned by PushManagedHook / GetManagedHookPush / RollbackManagedHookPush: the push id, hook name, namespace, status, a had_prior_state boolean, and the push / rollback timestamps. The captured prior cluster object is reduced to the boolean and is never projected onto the wire.

The closed Problem.code set the managed-push family emits, exactly as pinned in ../../../internal/transport/http/v1/managedpush/wiring.go:

HTTP statusProblem.codeTrigger
400invalid_domain_id / invalid_push_id / invalid_bodyA path id or request body failed shape validation at the handler boundary.
401unauthenticatedNo resolved principal.
403(PermissionDenied)The per-operation domain#read / domain#manage gate denied the caller (separate PermissionDenied schema, not Problem).
404managed_push_not_configured / domain_not_found / push_not_foundThe Domain has no target, the addressed Domain does not exist, or no recorded push matches the id.
409managed_push_disabled / managed_push_conflict / push_already_rolled_backThe target is disabled, a competing field manager owns a field the apply touches, or the push was already rolled back.
413kubeconfig_too_largePutManagedPush body exceeded the 64 KiB cap.
422kubeconfig_invalid / plexd_hook_invalidThe attach-time kubeconfig or the push-time PlexdHook spec failed validation.
502cluster_unreachableThe customer cluster could not be reached to apply or roll back.
500internalAn unexpected error; the underlying text is logged, never surfaced.
501managed_push_not_provisionedFail-closed opt-in: the Domain has not enabled managed push (or the deployment has not provisioned the sealing key), so the surface is fully off.

Cross-references