Skip to content
Merged
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
78 changes: 64 additions & 14 deletions crates/control-plane-api/src/server/create_data_plane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,16 @@ pub async fn create_data_plane(
category,
}): super::Request<Request>,
) -> Result<axum::Json<Response>, crate::server::error::ApiError> {
let models::authorizations::ControlClaims { sub: user_id, .. } = env.claims()?;
let claims = env.claims()?;
let user_id = claims.sub;

if let None = sqlx::query!(
"select role_prefix from internal.user_roles($1, 'admin') where role_prefix = 'ops/'",
user_id,
)
.fetch_optional(&env.pg_pool)
.await?
{
return Err(tonic::Status::permission_denied(
"authenticated user is not an admin of the 'ops/' tenant",
)
.into());
}
let policy_result = super::evaluate_names_authorization(
env.snapshot(),
claims,
models::Capability::Admin,
["ops/"],
);
let (_expiry, ()) = env.authorization_outcome(policy_result).await?;

let (data_plane_fqdn, base_name, pulumi_stack) = match &private {
None => (
Expand Down Expand Up @@ -233,7 +229,7 @@ pub async fn create_data_plane(
.into();

let publication = DraftPublication {
user_id: *user_id,
user_id,
logs_token: insert.logs_token,
draft,
dry_run: false,
Expand Down Expand Up @@ -327,3 +323,57 @@ impl Validate for Category {
}
}
}

#[cfg(test)]
mod test {
use crate::test_server;

/// Denial-path coverage of the `ops/` admin gate. Only denials are
/// exercised end-to-end: an authorized request proceeds into data-plane
/// provisioning, which is out of scope for authorization tests (and would
/// panic in the test server's NoopBuilder). The allowed case is pinned by
/// the gate's unit test in the parent module, and the grant walk it
/// composes is covered by the `tables` crate.
#[sqlx::test(
migrations = "../../supabase/migrations",
fixtures(path = "../fixtures", scripts("data_planes", "alice"))
)]
async fn test_ops_admin_gate_denials(pool: sqlx::PgPool) {
let _guard = test_server::init();
let snapshot = test_server::snapshot(pool.clone(), false).await;
let server = test_server::TestServer::start(pool, snapshot).await;

let body = serde_json::json!({"name": "test-plane", "category": "managed"});

// A request without a bearer token is unauthenticated.
let response = server
.rest_client()
.post("/admin/create-data-plane", &body, None)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);

// Alice admins aliceCo/ but holds nothing on ops/. The test Snapshot
// post-dates the request, so the denial is terminal rather than a
// 307 authorization retry.
let alice = uuid::Uuid::from_bytes([0x11; 16]);
let token = server.make_access_token(alice, Some("alice@example.com"));
let response = server
.rest_client()
.post("/admin/create-data-plane", &body, Some(&token))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);

let text = response.text().await.unwrap();
assert!(
text.contains(
"alice@example.com is not authorized to access prefix or name 'ops/' \
with required capability admin"
),
"unexpected denial body: {text}"
);
}
}
45 changes: 45 additions & 0 deletions crates/control-plane-api/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,48 @@ const fn map_capability_to_gazette(capability: models::Capability) -> u32 {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_server::snapshot_of_grants;

fn claims(user_id: uuid::Uuid) -> crate::ControlClaims {
models::authorizations::ControlClaims {
iat: 0,
exp: u64::MAX,
sub: user_id,
role: "authenticated".to_string(),
aud: "authenticated".to_string(),
email: Some("user@example.test".to_string()),
}
}

/// The `ops/` admin gate of the /admin/* endpoints, expressed as
/// evaluate_names_authorization over the Snapshot's grant walk.
#[test]
fn test_evaluate_ops_admin_gate() {
use models::Capability::{Admin, Read};
let user = uuid::Uuid::from_bytes([0x11; 16]);
let evaluate = |snapshot: &Snapshot| {
evaluate_names_authorization(snapshot, &claims(user), Admin, ["ops/"])
};

// A direct admin grant to `ops/` is authorized.
let snapshot = snapshot_of_grants(&[(user, "ops/", Admin)], &[]);
assert!(evaluate(&snapshot).is_ok());

// Admin of an unrelated tenant is denied.
let snapshot = snapshot_of_grants(&[(user, "acmeCo/", Admin)], &[]);
let status = evaluate(&snapshot).unwrap_err();
assert_eq!(status.code(), tonic::Code::PermissionDenied);
assert_eq!(
status.message(),
"user@example.test is not authorized to access prefix or name 'ops/' with required capability admin"
);

// A read grant to `ops/` is not admin.
let snapshot = snapshot_of_grants(&[(user, "ops/", Read)], &[]);
assert!(evaluate(&snapshot).is_err());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,31 +78,9 @@ pub(super) fn filtered_authorized_prefixes(
mod tests {
use super::super::filters::PrefixFilter;
use super::{authorized_prefixes, filtered_authorized_prefixes};
use crate::test_server::make_grants;
use models::Capability::{Admin, Read, Write};

fn make_grants(
user_grants: &[(uuid::Uuid, &str, models::Capability)],
role_grants: &[(&str, &str, models::Capability)],
) -> (tables::UserGrants, tables::RoleGrants) {
let ug = tables::UserGrants::from_iter(user_grants.iter().map(|(id, obj, cap)| {
tables::UserGrant {
user_id: *id,
object_role: models::Prefix::new(*obj),
capability: *cap,
bundles: vec![],
}
}));
let rg = tables::RoleGrants::from_iter(role_grants.iter().map(|(sub, obj, cap)| {
tables::RoleGrant {
subject_role: models::Prefix::new(*sub),
object_role: models::Prefix::new(*obj),
capability: *cap,
bundles: vec![],
}
}));
(ug, rg)
}

const ALICE: uuid::Uuid = uuid::Uuid::from_bytes([0x11; 16]);

#[test]
Expand Down
36 changes: 36 additions & 0 deletions crates/control-plane-api/src/test_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,39 @@ impl crate::publications::builds::Builder for NoopBuilder {
panic!("NoopBuilder::build called in test - this should not happen for authorization tests")
}
}

/// Build grant tables from compact (subject, object, capability) tuples,
/// shared by tests which pin grant-walk authorization semantics.
pub fn make_grants(
user_grants: &[(uuid::Uuid, &str, models::Capability)],
role_grants: &[(&str, &str, models::Capability)],
) -> (tables::UserGrants, tables::RoleGrants) {
let user_grants =
tables::UserGrants::from_iter(user_grants.iter().map(|(id, obj, cap)| tables::UserGrant {
user_id: *id,
object_role: models::Prefix::new(*obj),
capability: *cap,
bundles: Vec::new(),
}));
let role_grants = tables::RoleGrants::from_iter(role_grants.iter().map(|(sub, obj, cap)| {
tables::RoleGrant {
subject_role: models::Prefix::new(*sub),
object_role: models::Prefix::new(*obj),
capability: *cap,
bundles: Vec::new(),
}
}));
(user_grants, role_grants)
}

/// Build a Snapshot holding only the given user and role grants.
pub fn snapshot_of_grants(
user_grants: &[(uuid::Uuid, &str, models::Capability)],
role_grants: &[(&str, &str, models::Capability)],
) -> Snapshot {
let (user_grants, role_grants) = make_grants(user_grants, role_grants);
let mut snapshot = Snapshot::empty();
snapshot.user_grants = user_grants;
snapshot.role_grants = role_grants;
snapshot
}
Loading