control-plane-api: expose capability bundles and bits in GraphQL, deprecate legacy capability fields#3144
control-plane-api: expose capability bundles and bits in GraphQL, deprecate legacy capability fields#3144GregorShear wants to merge 12 commits into
Conversation
… CapabilityBundle + withCapability prefix filter show deprecated inputs in the gql playground
0dea593 to
5e88e15
Compare
… the capability_bundle rename
… set, not exactly one
…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.
…bundles covered by the granted bits
…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, |
There was a problem hiding this comment.
make sure we can still see deprecated input fields in the graphql playground
| 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'; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
And also there's only one tenant with a capability bundle manage_data_plane
…de, matching catalog/status
…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" |
There was a problem hiding this comment.
keeps flowctl build working with deprecated fields
| name prefix: every bundle whose full capability set is covered by | ||
| `capabilityBits`, regardless of which bundles were explicitly granted. | ||
| """ | ||
| capabilities: [CapabilityBundle!]! |
There was a problem hiding this comment.
Should this really be called capabilityBundles to align with capabilityBits right below?
|
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.
|
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 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. |
|
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
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. |
Next step in migrating the GraphQL API off the legacy ordered
Capabilityenum (read/write/admin) and onto the orthogonal capability model, staged so the frontend can migrate operation-by-operation. TheCapabilitytype keeps its name; every field that reports it is deprecated, with a fine-grainedCapabilityBitreplacement to migrate to.Viewer→View,Writer→Write,Editor→Edit,Billing→ManageBilling,TeamAdmin→ManageUsers,ManageDataPlane→ManageDataPlanes), with a migration that relabels thecapability_bundleenum values in place — existinguser_grants.bundles/role_grants.bundlesrows carry over without a backfill.prefixes(by:)gains awithCapabilities: [CapabilityBit!]filter and deprecatesminCapability— 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 requiredCapabilitySetmatched 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.inputValueDeprecationin 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 acapabilityBits: [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'scapabilityBitsis null when the user has no access, mirroringuserCapability.InviteLinkandRedeemInviteLinkResultadditionally reportcapabilityBundles: [CapabilityBundle!]!— the bundle explicitly selected when the link was created (bundles_for_legacy), not everything the grant implies: anadminlink reports[Admin], not alsoManageUsers,ManageBilling, etc. — whilecapabilityBitscarries the full effective set (bits_for_legacy).createInviteLinkstill takes the single legacycapabilityinput; 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 tocapabilityBits, and gets deleted once clients migrate — thecapabilitiesname is never reused with a different type.tables::UserGrant::get_user_capabilitiesreturns the union of capability bits at prefixes covering a name;attach_user_capabilitieshands resolvers both the legacy capability and the bit set.-D warningsandgraphql_clientturns schema deprecations into Rust#[deprecated], so its queries opt out viadeprecated = "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.