Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

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

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"description": "A support role is published, approved by another administrator, widened, republished in a different action order, approved, then narrowed. Widening waits for approval; reordering keeps the same definition; narrowing applies on publish.",
"description": "A support role is published, approved by another administrator, widened, republished in a different permission order, approved, then narrowed. Widening waits for approval; reordering keeps the same definition; narrowing applies on publish.",
"publications": [
["view"],
["view", "job.run"],
["job.run", "view"],
["job.run"]
[{"nodes": ["tag:orders"], "permissions": ["view"], "resolved_nodes": {"tag:orders": ["orders"]}}],
[{"nodes": ["tag:orders"], "permissions": ["view", "job.run"], "resolved_nodes": {"tag:orders": ["orders"]}}],
[{"nodes": ["tag:orders"], "permissions": ["job.run", "view"], "resolved_nodes": {"tag:orders": ["orders"]}}],
[{"nodes": ["tag:orders"], "permissions": ["job.run"], "resolved_nodes": {"tag:orders": ["orders"]}}]
],
"expected_after_narrowing": {"view": null, "job.run": "orders_support"}
}
11 changes: 11 additions & 0 deletions benchmarks/duckshop/edge_cases/orchestration/project_roles.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Project roles: one grant follows tag coverage across publications; one intersection selects nothing yet.
[roles.orders_support]
description = "Answer order questions"

[[roles.orders_support.grants]]
nodes = ["tag:support"]
permissions = ["view.sql"]

[[roles.orders_support.grants]]
nodes = ["tag:support,tag:pii"]
permissions = ["view.evidence"]
1 change: 1 addition & 0 deletions crates/sqlbuild-orchestration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ sqlx-postgres.workspace = true
subtle.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
utoipa.workspace = true
uuid.workspace = true

Expand Down
204 changes: 186 additions & 18 deletions crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
//! Published custom roles narrow at once; a widened definition waits for an administrator approval.
//! Reviewed role selectors and permissions narrow at once and widen only by approval; coverage follows snapshots.

use std::collections::{BTreeMap, BTreeSet};

use serde_json::{Value, json};
use sqlbuild_recall::records::main::sha256_hex::sha256_hex;
use uuid::Uuid;

use crate::credentials::models::{Action, PublishedRole, RoleDefinition};
use crate::credentials::models::{
Action, PermissionScope, PublishedRole, RoleDefinition, RoleGrant,
};
use crate::snapshots::main::read_snapshot_documents::read_snapshot_documents;
use crate::store::errors::ControlError;
use crate::store::main::query::query;
use crate::store::models::{Change, ControlTransaction, Parameter};
use crate::store::types::Row;

const ADMINISTRATIVE: [Action; 5] = [
Action::LocationManage,
Expand All @@ -29,6 +32,18 @@ fn action_name(action: Action) -> Result<String, ControlError> {
}
}

fn valid_grant(grant: &RoleGrant) -> bool {
let blank_selector = grant
.nodes
.iter()
.any(|selector| selector.trim().is_empty());
let administrative = grant
.permissions
.iter()
.any(|action| ADMINISTRATIVE.contains(action));
!grant.nodes.is_empty() && !blank_selector && !grant.permissions.is_empty() && !administrative
}

