diff --git a/crates/fractal-daemon/src/service.rs b/crates/fractal-daemon/src/service.rs index 0c174bec143..f826a2e7ada 100644 --- a/crates/fractal-daemon/src/service.rs +++ b/crates/fractal-daemon/src/service.rs @@ -1739,6 +1739,7 @@ mod tests { limits: crate::config::LimitsConfig::default(), logging: crate::config::LoggingConfig::default(), reconcile: crate::config::ReconcileConfig::default(), + programs: crate::config::ProgramsConfig::default(), asks: crate::config::AsksConfig::default(), codex_work_without_asking: None, wait_for_peers_after_start: None, diff --git a/crates/fractal-delivery/src/store/delivery.rs b/crates/fractal-delivery/src/store/delivery.rs index 672e27e928b..f6d53e9ac5c 100644 --- a/crates/fractal-delivery/src/store/delivery.rs +++ b/crates/fractal-delivery/src/store/delivery.rs @@ -299,7 +299,7 @@ impl DeliveryStore for Store { let child = parent.map(|parent| ChildMissionContext { root_revision: parent.root_revision.clone(), root_run_id: parent.root_mission_run.trim_start_matches("mission-run/").into(), - parent_step_run: normalize_step_run(subscription), default_selector: None, + parent_step_run: normalize_step_run(subscription), default_selector: None, started_by: None, }); self.create_mission_run_inner(request, child, None, Some((resource, discovery))) } diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index 85ff65a7fa4..4ad04741175 100644 --- a/crates/fractal-kernel/src/config.rs +++ b/crates/fractal-kernel/src/config.rs @@ -151,6 +151,10 @@ pub struct Config { /// Background reconciliation start cap, reloaded with daemon policies. #[serde(skip_serializing_if = "ReconcileConfig::is_default")] pub reconcile: ReconcileConfig, + /// What a write outside a mission run's authority does. Written only when it differs from + /// the default. + #[serde(skip_serializing_if = "ProgramsConfig::is_default")] + pub programs: ProgramsConfig, /// How long a person has to answer an ask that names no deadline. Written only when it /// differs from the default. #[serde(skip_serializing_if = "AsksConfig::is_default")] @@ -460,6 +464,27 @@ impl LimitsConfig { } } +/// `[programs]`: how the daemon holds a program's run to its authority +/// (docs/st3/program-engine.md §3). +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ProgramsConfig { + /// `audit` lets a write outside a run's authority proceed and records it; `enforce` refuses it. + pub authority: RunAuthorityMode, +} + +impl ProgramsConfig { + pub fn is_default(&self) -> bool { self == &Self::default() } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunAuthorityMode { + #[default] + Audit, + Enforce, +} + /// The reconcile pass cap when `[reconcile] max_passes_per_minute` is not set. pub const DEFAULT_MAX_PASSES_PER_MINUTE: u32 = 30; @@ -789,6 +814,7 @@ impl Default for Config { limits: LimitsConfig::default(), logging: LoggingConfig::default(), reconcile: ReconcileConfig::default(), + programs: ProgramsConfig::default(), asks: AsksConfig::default(), codex_work_without_asking: None, wait_for_peers_after_start: None, diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 547e5116578..996bd3b09ea 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -236,6 +236,25 @@ mod identifier_query_tests { } } +fn child_context( + parent: &MissionRunView, + parent_step_run: &str, + default_selector: Option, + started_by: Option, +) -> ChildMissionContext { + ChildMissionContext { + root_revision: parent.root_revision.clone(), + root_run_id: parent + .root_mission_run + .strip_prefix("mission-run/") + .unwrap_or(&parent.root_mission_run) + .to_owned(), + parent_step_run: normalize_step_run(parent_step_run), + default_selector, + started_by, + } +} + /// Render a copy; the signed revision and execution spec remain physical. fn public_mission_spec(connection: &Connection, body: &str) -> Result { let mut mission: MissionSpec = serde_json::from_str(body)?; @@ -1610,6 +1629,15 @@ pub trait MissionsStore { parent_step_run: &str, default_selector: Option<&WorkSelector>, ) -> Result; + /// A child run a program step starts: the parent run starts it, so its creation is written + /// as `mission-run/PARENT` and it holds at most its parent's authority + /// (docs/st3/program-engine.md §3). The requester stays the parent's. + fn create_program_child_mission_run( + &self, + request: &MissionRunRequest, + parent: &MissionRunView, + parent_step_run: &str, + ) -> Result; /// An occurrence belongs to its schedule, regardless of the requesting member or revision. fn scheduled_mission_run_subject(schedule: &str, occurrence: u64) -> String; fn create_scheduled_mission_run( @@ -2939,16 +2967,21 @@ impl MissionsStore for Store { ) -> Result { self.create_mission_run_inner( request, - Some(ChildMissionContext { - root_revision: parent.root_revision.clone(), - root_run_id: parent - .root_mission_run - .strip_prefix("mission-run/") - .unwrap_or(&parent.root_mission_run) - .to_owned(), - parent_step_run: normalize_step_run(parent_step_run), - default_selector: default_selector.cloned(), - }), + Some(child_context(parent, parent_step_run, default_selector.cloned(), None)), + None, + None, + ) + } + + fn create_program_child_mission_run( + &self, + request: &MissionRunRequest, + parent: &MissionRunView, + parent_step_run: &str, + ) -> Result { + self.create_mission_run_inner( + request, + Some(child_context(parent, parent_step_run, None, Some(parent.subject.clone()))), None, None, ) @@ -2984,6 +3017,7 @@ impl MissionsStore for Store { .into(), parent_step_run: normalize_step_run(schedule), default_selector: None, + started_by: None, }); self.create_mission_run_inner(request, child, Some(&subject), None) } @@ -4674,7 +4708,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } } revision_seats::carry_completed_agents_tx( @@ -4717,7 +4751,7 @@ impl MissionsStore for Store { None, idempotency_key, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; terminalize_generation_steps_tx( &transaction, &self.origin, @@ -4744,7 +4778,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; if reopening { // The run's own state history says who reopened it, when, and why. append_claim_tx( @@ -4762,7 +4796,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } if let Some(proposal) = proposal { let changed = transaction @@ -4793,7 +4827,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } let view = mission_run_view_tx(&transaction, run_id).map_err(internal)?; transaction diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 0e2731a5b2d..57d2b042449 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -73,6 +73,7 @@ pub mod seat_queue_plan_tests; pub mod schedule_standby; pub mod mission_capacity; pub mod run_credentials; +pub mod run_authority; pub mod marks_switch; pub mod glasses; pub mod mailbox_wakes; @@ -354,6 +355,8 @@ mod run_eta_tests; #[cfg(test)] mod run_credentials_tests; #[cfg(test)] +mod run_authority_tests; +#[cfg(test)] mod run_eta_review_tests; pub mod client_summary; pub mod unread_mail; @@ -2435,6 +2438,10 @@ pub struct ChildMissionContext { pub root_run_id: String, pub parent_step_run: String, pub default_selector: Option, + /// Who starts the child when it is not its requester: `mission-run/PARENT` for a child a + /// program starts. Its creation claims are written as this actor, and run authority takes it + /// as the child's starter (docs/st3/program-engine.md §3). + pub started_by: Option, } fn stale_ref_request_tx(connection: &Connection, resource: &str, discovery: &str) -> Result> { @@ -7952,7 +7959,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; if status == "draining" { transaction .execute( @@ -8047,7 +8054,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(claim.id); } } @@ -8094,7 +8101,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(superseded.id); claim_ids.extend(terminalize_generation_steps_tx( transaction, @@ -8122,7 +8129,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(created.id); Ok((claim_ids, false)) } @@ -11605,6 +11612,8 @@ fn replicated_work_validation_fields<'a>( owned_sets::validate_receipt(subject, body).map_err(anyhow::Error::new)?; } let fields = schema_fields_for_body(kind, body)?; + run_authority::admit_tx(transaction, origin, actor, kind, subject, &fields) + .map_err(anyhow::Error::new)?; human_review_reassignment::admit_reassignment(transaction, kind, &fields) .map_err(anyhow::Error::new)?; if kind == "person.delegation-set" @@ -21760,7 +21769,7 @@ pub fn terminalize_generation_steps_tx( &[], batch_id, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(claim.id); } Ok(claim_ids) @@ -22230,7 +22239,7 @@ pub fn cancel_descendant_mission_runs_tx( &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(run_claim.id); let generation_claim = append_claim_tx( transaction, @@ -22242,7 +22251,7 @@ pub fn cancel_descendant_mission_runs_tx( &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(generation_claim.id); } Ok(claim_ids) diff --git a/crates/fractal-store/src/store/message_routing.rs b/crates/fractal-store/src/store/message_routing.rs index 904ddf7774d..1b06914bd5e 100644 --- a/crates/fractal-store/src/store/message_routing.rs +++ b/crates/fractal-store/src/store/message_routing.rs @@ -22,6 +22,12 @@ pub fn legacy_reason(store: &Store, input: &ClaimInput) -> Result RunAuthorityMode { + if ENFORCE.load(Ordering::Relaxed) { RunAuthorityMode::Enforce } else { RunAuthorityMode::Audit } +} + +/// Whether `pattern` covers `target`, itself exact or a terminal `/*` namespace. +pub fn covers(pattern: &str, target: &str) -> bool { + pattern == target + || pattern.strip_suffix("/*").is_some_and(|namespace| { + target.strip_prefix(namespace).is_some_and(|rest| rest.starts_with('/')) + }) +} + +/// What both rule sets grant. With exact-or-namespace patterns this is exact: for each pair with +/// one verb, the narrower pattern when one covers the other. +pub fn intersect(left: &[AuthorityGrant], right: &[AuthorityGrant]) -> Vec { + let mut both = BTreeSet::new(); + for one in left { + for other in right.iter().filter(|other| other.verb == one.verb) { + if covers(&one.pattern, &other.pattern) { + both.insert(other.clone()); + } else if covers(&other.pattern, &one.pattern) { + both.insert(one.clone()); + } + } + } + both.into_iter().collect() +} + +/// The run a run-owned subject belongs to: `mission-run/RUN`, its generations and their step runs. +fn owning_run_tx(transaction: &Transaction<'_>, subject: &str) -> Result, FractalError> { + if let Some(run) = subject.strip_prefix("mission-run/") { + return Ok(Some(run.to_owned())); + } + let generation = subject.strip_prefix("run-generation/") + .or_else(|| subject.strip_prefix("step-run/").and_then(|rest| rest.split('/').next())); + let Some(generation) = generation else { return Ok(None) }; + transaction.query_row("SELECT run_id FROM run_generations WHERE id=?1", [generation], |row| row.get(0)) + .optional().map_err(internal) +} + +/// The grant one claim needs, or none for the run's own journal. A start or a revision writes +/// several claims as one operation; each names the operation's grant on the mission of the run +/// it changes, never a `write`, so the operation's verb alone admits it. +pub(super) fn needed_tx( + transaction: &Transaction<'_>, + run: &str, + kind: &str, + subject: &str, + fields: &BTreeMap, +) -> Result, FractalError> { + let mission_of = |run: &str| -> Result { + transaction.query_row( + "SELECT mission_id FROM mission_runs WHERE id=?1", + [run.trim_start_matches("mission-run/")], + |row| row.get::<_, String>(0), + ).optional().map_err(internal)?.ok_or_else(|| FractalError::new( + "run-authority-denied", format!("{run} is not a mission run this host holds"), + )) + }; + let field = |name: &str| fields.get(name).and_then(Value::as_str); + let grant = |verb, pattern: String| Ok(Some(AuthorityGrant { verb, pattern })); + let revise = |owner: Option| match owner { + Some(owner) => grant(AuthorityVerb::Revise, mission_of(&owner)?), + None => grant(AuthorityVerb::Write, subject.into()), + }; + match kind { + // A run always journals its own program steps. + "program.step" => { + if owning_run_tx(transaction, subject)?.as_deref() == Some(run) { + Ok(None) + } else { + grant(AuthorityVerb::Write, subject.into()) + } + } + "mission.published" => grant(AuthorityVerb::Publish, subject.trim_start_matches("mission/").into()), + "mission-run.created" => grant(AuthorityVerb::Start, field("mission").unwrap_or_default().trim_start_matches("mission/").into()), + // Only a revision's generation has a predecessor; its free-text reason decides nothing. + "run-generation.created" => { + let verb = if field("predecessor").is_some() { AuthorityVerb::Revise } else { AuthorityVerb::Start }; + grant(verb, mission_of(field("run").unwrap_or_default())?) + } + // What a revision writes on the runs it changes: its proposal, carried and terminalized + // steps, the superseded generation, the run's and its descendants' states. + "step-run.carried" | "step-run.state" | "run-generation.superseded" | "run-generation.state" + | "mission-run.state" => revise(owning_run_tx(transaction, subject)?), + "revision-proposal.created" => revise(owning_run_tx(transaction, field("run").unwrap_or_default())?), + "revision-proposal.applied" => { + revise(owning_run_tx(transaction, field("successor_generation").unwrap_or_default())?) + } + _ => grant(AuthorityVerb::Write, subject.into()), + } +} + +/// A run's effective authority, and the first starter in its chain that is not a run. Each level +/// reads only the revision's `authority` member, not the whole mission spec. +fn effective_tx( + transaction: &Transaction<'_>, + run: &str, +) -> Result<(Vec, Option), FractalError> { + let mut granted: Option> = None; + let mut current = run.to_owned(); + for _ in 0..MAX_STARTERS { + let Some((declared, requester, starter)) = transaction.query_row( + "SELECT json_extract(m.body, '$.authority'), r.requester, + (SELECT actor FROM claims WHERE subject='mission-run/'||r.id AND kind='mission-run.created' LIMIT 1) + FROM mission_runs r + JOIN run_generations g ON g.id=r.current_generation_id + LEFT JOIN mission_revisions m ON m.mission_id=r.mission_id AND m.revision=g.revision + WHERE r.id=?1", + [¤t], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?)), + ).optional().map_err(internal)? else { + return Ok((Vec::new(), None)); + }; + let declared: Vec = declared + .map(|declared| serde_json::from_str(&declared)) + .transpose() + .map_err(internal)? + .unwrap_or_default(); + granted = Some(match granted { + Some(granted) => intersect(&granted, &declared), + None => declared, + }); + let starter = starter.unwrap_or(requester); + match starter.strip_prefix("mission-run/") { + Some(parent) => current = parent.to_owned(), + None => return Ok((granted.unwrap_or_default(), Some(starter))), + } + } + Ok((Vec::new(), None)) +} + +/// Admit one claim written as `actor`: a claim a run writes outside its effective authority is +/// audited or refused. Every other writer passes untouched. +pub(super) fn admit_tx( + transaction: &Transaction<'_>, + origin: &str, + actor: Option<&str>, + kind: &str, + subject: &str, + fields: &BTreeMap, +) -> Result<(), FractalError> { + let Some(actor) = actor.filter(|actor| actor.starts_with("mission-run/")) else { return Ok(()) }; + let run = actor.trim_start_matches("mission-run/"); + let Some(needed) = needed_tx(transaction, run, kind, subject, fields)? else { return Ok(()) }; + let (granted, starter) = effective_tx(transaction, run)?; + if let Some(starter) = &starter { + smallclaims::store::principals::rules_gate_tx(transaction, origin, starter, kind, subject) + .map_err(smallclaims::error::typed)?; + } + if granted.iter().any(|grant| grant.verb == needed.verb && covers(&grant.pattern, &needed.pattern)) { + return Ok(()); + } + match run_authority_mode() { + RunAuthorityMode::Enforce => Err(FractalError::new( + "run-authority-denied", + format!( + "{actor} may not {} `{}` ({kind} on {subject}): its mission's authority, intersected with its starter's, does not grant it", + needed.verb.as_str(), needed.pattern, + ), + )), + RunAuthorityMode::Audit => { + append_claim_tx( + transaction, origin, &format!("rule/{AUDIT_RULE}"), smallclaims::rules::RULE_AUDITED, None, + &json!({"fields": {"rule": AUDIT_RULE, "actor": actor, "action": kind, "target": subject}, "evidence": []}), + &[], None, + ).map_err(internal)?; + Ok(()) + } + } +} diff --git a/crates/fractal-store/src/store/run_authority_tests.rs b/crates/fractal-store/src/store/run_authority_tests.rs new file mode 100644 index 00000000000..fa616485878 --- /dev/null +++ b/crates/fractal-store/src/store/run_authority_tests.rs @@ -0,0 +1,230 @@ +use super::run_authority::{covers, intersect, run_authority_mode, set_run_authority_mode}; +use super::tests_shared_seats::publish_mission; +use super::*; +use crate::config::RunAuthorityMode; +use crate::model::{AuthorityGrant, AuthorityVerb, MissionRunRequest}; + +/// The mode is the daemon's, process-wide: tests that depend on it take turns, and each gives +/// back the mode it found. +static MODE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +struct ModeGuard { + before: RunAuthorityMode, + _turn: std::sync::MutexGuard<'static, ()>, +} + +impl Drop for ModeGuard { + fn drop(&mut self) { + set_run_authority_mode(self.before); + } +} + +fn hold_mode() -> ModeGuard { + let turn = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + ModeGuard { before: run_authority_mode(), _turn: turn } +} + +fn grant(verb: AuthorityVerb, pattern: &str) -> AuthorityGrant { + AuthorityGrant { verb, pattern: pattern.into() } +} + +#[test] +fn covering_and_intersection_are_exact_for_namespaces() { + assert!(covers("custom/factory/*", "custom/factory/landing")); + assert!(covers("custom/factory/*", "custom/factory/deep/*")); + assert!(covers("custom/factory/landing", "custom/factory/landing")); + assert!(!covers("custom/factory/*", "custom/factory")); + assert!(!covers("custom/factory/*", "custom/factoryx/landing")); + assert!(!covers("custom/factory/landing", "custom/factory/*")); + let declared = [grant(AuthorityVerb::Write, "custom/*"), grant(AuthorityVerb::Start, "check")]; + let starter = [ + grant(AuthorityVerb::Write, "custom/factory/*"), + grant(AuthorityVerb::Start, "other"), + grant(AuthorityVerb::Publish, "check"), + ]; + assert_eq!(intersect(&declared, &starter), [grant(AuthorityVerb::Write, "custom/factory/*")]); + assert_eq!(intersect(&starter, &declared), [grant(AuthorityVerb::Write, "custom/factory/*")]); +} + +fn start(store: &Store, mission: &str, key: &str, started_by: Option<&MissionRunView>) -> Result { + let child = started_by.map(|parent| ChildMissionContext { + root_revision: parent.root_revision.clone(), + root_run_id: parent.root_mission_run.trim_start_matches("mission-run/").into(), + parent_step_run: parent.steps[0].subject.clone(), + default_selector: None, + started_by: Some(parent.subject.clone()), + }); + store.create_mission_run_inner(&MissionRunRequest { + mission: mission.into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/operator".into()), + mode: None, + inputs: Default::default(), + idempotency_key: key.into(), + }, child, None, None) +} + +fn write(store: &Store, actor: &str, subject: &str) -> Result { + store.append_claim(&ClaimInput { + subject: subject.into(), + kind: "custom.test.observed".into(), + actor: Some(actor.into()), + fields: BTreeMap::from([("value".into(), json!(1))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) +} + +fn code(result: Result) -> String { + result.unwrap_err().code.to_string() +} + +fn missions() -> Store { + let store = Store::open_memory("node").unwrap(); + publish_mission(&store, r#"version 2 +mission "land" state="ready" { + goal "Land one change." + authority { write "custom/factory/*"; write "doc/factory/*"; start "check"; revise "check"; } + step "program" { agentless } +}"#, "land"); + publish_mission(&store, r#"version 2 +mission "check" state="ready" { + goal "Check it." + authority { write "custom/*"; } + step "program" { agentless } +}"#, "check"); + publish_mission(&store, r#"version 2 +mission "other" state="ready" { + goal "Something else." + step "work" { agentless } +}"#, "other"); + store +} + +/// The grant a claim needs follows the operation it belongs to, decided by fields its writer +/// cannot choose. +#[test] +fn a_claims_needed_grant_follows_its_operation() { + use super::run_authority::needed_tx; + let store = missions(); + let land = start(&store, "land", "land-needs", None).unwrap(); + let check = start(&store, "check", "check-needs", None).unwrap(); + let other = start(&store, "other", "other-needs", None).unwrap(); + let run = land.subject.trim_start_matches("mission-run/"); + let generation = |view: &MissionRunView| view.steps[0].subject.split('/').nth(1).unwrap().to_owned(); + let mut connection = store.connection.write(); + let transaction = connection.transaction().unwrap(); + let needs = |kind: &str, subject: &str, fields: Value| { + let fields = fields.as_object().unwrap().clone().into_iter().collect::>(); + needed_tx(&transaction, run, kind, subject, &fields).unwrap() + .map(|grant| format!("{} {}", grant.verb.as_str(), grant.pattern)) + }; + let next = "run-generation/next"; + // A revision's generation names its predecessor, whatever its reason says. + assert_eq!(needs("run-generation.created", next, + json!({"run": check.subject, "predecessor": format!("run-generation/{}", generation(&check)), "reason": "initial mission run"})), + Some("revise check".into())); + assert_eq!(needs("run-generation.created", next, json!({"run": check.subject, "reason": "anything"})), Some("start check".into())); + // What a revision writes on the runs it changes needs `revise` on their missions. + assert_eq!(needs("step-run.carried", &check.steps[0].subject, json!({})), Some("revise check".into())); + assert_eq!(needs("run-generation.superseded", &format!("run-generation/{}", generation(&check)), json!({})), Some("revise check".into())); + assert_eq!(needs("step-run.state", &other.steps[0].subject, json!({})), Some("revise other".into())); + assert_eq!(needs("mission-run.state", &other.subject, json!({})), Some("revise other".into())); + assert_eq!(needs("revision-proposal.created", "revision-proposal/one", json!({"run": check.subject})), Some("revise check".into())); + // A run journals its own steps freely; another run's journal is a write like any other. + assert_eq!(needs("program.step", &land.steps[0].subject, json!({})), None); + assert_eq!(needs("program.step", &check.steps[0].subject, json!({})), Some(format!("write {}", check.steps[0].subject))); + assert_eq!(needs("custom.test.observed", "custom/factory/x", json!({})), Some("write custom/factory/x".into())); +} + +/// A document a run binds is decided by its run authority, as its claims are. +#[test] +fn a_runs_document_writes_are_decided_by_its_authority() { + let _mode = hold_mode(); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + let land = start(&store, "land", "land-docs", None).unwrap(); + let put = |name: &str| store.put_document_as(name, b"notes", &None, name, Some(&land.subject)); + put("doc/factory/notes").unwrap(); + assert_eq!(code(put("doc/elsewhere/notes")), "run-authority-denied"); + set_run_authority_mode(RunAuthorityMode::Audit); + put("doc/elsewhere/audited").unwrap(); + let audits = store.rule_audits(Some("run-authority"), 10).unwrap(); + assert_eq!(audits[0].body["fields"]["target"], "doc/elsewhere/audited"); + assert_eq!(audits[0].body["fields"]["action"], "doc.bound"); +} + +#[test] +fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { + let _mode = hold_mode(); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + let land = start(&store, "land", "land-run", None).unwrap(); + + // Inside the declared authority: written as the run. + let inside = write(&store, &land.subject, "custom/factory/landing").unwrap(); + assert_eq!(inside.actor.as_deref(), Some(land.subject.as_str())); + // Outside it: refused, and nothing is written. + let before = store.index().unwrap(); + assert_eq!(code(write(&store, &land.subject, "custom/other/landing")), "run-authority-denied"); + assert_eq!(store.index().unwrap(), before); + + // A program starts a child it may start; the child's creation is written as the parent run. + let check = start(&store, "check", "check-run", Some(&land)).unwrap(); + let created = store.claims_for(&check.subject, Some("mission-run.created")).unwrap(); + assert_eq!(created[0].actor.as_deref(), Some(land.subject.as_str())); + assert_eq!(check.requester, "person/operator"); + // The child declares `custom/*`, but its starter holds only `custom/factory/*`. + write(&store, &check.subject, "custom/factory/check").unwrap(); + assert_eq!(code(write(&store, &check.subject, "custom/other/check")), "run-authority-denied"); + // A mission it may not start does not start. + assert_eq!(code(start(&store, "other", "other-run", Some(&land))), "run-authority-denied"); + // A run whose mission declares nothing writes nothing. + let other = start(&store, "other", "other-direct", None).unwrap(); + assert_eq!(code(write(&store, &other.subject, "custom/factory/other")), "run-authority-denied"); + + // The person who started the run is still held to the rules that restrict them. + store.set_rule("locked", &smallclaims::rules::Rule { + mode: smallclaims::rules::Mode::Enforce, + actors: vec!["person/operator".into()], + kinds: vec!["custom.test.observed".into()], + subjects: vec!["custom/factory/locked".into()], + ..Default::default() + }, "person/owner").unwrap(); + assert_eq!(code(write(&store, &land.subject, "custom/factory/locked")), "rule-denied"); + assert_eq!(code(write(&store, &check.subject, "custom/factory/locked")), "rule-denied"); + + // In audit mode the write proceeds and is recorded under `rule/run-authority`. + set_run_authority_mode(RunAuthorityMode::Audit); + let audited = write(&store, &land.subject, "custom/other/audited").unwrap(); + assert_eq!(audited.actor.as_deref(), Some(land.subject.as_str())); + let audits = store.rule_audits(Some("run-authority"), 10).unwrap(); + assert_eq!(audits.len(), 1); + assert_eq!(audits[0].body["fields"]["actor"], land.subject); + assert_eq!(audits[0].body["fields"]["action"], "custom.test.observed"); + assert_eq!(audits[0].body["fields"]["target"], "custom/other/audited"); + // A write inside the authority leaves no audit. + write(&store, &land.subject, "custom/factory/again").unwrap(); + assert_eq!(store.rule_audits(Some("run-authority"), 10).unwrap().len(), 1); + assert_eq!(run_authority_mode(), RunAuthorityMode::Audit); +} + +#[test] +fn writers_other_than_runs_are_untouched() { + let _mode = hold_mode(); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + write(&store, "person/operator", "custom/other/person").unwrap(); + store.append_claim(&ClaimInput { + subject: "custom/other/node".into(), + kind: "custom.test.observed".into(), + actor: None, + fields: BTreeMap::from([("value".into(), json!(1))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }).unwrap(); + set_run_authority_mode(RunAuthorityMode::Audit); +} diff --git a/crates/fractal-store/src/store/runtime.rs b/crates/fractal-store/src/store/runtime.rs index 1268c9d1e80..4fdd3f6783f 100644 --- a/crates/fractal-store/src/store/runtime.rs +++ b/crates/fractal-store/src/store/runtime.rs @@ -268,6 +268,17 @@ impl Runtime for FractalRuntime { fn writer_observer(&self) -> Option> { Some(self.observer_actuals.clone()) } + fn admit_writer_tx( + &self, + transaction: &Transaction<'_>, + origin: &str, + writer: &str, + kind: &str, + subject: &str, + ) -> Result<(), FractalError> { + // A document a run binds is decided by its run authority, as its claims are. + super::run_authority::admit_tx(transaction, origin, Some(writer), kind, subject, &BTreeMap::new()) + } fn idempotency_response_in_use(&self, connection: &Connection, response: &str) -> Result { // Some local entries contain a digest, not JSON. They have no work lifetime to pin. let Ok(value) = serde_json::from_str::(response) else { diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 4b985df03ee..3a761cc6270 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7603,6 +7603,7 @@ async fn run_up(args: UpArgs) -> Result<()> { reconciler.set_idle_nudge(config.reconcile.idle_nudge()?); store.set_default_ask_deadline_ms(config.asks.default_deadline_ms()?)?; store.set_ask_deadlines(config.asks.deadlines); + fractal_store::store::run_authority::set_run_authority_mode(config.programs.authority); startup_phases.reconciler_ms = phase_elapsed_ms(); startup.phase("start-services"); startup_telemetry.phase("start-services"); diff --git a/crates/smallclaims/src/store.rs b/crates/smallclaims/src/store.rs index 9254fb815df..bf6c6751c00 100644 --- a/crates/smallclaims/src/store.rs +++ b/crates/smallclaims/src/store.rs @@ -6666,6 +6666,7 @@ impl Store { if let Some(writer) = writer { principals::rules_gate_tx(transaction, &self.origin, writer, "doc.bound", name) .map_err(crate::error::typed)?; + self.runtime.admit_writer_tx(transaction, &self.origin, writer, "doc.bound", name)?; } let mut body = json!({ "name": name, "hash": hash, "size": bytes.len() }); idempotency::attach(&mut body, idempotency_key).map_err(internal)?; diff --git a/crates/smallclaims/src/store/runtime.rs b/crates/smallclaims/src/store/runtime.rs index 908c533b447..1d66d0e19e3 100644 --- a/crates/smallclaims/src/store/runtime.rs +++ b/crates/smallclaims/src/store/runtime.rs @@ -109,6 +109,19 @@ pub trait Runtime: Send + Sync { false } + /// Admit a write whose claim names no actor but whose writer is known, such as a document + /// binding: the runtime's actor-keyed decisions see the writer as they see an actor. + fn admit_writer_tx( + &self, + _transaction: &Transaction<'_>, + _origin: &str, + _writer: &str, + _kind: &str, + _subject: &str, + ) -> Result<(), crate::error::Error> { + Ok(()) + } + /// Check a replicated claim's kind and fields once the graph has verified its hash and batch. fn classify_replicated_claim( &self, diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index db74d7437f9..ba3b7641dae 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -227,6 +227,19 @@ Mission-owned runtime declarations follow their owner lifetime; independent top- Within a fleet, an agent may publish, start, and revise missions and move agent queues as itself without a separate grant. This is broad authority, not isolation: review declarations before publication and give agents only access you intend them to use. A bound harness must retain its own identity; free mode does not allow it to impersonate a person or bypass operation-specific fences, human review, or a claimed producing step. +A mission run is the exception. A program step acts as `mission-run/RUN` and holds only what its +mission's `authority` block grants, intersected with the authority of whoever started the run. The +daemon's `[programs]` table says what a write outside it does: + +```toml +[programs] +authority = "audit" # the default: the write proceeds and `rule.audited` records it on `rule/run-authority` +# authority = "enforce" # the write is refused with `run-authority-denied` +``` + +Read the audit log with `fractal rules audit run-authority` before you enforce. The setting is read +when the daemon starts. + ## Definitions do not start work Publication saves a definition; it does not create a run. Start it explicitly with `fractal missions start`. To make a new run wait for another run to complete, use `--after RUN`; a failed or cancelled prerequisite is not successful completion. See [Missions in practice](../guides/missions-in-practice.md#queue-follow-on-work) for the runnable sequence. diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 0012338a973..46ad4c2fe2a 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -804,14 +804,14 @@ ], "flows": [ {"id": "flow-h0f24faa60a9f", "path": "crates/fractal-store/src/backup.rs", "function": "restore", "owner": "fractal_store::backup", "fingerprint": "367616d58ec64e13d447f49aa64db2731708beadc4a192eae7860c648e752808", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Offline backup restore is a historical producer/open path; write-preserving recovery and opener quiescence require a separate plan."}, - {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "5a85f47271c2c12786f62278c14e6686dbda7ab6f5f0c744e326482a56962d8c", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, + {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "0f90aec8e253911fe3860d326e69241918efbd9428a4594c48edae4a4f20582d", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, {"id": "flow-hae49bfcdc951", "path": "crates/fractal-store/src/store.rs", "function": "insert_event", "owner": "fractal_store::store", "fingerprint": "9cdeca113f2b75bcf56424758796dc68d0158e63ba3c7520de898551700bbf4e", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "description": "Event positions/publication must retain canonical pointer/body and ordering semantics."}, {"id": "flow-hcc1764594fd2", "path": "crates/fractal-store/src/store/backup.rs", "function": "receive_backup_page", "owner": "fractal_store::store::backup", "fingerprint": "35b1ec2cf1835ef24f9f29107f60813ca41eb4197e8a2be9a98b5771cbccaed7", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Backup import permits valid historical records under its own record/page limits, not client extractor limits."}, {"id": "flow-he7b2f39a2da2", "path": "crates/smallclaims/src/store.rs", "function": "append_claim_record_tx", "owner": "smallclaims::store", "fingerprint": "fd48ee537bf54b5b208b4e3da218640c9239c63fd3b76ab3e836cf6ef81b97b8", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Local append serializes the logical body and predecessor identities; no route-limit inference."}, {"id": "flow-h8ff255a3c271", "path": "crates/smallclaims/src/store.rs", "function": "insert_claim", "owner": "smallclaims::store", "fingerprint": "c1f2beebb8654792635657bffc14ee4fa7e2f3303e803360b91feb922790e708", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Direct claim insert: body, predecessors and identifiers; bound parameters enter claims."}, {"id": "flow-h9a1973bf379a", "path": "crates/smallclaims/src/store.rs", "function": "promote_claim_blobs", "owner": "smallclaims::store", "fingerprint": "3a7eace9c6a74cbe44a5fbe23b9321f47c241c2662186a405ce7e863170442d5", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Blob promotion copies staged bytes to durable graph storage without changing logical identity."}, {"id": "flow-hd4562283f4d7", "path": "crates/smallclaims/src/store.rs", "function": "put_blob", "owner": "smallclaims::store", "fingerprint": "46f2545418004329783b4f1be7c2e0c5d5bd0a271694c3168a91ad3264c354ec", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Immutable blob bytes are independently variable values; retain digest/length/promotion semantics."}, - {"id": "flow-h5298e6bf0818", "path": "crates/smallclaims/src/store.rs", "function": "put_document_as", "owner": "smallclaims::store", "fingerprint": "1e506aa4b6b4fbea69af79a2ce98c3220bb8b41f127de139e327fd6fedc6a7c9", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Document API/local producer body, identity and predecessors reach the common claim writer."}, + {"id": "flow-h5298e6bf0818", "path": "crates/smallclaims/src/store.rs", "function": "put_document_as", "owner": "smallclaims::store", "fingerprint": "d4b8d676c6eef326bc2fc38e9da42ba86b602aab489539e83a0eec6310543298", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Document API/local producer body, identity and predecessors reach the common claim writer."}, {"id": "flow-h7818827323a6", "path": "crates/smallclaims/src/store.rs", "function": "receive_replication_exchange_via", "owner": "smallclaims::store", "fingerprint": "7fac5944c99444ba5e7e6024033ae70f75fe4e71784443e6a0ea77de8be7b8f4", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Exchange importer delegates admission and projection; wire/decompression bounds are not a universal stored-cell cap."}, {"id": "flow-he3fba1ec9f02", "path": "crates/smallclaims/src/store.rs", "function": "seed_replica_envelopes_signed_tx", "owner": "smallclaims::store", "fingerprint": "e8bca83e997622c80dbc8208225b55e51bf69a8d329a524a1aa61a2a2fa95180", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Local sealing serializes admitted history into exact signed raw envelopes; body/wire representations both matter."}, {"id": "flow-hc220cedadab9", "path": "crates/smallclaims/src/store.rs", "function": "separate_staged_blobs", "owner": "smallclaims::store", "fingerprint": "691fbc18013b213398f54c34218b5e72735c4b2300534ff1ed68822f17d8b4e7", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Legacy staged blob migration/copy may contain valid historical large cells."}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index aed8177627a..defbd973036 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -244,8 +244,25 @@ a mission ID without `mission/`; `write` takes a subject, or a registered family `/*`. A pattern is exact or ends in `/*`, which covers everything under it. Parsing refuses an empty block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop mission. Rules are stored in canonical order, so reordering them is not a revision; any other change -is. A mission without the block grants its runs nothing. The block is stored with the revision; -the daemon does not enforce it yet. A request whose caller's ancestry carries a live run credential +is. A mission without the block grants its runs nothing. + +Each claim written as `mission-run/RUN`, and each document a run binds, is decided at write +admission; a run's message send stays on the graph append so the decision applies to it. The run +holds the `authority` of its current generation's revision, intersected with its starter's: the +actor of its `mission-run.created`, which is its requester, or `mission-run/PARENT` for a child a +program starts (whose own effective authority is intersected in turn). A person, agent or daemon +starter contributes everything free mode gives it, and the rules that restrict it decide each of +the run's writes as well. The claim names the grant it needs: `mission.published` on `mission/ID` +needs `publish ID`; a run's creation and a generation without a predecessor need `start ID`; a +generation with a predecessor needs `revise ID`, as does everything a revision writes on the runs +it changes, each on that run's mission; any other claim needs `write SUBJECT`. A run always +journals `program.step` on its own step runs. `cancel` is reserved and grants nothing yet: setting +another run's outcome, which is how a run is cancelled, takes a person or agent actor. A write +outside the effective authority is recorded as `rule.audited` on `rule/run-authority` and +proceeds, or is refused with `run-authority-denied`, as the daemon's `[programs] authority` says +(`audit` by default). + +A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`, one per program attempt) is bound to `mission-run/RUN`: like a bound agent, it may not call internal routes, send a person header or name another `actor` or `requester`, and its writes are attributed to the run. The credential binds only while its attempt is the step run's diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index 5fabe56c608..67fc784cb24 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -195,7 +195,8 @@ repeated block or rule, an empty block, an unknown verb and any other `*`. There form: a mission without the block grants its runs nothing. The block is a field of the mission spec, kept in canonical order and absent when undeclared, so existing revisions hash unchanged and changing it is a mission revision. Publication holds a revision that declares it until every -member advertises `mission_authority=1`, as for `capacity`. +member advertises `mission_authority=1`, as for `capacity`. `cancel` is reserved: it parses and +grants nothing yet, because setting another run's outcome takes a person or agent actor. **Starter.** A run's starter is the actor of its `mission-run.created`: its requester, or, for a child a program starts (§6), the parent run, `mission-run/PARENT`. The requester stays the human. @@ -230,13 +231,18 @@ same run. The same-UID boundary is cooperative, as for agents. on its own step runs, whatever it declares. **Enforcement** is one decision at the store's write admission, beside the rules gate, for every -claim whose actor is a run. The claim names the grant it needs: `mission.published` on `mission/ID` -needs `publish ID`; a run's creation and its initial generation need `start ID`; a later generation -needs `revise ID`; any other claim needs `write SUBJECT`. Setting another run's outcome takes a -person or agent actor, so a run cannot cancel one. The daemon's `[programs] authority` says what -a write outside the authority does: `enforce` refuses it with `run-authority-denied`; `audit` -lets it through and records `rule.audited` on `rule/run-authority`, so a new program can discover -the grants it needs. +claim whose actor is a run and every document a run binds. A run's message send stays on the graph +append, where the decision applies, rather than the messages stream. The claim names the grant it +needs, from fields its writer cannot choose: `mission.published` on `mission/ID` needs +`publish ID`; a run's creation and a generation without a predecessor need `start ID`; a +generation with a predecessor needs `revise ID`, and so does everything a revision writes on the +runs it changes (its proposal, carried and terminalized steps, the superseded generation, the +run's and its descendants' states), each on the mission of the run it changes; any other claim +needs `write SUBJECT`. A refusal reaches its caller as `run-authority-denied`. The daemon's +`[programs] authority` says what a write outside the authority does: `enforce` refuses it; +`audit` lets it through and records `rule.audited` on `rule/run-authority`, so a new program can +discover the grants it needs. Each decision reads only each revision's `authority` member along +the starter chain. ### 4. Versioning diff --git a/release-notes/mission-run-authority-audit.json b/release-notes/mission-run-authority-audit.json new file mode 100644 index 00000000000..8f59509ba66 --- /dev/null +++ b/release-notes/mission-run-authority-audit.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Every claim and document binding written as `mission-run/RUN` is decided against the run's declared `authority` intersected with its starter's; the new daemon setting `[programs] authority` records a write outside it as `rule.audited` on `rule/run-authority` (`audit`) or refuses it with `run-authority-denied` (`enforce`); this change ships `audit` as the default, and the next change in the stack makes `enforce` the default", + "replay": {"status": "none", "detail": "No claim kind or projection change. Audit records reuse `rule.audited` with `rule` set to `run-authority`. A child run a program starts writes its `mission-run.created` and initial `run-generation.created` with actor `mission-run/PARENT`; its `requester` stays the parent's."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "First enforced declared authority. Writes by `mission-run/*` actors, and documents a run binds, are decided at write admission. `mission.published` needs `publish`; a run's creation and a generation without a predecessor need `start`; a generation with a predecessor needs `revise`, and so do the claims a revision writes on the runs it changes (its proposal, carried and terminalized steps, the superseded generation, run and descendant states); any other claim needs `write` on its subject. A run always journals `program.step` on its own step runs. A message a run sends stays on the graph append, where this decision applies, rather than the messages stream. The effective authority is the current revision's block intersected with the starter's (recursively through program-started parents); a person, agent or daemon starter's enforce-mode rules also decide each write. Writers that are not runs are untouched. `cancel` is reserved: it parses and grants nothing yet. Refusals reach the caller as `run-authority-denied`."}, + "compatibility": {"status": "changed", "detail": "New optional config table `[programs]` with `authority = \"audit\" | \"enforce\"`; an older build refuses a config that sets it (unknown field), so set it only on builds that read it. Without the table this change audits; once the next change lands, removing the table enforces. Nothing writes as a run until the program engine dispatches with run credentials."}, + "downtime": {"status": "none", "detail": "The mode is read when the daemon starts; changing it needs a restart."}, + "manual": {"status": "none", "detail": "No operator action is required. Read `fractal rules audit run-authority` before enforcing."}, + "recovery": {"status": "none", "detail": "Set `[programs] authority = \"audit\"` and restart to let refused writes proceed again while their grants are declared."} +}