Skip to content

control-plane-api: expose capability bundles and bits in GraphQL, deprecate legacy capability fields#3144

Open
GregorShear wants to merge 12 commits into
masterfrom
greg/gql-legacy-capability-phase1
Open

control-plane-api: expose capability bundles and bits in GraphQL, deprecate legacy capability fields#3144
GregorShear wants to merge 12 commits into
masterfrom
greg/gql-legacy-capability-phase1

Conversation

@GregorShear

@GregorShear GregorShear commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Next step in migrating the GraphQL API off the legacy ordered Capability enum (read/write/admin) and onto the orthogonal capability model, staged so the frontend can migrate operation-by-operation. The Capability type keeps its name; every field that reports it is deprecated, with a fine-grained CapabilityBit replacement to migrate to.

  • Bundle variants are renamed to action-oriented names (ViewerView, WriterWrite, EditorEdit, BillingManageBilling, TeamAdminManageUsers, ManageDataPlaneManageDataPlanes), with a migration that relabels the capability_bundle enum values in place — existing user_grants.bundles / role_grants.bundles rows carry over without a backfill.
  • prefixes(by:) gains a withCapabilities: [CapabilityBit!] filter and deprecates minCapability — a "minimum" has no meaning in the orthogonal model. The filter takes fine-grained bits rather than bundles because the bundle → bit mapping is dynamic: callers state the operations they need without tracking bundle composition. Both inputs reduce to a required CapabilitySet matched by superset; the user must hold all listed capabilities, so listing more only narrows results. Supplying both is an error, and supplying neither applies no capability filter.
  • The GraphiQL playground now requests inputValueDeprecation in introspection, so deprecated inputs render struck through instead of disappearing from the docs.

Every legacy capability output (DataPlane.userCapability, StorageMapping.userCapability, LiveSpecRef.userCapability, PrefixRef.userCapability, InviteLink.capability, RedeemInviteLinkResult.capability) is now @deprecated, and each gains a capabilityBits: [CapabilityBit!] replacement reporting the user's fine-grained capabilities. UI gating consumes bits exclusively — resource-shaped types deliberately do not report bundles, which are a grant-time concept.

  • LiveSpecRef's capabilityBits is null when the user has no access, mirroring userCapability.
  • InviteLink and RedeemInviteLinkResult additionally report capabilityBundles: [CapabilityBundle!]! — the bundle explicitly selected when the link was created (bundles_for_legacy), not everything the grant implies: an admin link reports [Admin], not also ManageUsers, ManageBilling, etc. — while capabilityBits carries the full effective set (bits_for_legacy). createInviteLink still takes the single legacy capability input; input-side migration is deferred until invite links store explicit capability bundles.
  • PrefixRef.capabilities: [CapabilityBit!]! already shipped, so it stays bits-typed and wire-compatible, deprecated as renamed to capabilityBits, and gets deleted once clients migrate — the capabilities name is never reused with a different type.
  • New plumbing: tables::UserGrant::get_user_capabilities returns the union of capability bits at prefixes covering a name; attach_user_capabilities hands resolvers both the legacy capability and the bit set.
  • flowctl builds with -D warnings and graphql_client turns schema deprecations into Rust #[deprecated], so its queries opt out via deprecated = "allow" until flowctl migrates.

Deploy ordering

The relabel migration must run before the new code rolls out, since the new binary reads and writes the new labels. During the rollout window, old pods can't decode grant rows carrying renamed bundle labels (only one tenant affected), and the old binary's private data-plane provisioning still writes manage_data_plane, which the relabeled enum rejects — avoid provisioning a private data plane mid-deploy.

… CapabilityBundle + withCapability prefix filter

show deprecated inputs in the gql playground
@GregorShear
GregorShear force-pushed the greg/gql-legacy-capability-phase1 branch from 0dea593 to 5e88e15 Compare July 10, 2026 03:18
@GregorShear
GregorShear requested a review from jshearer July 10, 2026 14:18
…ry capability output with bundle + bit fields

Deprecations:
- All six LegacyCapability outputs (DataPlane.userCapability, StorageMapping.userCapability, LiveSpecRef.userCapability, PrefixRef.userCapability, InviteLink.capability, RedeemInviteLinkResult.capability) and the createInviteLink capabilityLegacy argument now carry @deprecated, each pointing at its replacement.
- flowctl consumes the deprecated fields knowingly: graphql_client turns schema deprecations into Rust #[deprecated] and flowctl builds with -D warnings, so the status query opts out via deprecated = "allow".

prefixes filter:
- withCapability: CapabilityBundle becomes withCapabilities: [CapabilityBundle!]. The user must hold all listed bundles; each bundle's bits union into the required CapabilitySet and the existing superset match applies, so listing more bundles only narrows results.

