Skip to content

Sink enablement ​

This document is the bounded-context reference for the SinkEnablement aggregate: the explicit grant that makes a Sink declared by a Domain usable inside one Project of that Domain. The domain root that pins the ubiquitous language is ../../../internal/observability/sinkenablement.

Declaring a sink at the Domain does not hand it to the Domain's Projects. A principal asks for the grant, a second principal decides it, and an approved grant may later be withdrawn. That sequence is what an operator talks about and audits, so the grant is an aggregate with its own lifecycle rather than a flag on the Sink. The context owns the aggregate, its five domain events, the persistence, the application service that drives every transition, and the authz sync arms that turn two of those events into the sink#uses tuple.

Ubiquitous language ​

The terms below travel verbatim across the domain root, the events, the application service, the persistence adapter and the authz sync arms.

TermDefinitionCode anchor
Sink enablementThe aggregate root: one Project's permission to deliver telemetry to one Sink of that Project's own Domain, identified by a UUIDv7. It names the Project, the Sink, the lifecycle state, the principal that asked for the grant and the decision that settled it.../../../internal/observability/sinkenablement/sinkenablement.go (SinkEnablement)
GrantThe permission an enablement carries, and the name of the one-step push a principal holding the sink's assign permission uses to place that permission without a request.../../../internal/observability/sinkenablement/services/grant.go (Grant)
RequestThe ask: a principal holding the Project's deploy permission files an enablement in the requested state. A request grants nothing until it is decided.../../../internal/observability/sinkenablement/services/service.go (Request)
Four-eyes decisionThe rule that an enablement names both the principal that asked for it and the principal that decided it, and that on an approval the two are not the same principal.../../../internal/observability/sinkenablement/services/approve.go (Approve)
RequesterThe principal that filed the request, recorded as the raw principal UUID. It is fixed when the enablement is created; no transition rewrites it.../../../internal/observability/sinkenablement/sinkenablement.go (RequestedBy)
DeciderThe principal that moved the enablement out of requested, recorded as a ReBAC subject string together with the decision instant and the note. An approval records no note; a rejection and a revocation both do.../../../internal/observability/sinkenablement/sinkenablement.go (DecidedBySubject)

The lifecycle ​

An enablement holds one of four states, and the transition table on the State type is closed:

StateMeaningTerminal
requestedThe grant has been asked for and awaits a decision.no
approvedA decider accepted the request; the sink is enabled for the Project.no
rejectedA decider declined the request.yes
revokedA previously approved grant was withdrawn.yes

Three transitions connect them: requested → approved, requested → rejected and approved → revoked. approved and rejected are reachable from requested only, revoked from approved only, and the two terminal states are reachable from nowhere else. The transition methods are value semantics on the aggregate: each returns a new value and leaves the receiver alone, so an illegal transition costs a returned error and no mutated state.

There is no materialisation step between approved and effective. A sink's connection material stays in the KV store and the export pipeline reads it at export time from the path derived off the sink, so an approved enablement is effective the instant it is persisted.

Five entry points on the application service move the machine, each gated on a ReBAC permission before it touches a row:

  • Request needs the Project's deploy permission, which the schema defines as admin + maintainer + parent->manage. A Project admin, a Project maintainer and a Domain admin can therefore all file one. A gate on the bare relations would leave the path unreachable by the natural actor: creating a Project writes only the Domain parent edge, so the Domain admin who created it holds no relation on the Project itself.
  • Approve needs the sink's assign permission (owner + assigner) and is subject to the four-eyes rule below. It advances the enablement to approved and writes the backing tuple.
  • Reject needs the sink's assign permission. It records the decider and the reason, and touches no tuple.
  • Grant needs the sink's assign permission. It is the owner push: a fresh enablement is created and advanced to approved in one action, and the granting principal is stamped as both the requester and the decider, because it initiates and authorises the grant in one step.
  • Revoke is two-party. The revoking principal needs either the sink's assign permission (the sink authority withdrawing the grant) or the consuming Project's deploy permission (the Project walking away from a destination it no longer wants). The two legs hang off different objects, so the gate is two sequential checks rather than one union permission; the Project leg is consulted only after the sink leg denies, and an assigner's revocation costs a single round-trip.

Reading a Project's enablements is a sixth entry point, List, gated on the Project's read permission and returning one keyset-ordered page.