pub(crate) fn decode_roles(
document: Option<&Value>,
) -> Result<BTreeMap<String, PublishedRole>, ControlError> {
Expand All @@ -44,20 +59,29 @@ pub(crate) fn decode_roles(
if key.trim().is_empty()
|| BUILTIN.contains(&key.to_lowercase().as_str())
|| definition.description.trim().is_empty()
|| definition
.actions
.iter()
.any(|action| ADMINISTRATIVE.contains(action))
|| definition.grants.is_empty()
|| !definition.grants.iter().all(valid_grant)
{
return Err(ControlError::Invalid(format!(
"role {key} needs a description, a key that is not a built-in role, and only non-administrative actions"
"role {key} needs a description, a key that is not a built-in role, and grants of node selectors with only non-administrative permissions"
)));
}
let mut grants: BTreeSet<String> = BTreeSet::new();
for action in definition.actions {
grants.insert(action_name(action)?);
let mut grants: Vec<RoleGrant> = definition.grants;
grants.sort();
grants.dedup();
let mut selectors: BTreeSet<(Vec<String>, Vec<String>)> = BTreeSet::new();
for grant in &grants {
let permissions = grant
.permissions
.iter()
.map(|action| action_name(*action))
.collect::<Result<Vec<_>, _>>()?;
selectors.insert((grant.nodes.iter().cloned().collect(), permissions));
}
let definition = json!({"description": definition.description, "actions": grants});
let definition = json!({
"description": definition.description,
"grants": selectors.iter().map(|(nodes, permissions)| json!({"nodes": nodes, "permissions": permissions})).collect::<Vec<_>>(),
});
let digest = sha256_hex(definition.to_string().as_bytes());
published.insert(
key,
Expand All @@ -71,24 +95,129 @@ pub(crate) fn decode_roles(
Ok(published)
}

pub(crate) fn decode_grants(value: &Value) -> Result<BTreeSet<String>, ControlError> {
pub(crate) fn decode_grants(value: &Value) -> Result<Vec<RoleGrant>, ControlError> {
serde_json::from_str(value.as_str().unwrap_or("[]"))
.map_err(|error| ControlError::Schema(format!("effective role grants: {error}")))
}

pub(crate) fn grants_allow(value: &Value, action: Action) -> bool {
match (decode_grants(value), action_name(action)) {
(Ok(grants), Ok(name)) => grants.contains(&name),
_ => false,
/// Nodes on which these effective grants allow the action; a grant that resolved to none gives nothing.
pub(crate) fn granted_nodes(value: &Value, action: Action) -> BTreeSet<String> {
let mut nodes: BTreeSet<String> = BTreeSet::new();
for grant in decode_grants(value).unwrap_or_default() {
if !grant.permissions.contains(&action) {
continue;
}
for selector in &grant.nodes {
nodes.extend(
grant
.resolved_nodes
.get(selector)
.cloned()
.unwrap_or_default(),
);
}
}
nodes
}

fn pairs(grants: &[RoleGrant]) -> BTreeSet<(&str, Action)> {
let mut pairs: BTreeSet<(&str, Action)> = BTreeSet::new();
for grant in grants {
for selector in &grant.nodes {
for action in &grant.permissions {
pairs.insert((selector.as_str(), *action));
}
}
}
pairs
}

/// Custom roles whose effective grants add coverage, and requested nodes none covers; built-ins have no grants.
pub(crate) fn custom_coverage(
rows: &[&Row],
scope: &PermissionScope,
) -> (Vec<String>, Vec<String>) {
let mut covered: BTreeSet<String> = BTreeSet::new();
let mut granting: Vec<String> = Vec::new();
for row in rows {
let role_key = row["role_key"].as_str().unwrap_or_default();
let nodes = granted_nodes(&row["resolved_grants_json"], scope.action);
let adds = if scope.nodes.is_empty() {
!nodes.is_empty() && granting.is_empty()
} else {
scope
.nodes
.iter()
.any(|node| nodes.contains(node) && !covered.contains(node))
};
if !adds {
continue;
}
if !granting.iter().any(|key| key == role_key) {
granting.push(role_key.into());
}
covered.extend(nodes.into_iter().filter(|node| scope.nodes.contains(node)));
}
let missing: Vec<String> = scope.nodes.difference(&covered).cloned().collect();
(granting, missing)
}

/// A denial names at most twenty uncovered nodes so the audited reason stays bounded.
pub(crate) fn denial_reason(action: Action, missing: &[String]) -> Result<String, ControlError> {
if missing.is_empty() {
return Ok("missing permission in this scope; all requested nodes denied".into());
}
let listed: Vec<&str> = missing.iter().take(20).map(String::as_str).collect();
let more = missing.len() - listed.len();
let suffix = if more > 0 {
format!(" and {more} more")
} else {
String::new()
};
Ok(format!(
"missing {} on nodes {}{suffix}; all requested nodes denied",
action_name(action)?,
listed.join(", ")
))
}

/// Provably narrowing: every published (selector, permission) pair is already effective.
pub(crate) fn narrows(published: &[RoleGrant], effective: &[RoleGrant]) -> bool {
pairs(published).is_subset(&pairs(effective))
}

/// Effective pairs the publication still contains, one grant per selector with its resolution now.
pub(crate) fn retained_grants(published: &[RoleGrant], effective: &[RoleGrant]) -> Vec<RoleGrant> {
let kept: BTreeSet<(&str, Action)> = pairs(published)
.intersection(&pairs(effective))
.copied()
.collect();
let mut grants: BTreeMap<&str, RoleGrant> = BTreeMap::new();
for (selector, action) in kept {
let resolved = published
.iter()
.find_map(|grant| grant.resolved_nodes.get(selector))
.cloned()
.unwrap_or_default();
grants
.entry(selector)
.or_insert_with(|| RoleGrant {
nodes: BTreeSet::from([selector.to_owned()]),
permissions: BTreeSet::new(),
resolved_nodes: BTreeMap::from([(selector.to_owned(), resolved)]),
})
.permissions
.insert(action);
}
grants.into_values().collect()
}

/// The effective digest and grants of one custom role, if any definition is in effect.
pub(crate) async fn effective_role(
transaction: ControlTransaction,
(location_id, target): (Uuid, &str),
role_key: &str,
) -> Result<(ControlTransaction, Option<(String, BTreeSet<String>)>), ControlError> {
) -> Result<(ControlTransaction, Option<(String, Vec<RoleGrant>)>), ControlError> {
let (transaction, rows) = query(transaction,
"SELECT definition_digest,resolved_grants_json FROM sqb_effective_roles WHERE location_id=$1 AND target=$2 AND role_key=$3",
vec![Parameter::Id(location_id), Parameter::Text(target.into()), Parameter::Text(role_key.into())]).await?;
Expand Down Expand Up @@ -133,10 +262,49 @@ pub(crate) async fn current_role(
))
}

fn grants_digest(grants: Option<&[RoleGrant]>) -> Result<Option<String>, ControlError> {
let Some(grants) = grants else {
return Ok(None);
};
let text =
serde_json::to_string(grants).map_err(|error| ControlError::Invalid(error.to_string()))?;
Ok(Some(sha256_hex(text.as_bytes())))
}

/// Append an effective role's coverage change to the snapshot's change feed, after its node changes.
pub(crate) async fn record_role_change(
transaction: ControlTransaction,
(snapshot_id, role_key): (Uuid, &str),
(before, after): (Option<&[RoleGrant]>, Option<&[RoleGrant]>),
) -> Result<ControlTransaction, ControlError> {
let kind = if after.is_some() {
"changed"
} else {
"removed"
};
let (transaction, rows) = query(
transaction,
"SELECT COALESCE(MAX(change_seq) + 1, 0) AS next FROM sqb_snapshot_changes WHERE snapshot_id=$1",
vec![Parameter::Id(snapshot_id)],
)
.await?;
let sequence = rows
.first()
.and_then(|row| row["next"].as_i64())
.unwrap_or_default();
let (transaction, _) = query(transaction,
"INSERT INTO sqb_snapshot_changes (snapshot_id, change_seq, change_kind, subject_key, before_hash, after_hash) \
VALUES ($1,$2,$3,$4,$5,$6)",
vec![Parameter::Id(snapshot_id), Parameter::Integer(sequence), Parameter::Text(kind.into()),
Parameter::Text(format!("role:{role_key}")), Parameter::OptionalText(grants_digest(before)?),
Parameter::OptionalText(grants_digest(after)?)]).await?;
Ok(transaction)
}

pub(crate) async fn set_effective_role(
mut transaction: ControlTransaction,
(location_id, target, role_key): (Uuid, &str, &str),
(digest, grants): (&str, &BTreeSet<String>),
(digest, grants): (&str, &[RoleGrant]),
) -> Result<ControlTransaction, ControlError> {
let grants =
serde_json::to_string(grants).map_err(|error| ControlError::Invalid(error.to_string()))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use serde_json::json;
use uuid::Uuid;

use crate::authorization::_helpers::custom_roles::{
current_role, effective_role, set_effective_role,
current_role, effective_role, retained_grants, set_effective_role,
};
use crate::credentials::main::audit::append_audit::append_audit;
use crate::credentials::models::{AuditContext, RoleApproval, RoleDecision};
Expand Down Expand Up @@ -81,7 +81,7 @@ pub(crate) async fn record_decision(
transaction = set_effective_role(
transaction,
(location_id, target, role_key),
(digest, &role.grants),
(digest, &retained_grants(&role.grants, &role.grants)),
)
.await?;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! A location-wide listing needs view coverage of every node in the current snapshot; it is never filtered.

use std::collections::BTreeSet;

use uuid::Uuid;

use crate::authorization::main::authorize_session::authorize_session;
use crate::credentials::models::{Action, AuditContext, DefaultRole, PermissionScope};
use crate::store::errors::ControlError;
use crate::store::main::query::query;
use crate::store::models::{ControlTransaction, Parameter};

pub(crate) async fn authorize_listing(
transaction: ControlTransaction,
credentials: (&AuditContext, (&str, &str)),
(location_id, target): (Uuid, &str),
default_role: DefaultRole,
) -> Result<(ControlTransaction, AuditContext), ControlError> {
let (transaction, rows) = query(transaction,
"SELECT n.node_key FROM sqb_snapshot_nodes n JOIN sqb_current_snapshots c ON c.snapshot_id=n.snapshot_id \
WHERE c.location_id=$1 AND c.target=$2",
vec![Parameter::Id(location_id), Parameter::Text(target.into())]).await?;
let mut nodes: BTreeSet<String> = BTreeSet::new();
for row in &rows {
nodes.insert(row["node_key"].as_str().unwrap_or_default().into());
}
let scope = PermissionScope {
action: Action::View,
location_id: Some(location_id),
target: Some(target.into()),
nodes,
};
authorize_session(transaction, credentials, &scope, default_role).await
}
Loading
Loading