New outputs:
- DataPlane, StorageMapping, LiveSpecRef, InviteLink, and RedeemInviteLinkResult gain capabilities: [CapabilityBundle!] and capabilityBits: [CapabilityBit!]. A bundle is reported when its full capability set is covered by the user's effective bits (CapabilityBundle::covered_by), which makes the reported capabilities exactly the values that match under the withCapabilities filter.
- LiveSpecRef's pair is null when the user has no access, mirroring userCapability.
- Invite-link values are derived from the stored legacy capability via bits_for_legacy; storing bundles on invite links is deliberately deferred.
- PrefixRef keeps capabilities: [CapabilityBit!]! wire-compatible with master (deprecated as renamed to capabilityBits); it gains a bundle-typed capabilities only after clients migrate off the bits-typed name.

Plumbing: tables::UserGrant::get_user_capabilities returns the union of capability bits at prefixes covering a name, and attach_user_capabilities now hands closures both the legacy capability and the bit set.
@GregorShear GregorShear changed the title control-plane-api: rename GraphQL Capability to LegacyCapability, expose CapabilityBundle control-plane-api: rename GraphQL Capability to LegacyCapability, expose capability bundles and bits Jul 10, 2026
…input-side migration until invite links store explicit capability bundles
// Ask for deprecated input fields/arguments in the
// introspection query, so they show (struck through) in the
// docs instead of being silently hidden.
inputValueDeprecation: true,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure we can still see deprecated input fields in the graphql playground

Comment thread crates/control-plane-api/src/server/public/graphql/invite_links.rs Outdated
Comment on lines +8 to +13
alter type capability_bundle rename value 'viewer' to 'view';
alter type capability_bundle rename value 'writer' to 'write';
alter type capability_bundle rename value 'editor' to 'edit';
alter type capability_bundle rename value 'billing' to 'manage_billing';
alter type capability_bundle rename value 'team_admin' to 'manage_users';
alter type capability_bundle rename value 'manage_data_plane' to 'manage_data_planes';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i haven't quite figured out whether this migration should be (or can be) run before or after the deployment ... someone help me think about this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the strategy will be to run this migration right before we deploy the new build - my understanding is that there would be a few moments where snapshot refresh will fail, but it should recover gracefully (see error condition at /flow/crates/control-plane-api/src/server/snapshot.rs line 409)

@GregorShear GregorShear Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And also there's only one tenant with a capability bundle manage_data_plane

…pe under its original name

The rename existed to eventually vacate the Capability name for the bundle enum, but the planned union-enum migration (fold deprecated lowercase legacy values into the renamed bundle enum) makes vacating unnecessary. Dropping the rename removes two transitional concepts (LegacyCapability and the CapabilityCompat input shim) from the schema and this PR: models::Capability publishes under its original Capability name again, and the minCapability and createInviteLink inputs take it directly. All legacy-field deprecations are unchanged.
extern_enums("Capability"),
// flowctl knowingly consumes the deprecated legacy-capability fields
// until it migrates to fine-grained capabilities.
deprecated = "allow"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeps flowctl build working with deprecated fields

