Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions crates/control-plane-api/src/fixtures/private_links.sql
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ begin
);

-- Mirrors what `create_data_plane.rs` installs at provisioning time:
-- legacy `read` for RLS/`user_roles()`, and the `ManageDataPlane` bundle
-- legacy `read` for RLS/`user_roles()`, and the `ManageDataPlanes` bundle
-- for the capability bits.
insert into public.role_grants (subject_role, object_role, capability, bundles) values
('aliceCo/', 'ops/dp/private/aliceCo/', 'read', '{manage_data_plane}');
('aliceCo/', 'ops/dp/private/aliceCo/', 'read', '{manage_data_planes}');

end
$$;
2 changes: 1 addition & 1 deletion crates/control-plane-api/src/server/create_data_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ pub async fn create_data_plane(
on conflict do nothing
"#,
&prefix as &str,
&[models::authz::CapabilityBundle::ManageDataPlane]
&[models::authz::CapabilityBundle::ManageDataPlanes]
as &[models::authz::CapabilityBundle],
&[] as &[models::authz::CapabilityBundle],
)
Expand Down
13 changes: 10 additions & 3 deletions crates/control-plane-api/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ where

/// Looks up the user's authorization grants for each item in
/// `prefixes_or_names`, and calls the provided `attach` function with each
/// item and its capability. The `Some` results are returned in a vec.
/// item, its legacy capability, and its fine-grained capability set.
/// The `Some` results are returned in a vec.
pub fn attach_user_capabilities<I, F, T>(
snapshot: &Snapshot,
claims: &crate::ControlClaims,
Expand All @@ -115,7 +116,7 @@ pub fn attach_user_capabilities<I, F, T>(
) -> Vec<T>
where
I: IntoIterator<Item = String>,
F: FnMut(String, Option<models::Capability>) -> Option<T>,
F: FnMut(String, Option<models::Capability>, models::authz::CapabilitySet) -> Option<T>,
{
prefixes_or_names
.into_iter()
Expand All @@ -126,7 +127,13 @@ where
claims.sub,
&prefix,
);
attach(prefix, capability)
let capabilities = tables::UserGrant::get_user_capabilities(
&snapshot.role_grants,
&snapshot.user_grants,
claims.sub,
&prefix,
);
attach(prefix, capability, capabilities)
})
.collect()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,29 +183,29 @@ mod tests {
use models::authz::CapabilityBundle;

// Two user grants at the same prefix carrying disjoint
// bundles (Editor and TeamAdmin share no bits). The
// bundles (Edit and ManageUsers share no bits). The
// per-prefix CapabilitySet observed via reachable_prefixes
// is the union of the two bundles' bits.
let ug = tables::UserGrants::from_iter(vec![
tables::UserGrant {
user_id: ALICE,
object_role: models::Prefix::new("acmeCo/"),
capability: models::Capability::None,
bundles: vec![CapabilityBundle::Editor],
bundles: vec![CapabilityBundle::Edit],
},
tables::UserGrant {
user_id: ALICE,
object_role: models::Prefix::new("acmeCo/"),
capability: models::Capability::None,
bundles: vec![CapabilityBundle::TeamAdmin],
bundles: vec![CapabilityBundle::ManageUsers],
},
]);
let rg = tables::RoleGrants::new();

let reachable = tables::UserGrant::reachable_prefixes(&rg, &ug, ALICE);
assert_eq!(
reachable["acmeCo/"].0,
CapabilityBundle::Editor.capabilities() | CapabilityBundle::TeamAdmin.capabilities(),
CapabilityBundle::Edit.capabilities() | CapabilityBundle::ManageUsers.capabilities(),
);
}