A rejected or a revoked enablement is done. A Project that wants the sink again files a fresh request, which the partial unique index described under persistence admits precisely because the two terminal states sit outside it.

The four-eyes rule ​

Approve refuses when the approving principal is the one that filed the request. Three properties of the guard matter:

  • The comparison runs on the principal UUIDs the subject strings carry, not on the strings, so a principal cannot dodge the rule by re-spelling its own subject with a different kind prefix.
  • It runs before the permission check, so a requester that holds assign on the sink still cannot decide its own request, and the refusal costs no authorizer round-trip.
  • The requester it compares against is read off the persisted row, never off the caller's input. A caller-supplied requester is the value the guard exists to check, so trusting it would let any caller disarm the rule.

Reject carries no self-guard. The rule exists to stop a principal granting itself access, and a rejection grants nothing; refusing a self-rejection would only strand pending requests nobody else wants to decide, so a requester may withdraw its own request.

Grant carries no self-guard either, and that absence is the path's purpose. There is no separate requester to compare the granting principal against, because one principal initiates and authorises the enablement in one step. Without the path, the owner of a sink wanting to hand it to a Project would have no route at all: requesting and then approving its own request is exactly what the rule refuses.

What a grant may target ​

Two rules the aggregate cannot hold live in the application service, which resolves the target Sink and applies both before any row or tuple exists:

  • The Project's own Domain. A Project may only be bound to a tenant sink its own Domain declared; a sink from another Domain is refused. The rule is enforced here rather than in the ReBAC schema because the graph cannot express it: sink#use is a plain union, since domain declares no use permission for an arrow to reach, so there is no operand that could carry "the Project must hang off the Domain that declared the sink". A caveat on the uses write was rejected at the schema, because a caveat is evaluated at check time on data the writer supplies and would re-ask the question this guard already answered. The write path is the one place where both aggregates are in hand.

    Both Domains the guard compares are read from the aggregate that owns them: the sink's off the Sink, the Project's through a ProjectReader port the service declares locally and the composition root bridges onto the identity context. Neither is a request field. This matters most on the Grant path, which checks assign on the sink and no permission on the target Project at all — the same-Domain comparison is the only thing binding that Project to the granting principal's authority, so a caller-supplied Domain would be a cross-tenant exfiltration path: a sink owner in one Domain could name a Project in another and pass its own Domain to satisfy the comparison.

  • Tenant sinks only. A built-in sink is refused. The three platform backends are usable without a grant, and they are seeded through the repository's built-in path, which emits no event, so they carry no tuples at all and there is nothing for a grant to add.

Event-to-tuple mapping ​

internal/authz/sync.MapEvent translates a discriminated outbox row into the relationships to write and the delete filters to apply. The enablement arms are registered in ../../../internal/authz/sync/mapping_observability.go alongside the sink lifecycle arms, and this table mirrors the truth there.

Enablement eventWriteDelete
obssinkenablement.SinkEnablementRequested— (a pending request granted nothing)—
obssinkenablement.SinkEnablementGrantedsink:<sink_id> <-[uses]- project:<project_id>—
obssinkenablement.SinkEnablementApprovedsink:<sink_id> <-[uses]- project:<project_id> (the identical tuple, TOUCH-idempotent)—
obssinkenablement.SinkEnablementRejected— (a declined request granted nothing)—
obssinkenablement.SinkEnablementRevoked—narrow (sink:<sink_id>, uses, project, <project_id>)

Three properties are worth reading aloud:

  • Granted and Approved share one arm. Granted is the owner push, Approved the decision on a Project-initiated request, and both make the identical grant effective. The authorizer's write has TOUCH semantics, so the duplicate write across the two events is a no-op at the SpiceDB layer rather than a conflict, and the uses edge survives either event being replayed on its own. Two arms with identical bodies would buy no behavioural difference and only give the two paths room to drift apart.
  • Revoked deletes narrowly. The filter pins the sink and the Project, so a sibling Project granted the same sink keeps its tuple. The wholesale purge is what the obssinks.SinkDeleted arm does, and it is correct only when the sink object itself is gone.
  • Requested and Rejected touch nothing. Neither event ever made the sink usable for the Project, so neither has graph state to add or reclaim.