@GregorShear GregorShear changed the title control-plane-api: rename GraphQL Capability to LegacyCapability, expose capability bundles and bits control-plane-api: expose capability bundles and bits in GraphQL, deprecate legacy capability fields Jul 14, 2026
name prefix: every bundle whose full capability set is covered by
`capabilityBits`, regardless of which bundles were explicitly granted.
"""
capabilities: [CapabilityBundle!]!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really be called capabilityBundles to align with capabilityBits right below?

Comment thread crates/flow-client/control-plane-api.graphql Outdated
@jgraettinger

Copy link
Copy Markdown
Member

Drive by question: are we thinking to expose capability bits as a user / API facing concern? I'm a little unclear just from glossing the PR.

My last understanding of the agreed approach is that the DB and API surfaces would only ever speak in terms of bundles, never bits -- bits are purely an implementation detail internal to the code of a released control plane application, so that we have flexibility to allocate new bits and change the mapping of bundle => bits at any time (aside: it's fine to have a bundle that maps to a single bit, but the key property we're preserving is freedom to re-map that bundle from one bit to multiple bits as a transparent operation).

…dles; withCapabilities takes capability bits

Review feedback on #3144. The bundle-reporting output field is renamed from capabilities to capabilityBundles on DataPlane, StorageMapping, LiveSpecRef, InviteLink, and RedeemInviteLinkResult, aligning with the paired capabilityBits field. This also removes the need to ever re-type PrefixRef.capabilities: it stays a deprecated alias of capabilityBits to be deleted once clients migrate, and PrefixRef gains capabilityBundles alongside the other types.

The prefixes(by: { withCapabilities }) filter is re-typed from [CapabilityBundle!] to [CapabilityBit!]: the bundle-to-bits mapping is dynamic, so callers filtering prefixes should state the fine-grained capabilities they need rather than depend on a bundle's current composition. Outputs still report bundles as a summary of what a user holds; filters take bits as a precise operational question.
…ty bits exclusively

Remove capabilityBundles from the resource-shaped types (DataPlane, StorageMapping, LiveSpecRef, PrefixRef). These fields reported bundles derived from the user's bits purely as a UI-gating convenience, and gating is done exclusively with capabilityBits going forward. CapabilityBundle remains on InviteLink and RedeemInviteLinkResult, where it reports the explicit selection made when the link was created rather than a derived summary.

CapabilityBundle::covered_by and CapabilityBundle::ALL had no remaining callers and are removed with it.
@jshearer

jshearer commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Maybe we should chat about ☝️? From my POV, the capability bits are the actual atoms that gate authorization questions ("can this subject perform this action to this object"), and multiple bundles can compose them. Thinking about the UI's behavior, it's going to need to answer those same questions in order to gate UI features/behavior: "can this user read billing info for this tenant", "can this user publish to this prefix", and maybe in a fancy future world, "can this prefix read data from this other prefix?"

Those are all questions answered by capability bits, not bundles. Put another way, the logic that decides whether to show the billing tab in the admin UI shouldn't need to know that both TeamAdmin and ManageBilling (or whatever we end up calling these bundles) both confer the ViewBilling capability bit.

OTOH, the "invite user" and "create service account" UI flows will clearly operate over bundles not bits, since that's how the grants behave. Indeed, the list of users with access to your tenant should probably also show their bundles, rather than enumerate all of their grant's composed bits. I don't have high conviction on whether we should actually communicate the concept of "bits" + "bundles" to users. It seems reasonable enough to hide the "bits" concept from the user, even though the UI will still use them internally to manage what features a user has access to visually.

@GregorShear

Copy link
Copy Markdown
Contributor Author

Maybe we should chat about ☝️? From my POV, the capability bits are the actual atoms that gate authorization questions ("can this subject perform this action to this object"), and multiple bundles can compose them. Thinking about the UI's behavior, it's going to need to answer those same questions in order to gate UI features/behavior: "can this user read billing info for this tenant", "can this user publish to this prefix", and maybe in a fancy future world, "can this prefix read data from this other prefix?"

Those are all questions answered by capability bits, not bundles. Put another way, the logic that decides whether to show the billing tab in the admin UI shouldn't need to know that both TeamAdmin and ManageBilling (or whatever we end up calling these bundles) both confer the ViewBilling capability bit.

OTOH, the "invite user" and "create service account" UI flows will clearly operate over bundles not bits, since that's how the grants behave. Indeed, the list of users with access to your tenant should probably also show their bundles, rather than enumerate all of their grant's composed bits. I don't have high conviction on whether we should actually communicate the concept of "bits" + "bundles" to users. It seems reasonable enough to hide the "bits" concept from the user, even though the UI will still use them internally to manage what features a user has access to visually.

I think we're in agreement about bundles being the main user-facing concept - users will interact with bundles when managing user (and service account) access.

UI logic will operate on bits, i.e. show edit button X if user has capabilityBit Y on prefix Z... I guess i don't have an opinion on whether or not the users ever see the named bits. I imagine knowing which bundles carry which bits could be useful, where a disabled edit button might show a tool tip that says "This requires capability A B or C - talk to your admin" but I don't think we have to decide that now.

@jshearer

Copy link
Copy Markdown
Contributor

Greg and I talked this through on a call today. The conclusion is that we're deferring this PR and shipping service accounts first on legacy read/write/admin, for a couple reasons:

  • A service account is deliberately just an ordinary auth.users user with ordinary user_grants, so that it works through flowctl and the existing PostgREST path without making the GraphQL migration a prerequisite to shipping. PostgREST can only authorize along the read/write/admin axis, so any API that still has PostgREST callers has to keep working off a legacy capability; bundles and bits were always scoped to new GraphQL-only APIs. That means a service account grant carries a legacy capability exactly like a user grant does: authorization-wise we can't do anything different for service accounts than we do for users.
  • We haven't started unbundling yet (e.g. splitting billing out of admin), and we can't meaningfully unbundle anything whose surfaces still have PostgREST callers, since those callers only understand read/write/admin. So there's nothing more for the service accounts UI to show than read or admin anyway. Same goes for invite links. A bundles-only service account API would just synthesize a legacy capability under the hood without getting us any closer to actual unbundling.

We'll pick this PR back up after, as its own focused effort: actually exercising bundles, the UX for selecting them, and the great unbundling. None of the API groundwork here changes: grants still take/report bundles alongside the legacy capability, resources still report bits.

@jshearer jshearer mentioned this pull request Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants