Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 5 additions & 9 deletions crates/control-plane-api/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,9 @@ where
Ok((None, ()))
}

/// Looks up the user's authorization grants for each item in
/// Looks up the user's `tables::UserAuthorization` 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.
///
/// Grant reachability is evaluated under the bearer's capability `mask`;
/// the legacy capability of a reached grant passes through un-attenuated
/// (see `tables::UserGrant::get_user_capability`).
/// item and its authorization. The `Some` results are returned in a vec.
pub fn attach_user_capabilities<I, F, T>(
snapshot: &Snapshot,
claims: &crate::ControlClaims,
Expand All @@ -161,19 +157,19 @@ 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, tables::UserAuthorization) -> Option<T>,
{
prefixes_or_names
.into_iter()
.flat_map(|prefix| {
let capability = tables::UserGrant::get_user_capability(
let authorization = tables::UserGrant::get_user_authorization(
&snapshot.role_grants,
&snapshot.user_grants,
claims.sub,
&prefix,
mask,
);
attach(prefix, capability)
attach(prefix, authorization)
})
.collect()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ impl AlertConfigsQuery {
&snapshot.user_grants,
claims.sub,
super::bearer_mask(ctx)?,
models::Capability::Read,
models::authz::CapabilityBundle::Viewer,
filter.and_then(|f| f.catalog_prefix_or_name),
"filter.catalogPrefixOrName",
)?;
Expand Down
75 changes: 73 additions & 2 deletions crates/control-plane-api/src/server/public/graphql/data_planes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ impl DataPlanesQuery {
env.claims()?,
super::bearer_mask(ctx)?,
names.into_iter(),
|data_plane_name, user_capability| {
|data_plane_name, authorization| {
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 @@ -579,7 +579,8 @@ impl DataPlanesQuery {
name: data_plane_name.clone(),
fqdn: dp.data_plane_fqdn.clone(),
reactor_address: dp.reactor_address.clone(),
user_capability: user_capability.expect("capability guaranteed by pre-filter"),
// The row is authorized by the effective-bits pre-filter.
user_capability: authorization.legacy_label(),
cloud_provider,
region,
tag,
Expand Down Expand Up @@ -883,6 +884,76 @@ mod tests {
use super::*;
use crate::test_server;

#[sqlx::test(
migrations = "../../supabase/migrations",
fixtures(path = "../../../fixtures", scripts("data_planes", "alice"))
)]
async fn test_graphql_data_planes_bundles_only_grant(pool: sqlx::PgPool) {
let _guard = test_server::init();

// Carol's read authorization to the public data planes comes
// entirely from the bundles column: the listing's pre-filter admits
// them on effective bits, and each row's userCapability reports the
// literal legacy column — none.
let carol_uid = uuid::Uuid::from_bytes([0x33; 16]);
sqlx::query("INSERT INTO auth.users (id, email) VALUES ($1, 'carol@example.test')")
.bind(carol_uid)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO public.user_grants (user_id, object_role, capability, bundles)
VALUES ($1, 'ops/dp/public/', 'none', ARRAY['viewer']::capability_bundle[])",
)
.bind(carol_uid)
.execute(&pool)
.await
.unwrap();

let server =
test_server::TestServer::start(pool.clone(), test_server::snapshot(pool, false).await)
.await;
let carol = server.make_access_token(carol_uid, Some("carol@example.test"));

let response: serde_json::Value = server
.graphql(
&serde_json::json!({
"query": r#"
query {
dataPlanes {
edges { node { name userCapability } }
}
}
"#
}),
Some(&carol),
)
.await;

insta::assert_json_snapshot!(response, @r#"
{
"data": {
"dataPlanes": {
"edges": [
{
"node": {
"name": "ops/dp/public/aws-us-west-2-c1",
"userCapability": "none"
}
},
{
"node": {
"name": "ops/dp/public/gcp-us-central1-c2",
"userCapability": "none"
}
}
]
}
}
}
"#);
}

#[sqlx::test(
migrations = "../../supabase/migrations",
fixtures(path = "../../../fixtures", scripts("data_planes", "alice"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ impl InviteLinksQuery {
&snapshot.user_grants,
env.claims()?.sub,
super::bearer_mask(ctx)?,
models::Capability::Admin,
models::authz::CapabilityBundle::Admin,
filter.and_then(|f| f.catalog_prefix),
"filter.catalogPrefix",
)?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,28 @@ pub struct LiveSpecRef {
/// name, and passing a name that the user cannot access. In either case,
/// the result would be `userCapability: null`, and all other fields on the
/// LiveSpecRef would also be null.
///
/// Access is decided by the user's effective capability bits under the
/// bearer's mask, and a non-null value is the literal legacy `capability`
/// column of the covering grant(s): informational compatibility metadata,
/// reported as `none` when access comes entirely from the `bundles`
/// column.
pub user_capability: Option<models::Capability>,
}

/// The `userCapability` value for a referent the user holds `authorization`
/// to: the legacy label when the effective bits grant access, and null (no
/// access — every other field is gated to null) when the bits fall short of
/// Viewer.
pub(super) fn user_capability_field(
authorization: tables::UserAuthorization,
) -> Option<models::Capability> {
authorization
.bits
.is_superset(models::authz::CapabilityBundle::Viewer.capabilities())
.then(|| authorization.legacy_label())
}

#[ComplexObject]
impl LiveSpecRef {
/// Returns the live spec that the reference points to, if the user has access to it.
Expand Down Expand Up @@ -166,12 +185,13 @@ impl LiveSpecRef {
/// order, both of `all_names` and the query results, must always be ascending,
/// regardless of whether forward or reverse pagination is being used. Source:
/// https://relay.dev/graphql/connections.htm#sec-Edge-order
/// If `require_min_capability` is `Some`, then `all_specs` will be filtered to
/// only include those specs for which the user has the required minimum
/// capability.
/// If `require_min_capability` is `Some`, then `all_specs` is filtered to
/// only the specs where the user's effective capability bits cover that
/// required set — names falling short are omitted entirely, never surfaced
/// as inaccessible refs.
pub async fn paginate_live_specs_refs(
ctx: &Context<'_>,
require_min_capability: Option<models::Capability>,
require_min_capability: Option<models::authz::CapabilitySet>,
all_names: Vec<String>,
after: Option<String>,
before: Option<String>,
Expand All @@ -188,13 +208,15 @@ pub async fn paginate_live_specs_refs(
env.claims()?,
super::bearer_mask(ctx)?,
all_names,
|name, maybe_capability| {
if require_min_capability.is_some_and(|min_cap| maybe_capability < Some(min_cap)) {
|name, authorization| {
if require_min_capability
.is_some_and(|required| !authorization.bits.is_superset(required))
{
return None;
}
Some(LiveSpecRef {
catalog_name: models::Name::new(name),
user_capability: maybe_capability,
user_capability: user_capability_field(authorization),
})
},
);
Expand Down Expand Up @@ -365,12 +387,12 @@ impl LiveSpecsQuery {
env.claims()?,
super::bearer_mask(ctx)?,
names,
|name, user_capability| {
|name, authorization| {
Some(connection::Edge::new(
name.clone(),
LiveSpecRef {
catalog_name: models::Name::new(name),
user_capability,
user_capability: user_capability_field(authorization),
},
))
},
Expand Down
Loading
Loading