Expand All @@ -214,8 +214,8 @@ mod tests {
use models::authz::CapabilityBundle;

// Alice is admin on acmeCo/. Two role grants reach
// sharedCo/ from acmeCo/ carrying disjoint bundles (Editor
// and TeamAdmin share no bits). At sharedCo/, the BFS
// sharedCo/ from acmeCo/ carrying disjoint bundles (Edit
// and ManageUsers share no bits). At sharedCo/, the BFS
// emits a NodeRef per role grant, and reachable_prefixes
// unions their bits into a single per-prefix CapabilitySet.
let ug = tables::UserGrants::from_iter(vec![tables::UserGrant {
Expand All @@ -229,20 +229,20 @@ mod tests {
subject_role: models::Prefix::new("acmeCo/"),
object_role: models::Prefix::new("sharedCo/"),
capability: models::Capability::None,
bundles: vec![CapabilityBundle::Editor],
bundles: vec![CapabilityBundle::Edit],
},
tables::RoleGrant {
subject_role: models::Prefix::new("acmeCo/"),
object_role: models::Prefix::new("sharedCo/"),
capability: models::Capability::None,
bundles: vec![CapabilityBundle::TeamAdmin],
bundles: vec![CapabilityBundle::ManageUsers],
},
]);

let reachable = tables::UserGrant::reachable_prefixes(&rg, &ug, ALICE);
assert_eq!(
reachable["sharedCo/"].0,
CapabilityBundle::Editor.capabilities() | CapabilityBundle::TeamAdmin.capabilities(),
CapabilityBundle::Edit.capabilities() | CapabilityBundle::ManageUsers.capabilities(),
);
}

Expand All @@ -264,7 +264,7 @@ mod tests {
user_id: ALICE,
object_role: models::Prefix::new("acmeCo/data/"),
capability: models::Capability::None,
bundles: vec![CapabilityBundle::Writer],
bundles: vec![CapabilityBundle::Write],
},
]);
let rg = tables::RoleGrants::new();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Transitional compatibility shim for the GraphQL `Capability` →
//! `LegacyCapability` rename.
//!
//! `models::Capability` now publishes under the name `LegacyCapability`. This
//! enum re-publishes the identical `none`/`read`/`write`/`admin` values under
//! the original name `Capability`, so client operations written against the
//! pre-rename schema keep validating. It is wired to the deprecated
//! `minCapability` filter (prefer `withCapabilities`) and to
//! `createInviteLink`'s `capability` argument, which stays legacy-typed until
//! invite links store explicit capability bundles. It is deleted once both
//! positions are gone.

/// Alias of `LegacyCapability`, preserved under the original `Capability`
/// name while clients migrate off it. Do not use in new operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, async_graphql::Enum)]
#[graphql(name = "Capability", rename_items = "lowercase")]
pub enum CapabilityCompat {
None,
Read,
Write,
Admin,
}

