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
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,8 +1,8 @@
{
"description": "A support role is published, widened in a different action order, narrowed, then removed. Every publication takes effect at once.",
"description": "A support role is published, widened in a different permission order over the tag:orders selector, narrowed, then removed. Every publication takes effect at once.",
"publications": [
{"actions": ["view"], "expected": {"view": "orders_support", "job.run": null}},
{"actions": ["job.run", "view"], "expected": {"view": "orders_support", "job.run": "orders_support"}},
{"actions": ["job.run"], "expected": {"view": null, "job.run": "orders_support"}}
{"permissions": ["view"], "expected": {"view": "orders_support", "job.run": null}},
{"permissions": ["job.run", "view"], "expected": {"view": "orders_support", "job.run": "orders_support"}},
{"permissions": ["job.run"], "expected": {"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 @@ -29,6 +29,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
151 changes: 133 additions & 18 deletions crates/sqlbuild-orchestration/src/authorization/_helpers/custom_roles.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
//! Published custom roles grant only non-administrative actions.
//! Published project roles take effect at once; their node coverage follows each snapshot.

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

use serde_json::Value;
use sqlbuild_recall::records::main::sha256_hex::sha256_hex;
use uuid::Uuid;

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

const ADMINISTRATIVE: [Action; 3] = [Action::UserManage, Action::RoleAssign, Action::AuditView];

Expand All @@ -18,47 +23,157 @@ 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, BTreeSet<String>>, ControlError> {
) -> Result<BTreeMap<String, Vec<RoleGrant>>, ControlError> {
let roles: BTreeMap<String, RoleDefinition> = document.map_or_else(
|| Ok(BTreeMap::new()),
|document| {
serde_json::from_value(document.clone())
.map_err(|error| ControlError::Invalid(format!("roles: {error}")))
},
)?;
let mut published: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut published: BTreeMap<String, Vec<RoleGrant>> = BTreeMap::new();
for (key, definition) in 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();
published.insert(key, grants);
}
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
}

/// 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(", ")
))
}

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 a 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 = match (before, after) {
(None, _) => "added",
(Some(_), Some(_)) => "changed",
(Some(_), None) => "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)
}
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_person::authorize_person;
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, Uuid),
(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_person(transaction, credentials, &scope, default_role).await
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! A live person's assignments and live group roles are evaluated under the operation's lock.
//! Live identity and the union of assigned and group role grants decide every node under lock.

use serde_json::json;
use uuid::Uuid;

use crate::authorization::_helpers::custom_roles::grants_allow;
use crate::authorization::_helpers::custom_roles::{custom_coverage, denial_reason};
use crate::authorization::main::deny_authorization::deny;
use crate::credentials::models::{Action, AuditContext, BuiltinRole, DefaultRole, PermissionScope};
use crate::store::errors::ControlError;
use crate::store::main::query::query;
use crate::store::models::{ControlTransaction, Parameter};
use crate::store::types::Row;

fn role_allows(role: BuiltinRole, action: Action) -> bool {
use Action::{
Expand Down Expand Up @@ -67,32 +68,34 @@ pub(crate) async fn authorize_person(
LEFT JOIN sqb_effective_roles e ON e.location_id=r.location_id AND e.role_key=r.role_key AND e.target=$2 \
ORDER BY r.role_key,r.group_name",
vec![Parameter::Id(principal_id), Parameter::OptionalText(scope.target.clone())]).await?;
for row in rows {
let allowed = serde_json::from_value::<BuiltinRole>(row["role_key"].clone()).map_or_else(
|_| grants_allow(&row["resolved_grants_json"], scope.action),
|role| role_allows(role, scope.action),
);
if allowed
&& (row["location_id"].is_null() || row["location_id"] == json!(scope.location_id))
&& (row["target"].is_null() || row["target"] == json!(scope.target))
{
let role = row["role_key"].as_str().unwrap_or_default();
context.granting_role = match row["group_name"].as_str() {
Some(group) => format!("group:{group}:{role}"),
None => role.into(),
};
return Ok((transaction, context));
}
let rows: Vec<&Row> = rows
.iter()
.filter(|row| {
(row["location_id"].is_null() || row["location_id"] == json!(scope.location_id))
&& (row["target"].is_null() || row["target"] == json!(scope.target))
})
.collect();
let builtin = rows.iter().find(|row| {
serde_json::from_value::<BuiltinRole>(row["role_key"].clone())
.is_ok_and(|role| role_allows(role, scope.action))
});
if let Some(row) = builtin {
let role = row["role_key"].as_str().unwrap_or_default();
context.granting_role = match row["group_name"].as_str() {
Some(group) => format!("group:{group}:{role}"),
None => role.into(),
};
return Ok((transaction, context));
}
let (granting, missing) = custom_coverage(&rows, scope);
if !granting.is_empty() && missing.is_empty() {
context.granting_role = granting.join("+");
return Ok((transaction, context));
}
if default_role == DefaultRole::Viewer && role_allows(BuiltinRole::Viewer, scope.action) {
context.granting_role = "default:viewer".into();
return Ok((transaction, context));
}
deny(
transaction,
&context,
json!(scope),
"missing permission in this scope; all requested nodes denied",
)
.await
let reason = denial_reason(scope.action, &missing)?;
deny(transaction, &context, json!(scope), &reason).await
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! A project's `roles.toml` becomes the snapshot `roles` document, its selectors resolved per snapshot.

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

use serde_json::Value;

use crate::authorization::_helpers::custom_roles::decode_roles;
use crate::credentials::models::RolesFile;

/// The `roles` document and one warning per grant whose selectors match no node in this snapshot.
pub fn compile_roles(
text: &str,
resolve: impl Fn(&str) -> Result<BTreeSet<String>, String>,
) -> Result<(Value, Vec<String>), String> {
let file: RolesFile = toml::from_str(text).map_err(|error| format!("roles.toml: {error}"))?;
let mut roles = file.roles;
let mut warnings: Vec<String> = Vec::new();
for (key, role) in &mut roles {
for (index, grant) in role.grants.iter_mut().enumerate() {
let mut resolved: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for selector in &grant.nodes {
let nodes = resolve(selector).map_err(|error| {
format!("roles.toml: role {key} selector '{selector}': {error}")
})?;
resolved.insert(selector.clone(), nodes);
}
if resolved.values().all(BTreeSet::is_empty) {
warnings.push(format!(
"role {key} grant {} selects no nodes ({}); it grants nothing in this snapshot",
index + 1,
grant.nodes.iter().cloned().collect::<Vec<_>>().join(" ")
));
}
grant.resolved_nodes = resolved;
}
}
let document = serde_json::to_value(&roles).map_err(|error| error.to_string())?;
decode_roles(Some(&document)).map_err(|error| format!("roles.toml: {error}"))?;
Ok((document, warnings))
}
2 changes: 2 additions & 0 deletions crates/sqlbuild-orchestration/src/authorization/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
pub mod assign_builtin_role;
pub(crate) mod authorize_listing;
pub(crate) mod authorize_person;
pub(crate) mod begin_authorized;
pub mod bootstrap_user;
pub mod change_administrator;
pub mod compile_roles;
pub(crate) mod deny_authorization;
pub mod grant_admin;
pub(crate) mod publish_custom_roles;
Expand Down
Loading
Loading