diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index 4ad04741175..7f878a9de72 100644 --- a/crates/fractal-kernel/src/config.rs +++ b/crates/fractal-kernel/src/config.rs @@ -469,7 +469,8 @@ impl LimitsConfig { #[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. + /// `enforce` (the default) refuses a write outside a run's authority; `audit` lets it proceed + /// and records it. pub authority: RunAuthorityMode, } @@ -480,8 +481,8 @@ impl ProgramsConfig { #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum RunAuthorityMode { - #[default] Audit, + #[default] Enforce, } @@ -1414,6 +1415,16 @@ fn machine_host_name() -> String { #[cfg(test)] mod tests { + #[test] + fn run_authority_is_enforced_unless_the_config_asks_for_audit() { + use super::RunAuthorityMode; + let absent: super::Config = toml::from_str("").unwrap(); + assert_eq!(absent.programs.authority, RunAuthorityMode::Enforce); + let audit: super::Config = toml::from_str("[programs]\nauthority = \"audit\"\n").unwrap(); + assert_eq!(audit.programs.authority, RunAuthorityMode::Audit); + assert!(toml::from_str::("[programs]\nauthority = \"off\"\n").is_err()); + } + #[test] fn usage_writer_configuration_is_explicit_and_agent_only() { let absent: super::Config = toml::from_str("").unwrap(); diff --git a/crates/fractal-store/src/store/run_authority.rs b/crates/fractal-store/src/store/run_authority.rs index d5bef074d4d..810a175fe4b 100644 --- a/crates/fractal-store/src/store/run_authority.rs +++ b/crates/fractal-store/src/store/run_authority.rs @@ -7,8 +7,8 @@ //! person, agent or daemon starter holds everything in free mode except what the rules restrict, //! so each write is also decided by the rules for that starter. //! -//! A write outside the authority is audited (`rule.audited` on `rule/run-authority`) or refused -//! (`run-authority-denied`), as the daemon's `[programs] authority` says. +//! A write outside the authority is refused (`run-authority-denied`), or audited (`rule.audited` +//! on `rule/run-authority`) when the daemon's `[programs] authority` says `audit`. use super::*; use crate::config::RunAuthorityMode; use crate::model::{AuthorityGrant, AuthorityVerb}; @@ -20,7 +20,8 @@ pub const AUDIT_RULE: &str = "run-authority"; /// Run starters nest at most this deep; a deeper chain grants nothing. const MAX_STARTERS: usize = 32; -static ENFORCE: AtomicBool = AtomicBool::new(false); +/// Enforced until the daemon's config says otherwise, as `[programs] authority` defaults. +static ENFORCE: AtomicBool = AtomicBool::new(true); /// Set what a write outside a run's authority does, from the daemon's `[programs] authority`. pub fn set_run_authority_mode(mode: RunAuthorityMode) { diff --git a/crates/fractal-store/src/store/run_authority_tests.rs b/crates/fractal-store/src/store/run_authority_tests.rs index fa616485878..409907e47f5 100644 --- a/crates/fractal-store/src/store/run_authority_tests.rs +++ b/crates/fractal-store/src/store/run_authority_tests.rs @@ -214,7 +214,8 @@ fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { #[test] fn writers_other_than_runs_are_untouched() { let _mode = hold_mode(); - set_run_authority_mode(RunAuthorityMode::Enforce); + // Enforced by default, before any config sets the mode. + assert_eq!(run_authority_mode(), RunAuthorityMode::Enforce); let store = missions(); write(&store, "person/operator", "custom/other/person").unwrap(); store.append_claim(&ClaimInput { @@ -226,5 +227,5 @@ fn writers_other_than_runs_are_untouched() { expected_subject: None, idempotency_key: None, }).unwrap(); - set_run_authority_mode(RunAuthorityMode::Audit); + assert_eq!(run_authority_mode(), RunAuthorityMode::Enforce); } diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index ba3b7641dae..9246d98a000 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -233,11 +233,12 @@ 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` +authority = "enforce" # the default: the write is refused with `run-authority-denied` +# authority = "audit" # the write proceeds and `rule.audited` records it on `rule/run-authority` ``` -Read the audit log with `fractal rules audit run-authority` before you enforce. The setting is read +Audit lets you discover the grants a new program needs: read them with +`fractal rules audit run-authority`, declare them, then return to enforce. The setting is read when the daemon starts. ## Definitions do not start work diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index defbd973036..f6eb69699fa 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -258,9 +258,9 @@ generation with a predecessor needs `revise ID`, as does everything a revision w 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). +outside the effective authority is refused with `run-authority-denied`, or, when the daemon's +`[programs] authority` is `audit`, recorded as `rule.audited` on `rule/run-authority` and let +through (`enforce` 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 diff --git a/release-notes/mission-run-authority-enforced.json b/release-notes/mission-run-authority-enforced.json new file mode 100644 index 00000000000..981f1409ebf --- /dev/null +++ b/release-notes/mission-run-authority-enforced.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Mission run authority is enforced by default: a claim a program writes as `mission-run/RUN` outside its declared authority, intersected with its starter's, is refused with `run-authority-denied`; `[programs] authority = \"audit\"` lets it through and records it instead", + "replay": {"status": "none", "detail": "No claim, projection or replay change."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "The default of `[programs] authority` changes from `audit` to `enforce`, both in the config and in the store before the daemon reads its config. Writers that are not mission runs are untouched."}, + "compatibility": {"status": "changed", "detail": "A program whose mission does not declare the authority its effects use now has those writes refused. Programs land in production only from the next milestone; a daemon that needs to discover grants first sets `[programs] authority = \"audit\"`."}, + "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 for existing work, since nothing wrote as a run before program dispatch carried run credentials."}, + "recovery": {"status": "none", "detail": "Set `[programs] authority = \"audit\"` and restart to let refused writes proceed while their grants are declared."} +}