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
2 changes: 1 addition & 1 deletion clients/typescript/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ test("generated client preserves native scopes, replay, path encoding, SSE and b
const decisionId: string | null | undefined = denied.error?.decision_id;
assert.ok(decisionId);
assert.deepEqual(denied.error, {
error: `missing permission in this scope; all requested nodes denied; decision ${decisionId}`,
error: `missing view permission for this target; decision ${decisionId}`,
decision_id: decisionId,
});
const machine = await browser.GET(healthPath, { headers, params });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,18 @@ pub(crate) fn custom_coverage(
(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> {
/// The audited reason naming up to twenty missing nodes, and node-free text for the caller.
pub(crate) fn denial_reason(
action: Action,
missing: &[String],
) -> Result<(String, String), ControlError> {
let action = action_name(action)?;
let shown = format!("missing {action} permission for this target");
if missing.is_empty() {
return Ok("missing permission in this scope; all requested nodes denied".into());
return Ok((
"missing permission in this scope; all requested nodes denied".into(),
shown,
));
}
let listed: Vec<&str> = missing.iter().take(20).map(String::as_str).collect();
let more = missing.len() - listed.len();
Expand All @@ -132,10 +140,12 @@ pub(crate) fn denial_reason(action: Action, missing: &[String]) -> Result<String
} else {
String::new()
};
Ok(format!(
"missing {} on nodes {}{suffix}; all requested nodes denied",
action_name(action)?,
listed.join(", ")
Ok((
format!(
"missing {action} on nodes {}{suffix}; all requested nodes denied",
listed.join(", ")
),
shown,
))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ pub(crate) async fn authorize_person(
context.granting_role = "default:viewer".into();
return Ok((transaction, context));
}
let reason = denial_reason(scope.action, &missing)?;
deny(transaction, &context, json!(scope), &reason).await
let (audited, shown) = denial_reason(scope.action, &missing)?;
deny(transaction, &context, json!(scope), &audited)
.await
.map_err(|error| match error {
ControlError::Denied { decision_id, .. } => ControlError::Denied {
reason: shown,
decision_id,
},
error => error,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ pub(crate) mod publish_custom_roles;
pub(crate) mod publish_group_roles;
pub(crate) mod read_audit_events;
pub mod read_security_audit;
pub(crate) mod refuse_unknown;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! An unknown object is refused like a forbidden one unless the person may act on the whole scope it was looked up in.
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};

/// `location` is where the lookup happened, or `None` when the object alone named it.
pub(crate) async fn refuse_unknown<T>(
transaction: ControlTransaction,
credentials: (&AuditContext, Uuid),
(action, location): (Action, Option<(Uuid, &str)>),
missing: ControlError,
) -> Result<T, ControlError> {
let (transaction, nodes) = match location {
Some((location_id, target)) => {
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 nodes: BTreeSet<String> = rows
.iter()
.filter_map(|row| row["node_key"].as_str().map(str::to_owned))
.collect();
(transaction, nodes)
}
None => (transaction, BTreeSet::new()),
};
let scope = PermissionScope {
action,
location_id: location.map(|(location_id, _)| location_id),
target: location.map(|(_, target)| target.to_owned()),
nodes,
};
authorize_person(transaction, credentials, &scope, DefaultRole::None).await?;
Err(missing)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use uuid::Uuid;

use crate::authorization::main::refuse_unknown::refuse_unknown;
use crate::credentials::models::{Action, AuditContext, PermissionScope};
use crate::declarations::models::{TriggerChange, TriggerChangeRequest};
use crate::runs::main::frozen_job_request::frozen_job_request;
Expand All @@ -14,15 +15,27 @@ use crate::store::models::{ControlTransaction, Parameter};
/// A group's scope is its frozen selection's write set, which covers every child's write set.
pub(crate) async fn trigger_scope(
transaction: ControlTransaction,
context: &AuditContext,
(context, credentials): (&AuditContext, Uuid),
request: &TriggerChangeRequest,
) -> Result<(ControlTransaction, PermissionScope), ControlError> {
let action = match request.change {
TriggerChange::Enable | TriggerChange::Pause | TriggerChange::ResetDefault => {
Action::TriggerToggle
}
TriggerChange::SetCursor { .. } | TriggerChange::ResetCursor => Action::SensorResetCursor,
};
let (transaction, rows) = query(transaction,
"SELECT location_id,target,job_key FROM sqb_triggers WHERE trigger_id=$1 AND removed_in_snapshot_id IS NULL",
vec![Parameter::Id(request.trigger_id)]).await?;
let row = rows
.first()
.ok_or_else(|| ControlError::Invalid("current trigger not found".into()))?;
let Some(row) = rows.first() else {
return refuse_unknown(
transaction,
(context, credentials),
(action, None),
ControlError::Invalid("current trigger not found".into()),
)
.await;
};
let job = JobRunRequest {
run_id: Uuid::nil(),
location_id: serde_json::from_value(row["location_id"].clone())
Expand All @@ -36,12 +49,6 @@ pub(crate) async fn trigger_scope(
let (transaction, frozen) = frozen_job_request(transaction, context, &job).await?;
let (transaction, nodes) =
job_write_set(transaction, frozen.snapshot_id, &frozen.selection).await?;
let action = match request.change {
TriggerChange::Enable | TriggerChange::Pause | TriggerChange::ResetDefault => {
Action::TriggerToggle
}
TriggerChange::SetCursor { .. } | TriggerChange::ResetCursor => Action::SensorResetCursor,
};
Ok((
transaction,
PermissionScope {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ pub async fn change_person_trigger(
) -> Result<bool, ControlError> {
let (transaction, scope) = trigger_scope(
begin(store, TransactionMode::Write).await?,
context,
(context, credentials),
request,
)
.await?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde_json::json;
use uuid::Uuid;

use crate::authorization::main::authorize_person::authorize_person;
use crate::authorization::main::refuse_unknown::refuse_unknown;
use crate::credentials::main::audit::append_audit::append_audit;
use crate::credentials::models::{Action, AuditContext, DefaultRole, PermissionScope};
use crate::runs::_helpers::requests::permissions::authorize_execution;
Expand All @@ -14,7 +15,8 @@ use crate::runs::constants::MANUAL_JOB_AUDIT_TAG;
use crate::runs::main::frozen_job_request::frozen_job_request;
use crate::runs::models::{JobRunRequest, RunWindow};
use crate::store::errors::ControlError;
use crate::store::models::ControlTransaction;
use crate::store::main::query::query;
use crate::store::models::{ControlTransaction, Parameter};

pub(crate) async fn request_job_run_in(
mut transaction: ControlTransaction,
Expand Down Expand Up @@ -61,6 +63,10 @@ pub(crate) async fn request_job_run_in(
if retry {
return Ok((transaction, false));
}
let transaction = match credentials {
Some(credentials) => known_job(transaction, (&context, credentials), request).await?,
None => transaction,
};
let (mut transaction, frozen) = frozen_job_request(transaction, &context, request).await?;
if let Some(credentials) = credentials {
(transaction, context) = authorize_execution(
Expand All @@ -78,3 +84,25 @@ pub(crate) async fn request_job_run_in(
json!(null), json!({"request":request,"frozen":frozen}))).await?;
Ok((transaction, true))
}

/// A person's unknown job is refused like a forbidden one unless they may run every node of the target.
async fn known_job(
transaction: ControlTransaction,
credentials: (&AuditContext, Uuid),
request: &JobRunRequest,
) -> Result<ControlTransaction, ControlError> {
let (transaction, jobs) = query(transaction,
"SELECT j.job_key FROM sqb_jobs j JOIN sqb_current_snapshots c ON c.location_id=j.location_id \
AND c.target=j.target AND c.snapshot_id=j.snapshot_id WHERE j.location_id=$1 AND j.target=$2 AND j.job_key=$3",
vec![Parameter::Id(request.location_id), Parameter::Text(request.target.clone()), Parameter::Text(request.job_key.clone())]).await?;
if jobs.is_empty() {
return refuse_unknown(
transaction,
credentials,
(Action::JobRun, Some((request.location_id, &request.target))),
ControlError::Invalid("published job not found in this location and target".into()),
)
.await;
}
Ok(transaction)
}
72 changes: 72 additions & 0 deletions crates/sqlbuild-orchestration/tests/api/denials/helpers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! Masked denial responses, person requests, and the audit detail behind them.

use reqwest::header;
use serde_json::{Value, json};
use uuid::Uuid;

use crate::store::main::{begin::begin, commit::commit, query::query};
use crate::store::models::{ControlStore, TransactionMode};

/// Status, the headers that could tell cases apart, and the body with its decision ID masked.
pub(crate) async fn masked(response: reqwest::Response) -> (Value, Uuid) {
let status = response.status().as_u16();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.filter(|(name, _)| *name != header::DATE)
.map(|(name, value)| (name.to_string(), value.to_str().unwrap_or("").to_owned()))
.collect();
let text = response.text().await.expect("denial body");
let body: Value = serde_json::from_str(&text).expect("JSON denial");
let decision: Uuid = serde_json::from_value(body["decision_id"].clone()).expect("decision ID");
(
json!({"status": status, "headers": headers,
"body": text.replace(&decision.to_string(), "<decision>")}),
decision,
)
}

pub(crate) async fn audited_scope(store: &ControlStore, decision: Uuid) -> Value {
let transaction = begin(store, TransactionMode::Read).await.expect("audit");
let (transaction, rows) = query(
transaction,
"SELECT scope_json, after_json FROM sqb_security_audit WHERE action='authorization.denied'",
vec![],
)
.await
.expect("denials");
commit(transaction).await.expect("close audit");
rows.into_iter()
.find(|row| row["after_json"]["decision_id"] == json!(decision))
.map(|row| json!([row["scope_json"]["nodes"], row["after_json"]["reason"]]))
.unwrap_or(Value::Null)
}

/// A person's browser request with session cookie, origin and CSRF proof.
pub(crate) async fn person_send(
client: &reqwest::Client,
(url, session, csrf): (&str, &str, &str),
body: Option<Value>,
) -> reqwest::Response {
let request = body.map_or_else(|| client.get(url), |body| client.post(url).json(&body));
request
.header(header::HOST, "control.example.com")
.header(header::ORIGIN, "https://control.example.com")
.header(header::COOKIE, format!("__Host-sqb-session={session}"))
.header("x-sqb-csrf", csrf)
.send()
.await
.expect("person request")
}

/// A job request body for a location and job key.
pub(crate) fn job(location_id: Uuid, job_key: &str) -> Value {
json!({"run_id": Uuid::now_v7(), "location_id": location_id, "target": "development",
"job_key": job_key, "reason": "Refresh orders", "priority": 0})
}

/// A trigger change body of the given kind.
pub(crate) fn trigger(trigger_id: Uuid, change: &str) -> Value {
json!({"event_id": Uuid::now_v7(), "trigger_id": trigger_id, "actor": "",
"reason": "Hold orders", "change": {"kind": change}})
}
4 changes: 4 additions & 0 deletions crates/sqlbuild-orchestration/tests/api/test_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#[path = "changes/helpers.rs"]
mod changes_helpers;
#[path = "denials/helpers.rs"]
mod denials_helpers;
#[path = "fingerprint_reads/helpers.rs"]
mod fingerprint_reads_helpers;
#[path = "helpers.rs"]
Expand Down Expand Up @@ -40,6 +42,8 @@ mod test_cli_login;
mod test_contract;
#[path = "test_custom_roles.rs"]
mod test_custom_roles;
#[path = "test_denials.rs"]
mod test_denials;
#[path = "test_development.rs"]
mod test_development;
#[path = "test_external_sessions.rs"]
Expand Down
Loading
Loading