impl From<CapabilityCompat> for models::Capability {
fn from(value: CapabilityCompat) -> Self {
match value {
CapabilityCompat::None => models::Capability::None,
CapabilityCompat::Read => models::Capability::Read,
CapabilityCompat::Write => models::Capability::Write,
CapabilityCompat::Admin => models::Capability::Admin,
}
}
}
31 changes: 22 additions & 9 deletions crates/control-plane-api/src/server/public/graphql/data_planes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@ pub struct DataPlane {
/// Address of reactors within the data-plane.
pub reactor_address: String,
/// The current user's capability to this data plane's name prefix.
#[graphql(
deprecation = "The legacy read/write/admin capability model is being replaced; use `capabilities` instead."
)]
pub user_capability: models::Capability,
/// Capability bundles the user effectively holds to this data plane's
/// name prefix: every bundle whose full capability set is covered by
/// `capabilityBits`, regardless of which bundles were explicitly granted.
pub capabilities: Vec<models::authz::CapabilityBundle>,
/// Fine-grained capabilities the user has to this data plane's name prefix.
pub capability_bits: Vec<models::authz::Capability>,
/// Cloud provider where this data-plane is hosted.
pub cloud_provider: DataPlaneCloudProvider,
/// Cloud region where this data-plane is hosted.
Expand Down Expand Up @@ -399,7 +408,7 @@ impl DataPlanesQuery {
env.snapshot(),
env.claims()?,
names.into_iter(),
|data_plane_name, user_capability| {
|data_plane_name, user_capability, user_capabilities| {
let dp = row_data.get(&data_plane_name)?;
let details = details_map.get(&data_plane_name);
let (cloud_provider, region, tag, is_public) =
Expand All @@ -409,6 +418,8 @@ impl DataPlanesQuery {
fqdn: dp.data_plane_fqdn.clone(),
reactor_address: dp.reactor_address.clone(),
user_capability: user_capability.expect("capability guaranteed by pre-filter"),
capabilities: models::authz::CapabilityBundle::covered_by(user_capabilities),
capability_bits: user_capabilities.iter().collect(),
cloud_provider,
region,
tag,
Expand Down Expand Up @@ -559,6 +570,8 @@ mod tests {
tag
isPublic
userCapability
capabilities
capabilityBits
cidrBlocks
gcpServiceAccountEmail
awsIamUserArn
Expand Down Expand Up @@ -643,11 +656,11 @@ mod tests {
}

// A caller with only legacy `read` on the DP prefix can view the
// private-networking fields (the `Viewer` bundle carries
// private-networking fields (the `View` bundle carries
// `ViewDataPlanePrivateNetworking`, because `read` on a data-plane
// prefix already conveys deploy-level trust) but cannot mutate them:
// `ModifyDataPlanePrivateNetworking` only comes via the separately
// granted `ManageDataPlane` bundle.
// granted `ManageDataPlanes` bundle.
#[sqlx::test(
migrations = "../../supabase/migrations",
fixtures(
Expand Down Expand Up @@ -741,7 +754,7 @@ mod tests {
}

// Existing tenants can still view their private data plane's private links
// even before the `manage_data_plane` backfill runs, which is what later
// even before the `manage_data_planes` backfill runs, which is what later
// adds the ability to modify them. Clearing the bundle reproduces that
// pre-backfill state: the links stay readable, the update mutation is denied.
#[sqlx::test(
Expand All @@ -751,10 +764,10 @@ mod tests {
scripts("data_planes", "alice", "private_links")
)
)]
async fn test_modify_denied_when_role_grant_lacks_manage_data_plane(pool: sqlx::PgPool) {
async fn test_modify_denied_when_role_grant_lacks_manage_data_planes(pool: sqlx::PgPool) {
let _guard = test_server::init();

// Strip the `manage_data_plane` bundle from the only edge carrying
// Strip the `manage_data_planes` bundle from the only edge carrying
// Alice to the private dp, leaving its legacy `read` untouched.
sqlx::query(
r#"UPDATE role_grants
Expand All @@ -776,7 +789,7 @@ mod tests {

let dp = "ops/dp/private/aliceCo/aws-us-east-1-c1";

// View still resolves: `read` -> Viewer -> ViewDataPlanePrivateNetworking
// View still resolves: `read` -> View -> ViewDataPlanePrivateNetworking
// does not depend on the cleared bundle.
let view: serde_json::Value = server
.graphql(
Expand All @@ -802,11 +815,11 @@ mod tests {
assert_eq!(
private_dp["node"]["privateLinks"].as_array().unwrap().len(),
3,
"read must still grant view after the manage_data_plane bundle is cleared: {private_dp}",
"read must still grant view after the manage_data_planes bundle is cleared: {private_dp}",
);

// Modify is denied: ModifyDataPlanePrivateNetworking flowed only
// through the now-cleared `manage_data_plane` bundle on the edge.
// through the now-cleared `manage_data_planes` bundle on the edge.
let denied: serde_json::Value = server
.graphql(&update_mutation(dp, VALID_AWS_INPUT), Some(&alice_token))
.await;
Expand Down
Loading
Loading