Revoking withdraws two things at once. use = uses + owner + assigner, so the Project loses use on the sink with the edge itself; and observe = owner + assigner + parent->manage + uses->read reaches the Project through uses->read, so the Project's readers lose the observe path over the sink as well.

The sync arm is the backstop, not the only writer. Approve and Grant write the same tuple through the authorizer, and Revoke issues the same narrow delete, so a caller's follow-up check on the same context is read-your-writes instead of waiting on outbox relay latency; the arm re-converges the graph when its event is replayed.

The graph write is deliberately ordered after the transaction commits. Each of the three paths commits its row and its outbox event first, emits its audit row, and only then touches the graph, so a half-completed call leaves a graph that lags a committed event — and the committed event is exactly what the sync arm replays to close it. The reverse order looks equivalent and is not: writing first puts the tuple ahead of the live-unique index that Grant relies on and ahead of the optimistic-concurrency guard that Approve relies on, so a duplicate or a racing Reject would strand a live uses edge behind a row that never became approved. Nothing reclaims that edge, because Reject touches no tuple and Revoke refuses any state other than approved. Deleting first has the mirror failure: the project's telemetry stops being deliverable while every read surface still reports the grant as live.

What a failing graph mutation means for the caller depends on which direction failed, and the two are deliberately different signals. A failed Approve or Grant write is fail-safe: the transition committed and the edge is merely missing, so nobody holds access they should not. The caller is handed the committed enablement together with a "grant not yet effective" signal and must not retry an approval or a grant that already happened and would now come back as an illegal transition or a duplicate. A failed Revoke delete inverts that. The row reads revoked while the edge is still in the graph, so the project keeps delivering to the sink and keeps the uses->read path over it. That is fail-open, it carries its own signal, and a caller may not render it as a completed revocation — an operator withdrawing access during an incident has to learn that the access persists. Retrying is refused in both directions, so the removal is left to the committed event replaying through the sync arm, or to an operator acting on the graph directly.

Ordering the mutation after the commit fixes which side can fail unrepaired; it does not make the mutation mutually exclusive with a concurrent transition on the same pair, and each direction guards itself as far as it can. Approve and Grant re-read the enablement immediately before writing and skip the write once the row has moved off approved, so a stalled approval cannot resurrect a grant somebody has since withdrawn. Revoke cannot re-read its own row — revoked is terminal, so the row reads the same however long the call has been stalled — so it probes the pair instead: while an approved enablement still backs (project, sink), the edge belongs to a newer grant and the delete is skipped, because a re-enablement filed after the revocation committed is a different row owning the same tuple. Both skips are logged, and neither changes what the caller is handed.

Both guards are narrow on purpose, and what they leave open is worth reading aloud. Each still loses a race in the window between its probe and its mutation. Neither reaches the sync arm, which is the second writer of the same tuple: a mapper is a pure function of its event payload, so it has no row to consult and writes or deletes unconditionally, and the relay that dispatches it drains rows in parallel under FOR UPDATE SKIP LOCKED — it converges, it does not order. So a committed Approved event that reaches SpiceDB after the Revoked event following it in the outbox puts the withdrawn edge back, and a Revoked event dispatched late strips an edge a newer grant owns. Closing that needs a serialisation point spanning the transaction and the graph mutation for one (project, sink) pair, which this context has no primitive for. The follow-up reconcile sweep is what converges every one of these leftovers, and it has to work in both directions — purging uses edges with no approved backing enablement and restoring the edge for an approved enablement whose tuple went missing.

Persistence ​

Two tables back the context: ../../../internal/platform/db/migrations/0084_sink_enablements.sql.

plexsphere.sink_enablement holds one row per grant: the Project, the Sink, the state, the requester, and the three decision columns (decided_by_subject, decided_at, decision_reason) that stay empty until a decision lands. The four state CHECK literals are exactly the aggregate's State constants, so the storage CHECK and the closed enum cannot drift. project_id references the Project ON DELETE CASCADE, because a Project deletion is the canonical tear-down of every piece of derived state. sink_id references the sink ON DELETE RESTRICT, because a cascade would be the one write path to this table that appends no outbox row: a sink delete would prune the enablements without the revocation event the rest of the platform reads.

sink_enablement_live_unique is a unique index on (project_id, sink_id) restricted to the two non-terminal states, so at most one live enablement exists per pair while a rejected or a revoked row stays outside the constraint. A full unique index was rejected: one historical terminal row would occupy the pair forever, and a Project whose request was once declined could never ask for that sink again. An application-side check without the index was rejected too, because two concurrent requests would each read "no live row" and both insert.

plexsphere.sink_enablement_outbox_token records, per enablement and event type, that the event was emitted. The primary key on the pair is the structural at-most-once guarantee: a retried request or decision finds the token and appends no second outbox row. Every state change writes the enablement row, the outbox row and the token in one transaction, so no consumer sees an event for a write that rolled back. The update statement carries an optimistic-concurrency guard as well: the caller passes the state it read before computing the transition, and a row a concurrent transition already moved matches zero rows instead of overwriting the winner.

The down arm drops both tables in reverse-FK order rather than refusing the downgrade, which is the posture plexsphere.cloud_credential_assignment takes; the sinks table below it refuses the downgrade instead, so a rollback past this migration stops at 0083. These two tables hold lifecycle metadata; the decision trail lives in plexsphere.outbox_events and the audit chain, both anchored on their own identifiers and untouched by the drop, so a downgrade resurrects no retention-sensitive material on a later upgrade.

The drop is not the whole story, because these rows are the only Postgres anchor for the sink#uses tuples in SpiceDB. Dropping the table emits no revoke event, so the edges outlive it, and the same holds for the ON DELETE CASCADE on project_id — mapProjectDeleted purges tuples where the Project is the resource, and in sink:<sink_id> <-[uses]- project:<id> it is the subject. Both leave graph litter an operator has to purge by hand until the reconcile sweep above lands; the same gap applies to the cloudcredential#uses and cloud#uses edges, so it is a platform-wide property rather than one this context introduced.

Audit trail ​

Every successful transition emits exactly one audit row, and its relation names the transition: sinkenablement.request, sinkenablement.approve, sinkenablement.grant, sinkenablement.reject, sinkenablement.revoke and sinkenablement.list. grant stays distinct from approve so an audit reader can tell the sink owner's push from a decision on somebody's request, the same split the event types make.

Each row carries names only. Its caveat context is the list of field names the row is about (project_id, sink_id, enablement_id) and never a value of any of them, so a sink's endpoint, its credential coordinates and an operator's decision note cannot reach an audit row. A listing names the Project rather than a sink, because the page spans every sink the Project asked for, and it carries project_id alone.

One denial is audited: a revocation that both legs deny. The attempt persists nothing, so the audit row is the only trace it leaves; it records the insufficient-relation reason and lists both assign and deploy as the paths the subject failed. A successful revocation records the single relation that decided it. Every other refusal on this surface leaves no audit row: a self-approval, a built-in or cross-Domain target, and a denied request each return an error and record nothing.

The audit sink is optional. A service wired without one degrades silently, and a recording failure is logged rather than propagated, so a flaky audit backend cannot turn a committed grant into a user-visible failure.

Operator surfaces ​

Four /v1 operations carry the lifecycle: RequestSinkEnablement and ListSinkEnablements under /v1/projects/{id}/sink-enablements, GrantSinkEnablement under /v1/sinks/{id}/sink-enablements, and RevokeSinkEnablement under /v1/sink-enablements/{id}/revoke. Their gates, the sync_pending contract and the closed Problem.code taxonomy are in ../../reference/api/sinks.md.

The CLI family is plexctl sink enablement. The Console carries the lifecycle on the Project detail's Telemetry tab, whose sink-enablements panel lists what the Project may route to and offers Request and Revoke.

Approve and reject are not on this surface. They are taken on the decision queue, which projects every enablement as a row of kind sink_enablement gated on the sink's assign permission and dispatches the decision back into Approve / Reject here. plexctl approval list --kind sink_enablement is the CLI equivalent, and the Console's approvals view carries the same rows.

Route authoring reads this grant. The route management service refuses a route that names a tenant sink the owning Project holds no approved enablement for, and the delivery engine applies the same predicate on every resolution, so a revoked grant takes the target out of the route without invalidating the route itself.

The sync arms run independently of any transport: they register at package init in the authz sync mapping, which the outbox consumer drives, so a replayed event re-converges the graph whichever entry point wrote the row.

Cross-references ​