From 4b34b193768f2d571d4e6b7d31572e123ddac796 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:04:54 +0200 Subject: [PATCH 1/6] feat(profiles): optional format version marker for people and profile files --- crates/permesh-cli/tests/profiles.rs | 18 +++++++ crates/permesh-config/src/lib.rs | 4 ++ crates/permesh-config/src/profiles.rs | 24 +++++++++ crates/permesh-config/tests/profiles.rs | 65 +++++++++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/crates/permesh-cli/tests/profiles.rs b/crates/permesh-cli/tests/profiles.rs index 991d850..0b959b6 100644 --- a/crates/permesh-cli/tests/profiles.rs +++ b/crates/permesh-cli/tests/profiles.rs @@ -322,3 +322,21 @@ fn left_person_expects_nothing_and_remaining_access_is_unexplained() { assert_eq!(bob["missing"], json!([]), "{bob}"); assert_eq!(bob["unexplained"].as_array().unwrap().len(), 1, "{bob}"); } + +#[test] +fn profile_check_rejects_unknown_version() { + let dir = demo(&[ + ("profiles/backend.yaml", BACKEND), + ( + "people/bob.yaml", + "version: 2\nidentity: bob@example.com\nprofiles: [backend]\n", + ), + ]); + let output = run(dir.path(), &["profile", "check", "--json"]); + assert_eq!(output.status.code(), Some(2)); + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("people/bob.yaml: unsupported format version 2"), + "{text}" + ); +} diff --git a/crates/permesh-config/src/lib.rs b/crates/permesh-config/src/lib.rs index 7b15bbf..7f2e913 100644 --- a/crates/permesh-config/src/lib.rs +++ b/crates/permesh-config/src/lib.rs @@ -44,6 +44,10 @@ pub enum Error { NotFound, #[error("cannot serialize workspace configuration")] Serialize, + #[error( + "unsupported format version {0}; this Permesh reads version 1. Remove the version line or upgrade Permesh." + )] + UnsupportedVersion(u64), #[error("{file}: {source}")] File { file: String, source: Box }, } diff --git a/crates/permesh-config/src/profiles.rs b/crates/permesh-config/src/profiles.rs index 29cf4a4..bc2e274 100644 --- a/crates/permesh-config/src/profiles.rs +++ b/crates/permesh-config/src/profiles.rs @@ -103,6 +103,9 @@ impl From for RawEntry { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Profile { + /// Optional format marker; absent means [`PROFILE_FORMAT`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, pub profile: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, @@ -113,6 +116,9 @@ pub struct Profile { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Person { + /// Optional format marker; absent means [`PROFILE_FORMAT`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, pub identity: String, #[serde(default)] pub status: PersonStatus, @@ -162,6 +168,16 @@ pub(crate) fn validate(config: &ProfilesConfig, instances: &BTreeSet<&str>) -> R Ok(()) } +/// The people and profile file format this build reads. +pub const PROFILE_FORMAT: u64 = 1; + +/// Read first, so a newer file reports its version instead of an unknown field. +#[derive(Deserialize)] +struct FormatPeek { + #[serde(default)] + version: Option, +} + fn in_file(file: &str, error: Error) -> Error { Error::File { file: file.to_owned(), @@ -202,6 +218,14 @@ fn read_dir( return Err(in_file(&label, Error::Read)); } let bytes = data::read_file(&file, MAX_CONFIG_BYTES).map_err(|e| in_file(&label, e))?; + // A file that is not even a mapping falls through to the full parse and its error. + if let Ok(FormatPeek { + version: Some(version), + }) = data::parse::(&bytes, MAX_CONFIG_BYTES) + && version != PROFILE_FORMAT + { + return Err(in_file(&label, Error::UnsupportedVersion(version))); + } files.push(( label.clone(), data::parse(&bytes, MAX_CONFIG_BYTES).map_err(|e| in_file(&label, e))?, diff --git a/crates/permesh-config/tests/profiles.rs b/crates/permesh-config/tests/profiles.rs index 5e934ee..da8f73d 100644 --- a/crates/permesh-config/tests/profiles.rs +++ b/crates/permesh-config/tests/profiles.rs @@ -241,3 +241,68 @@ fn left_status_is_parsed_and_default_is_active() { ); assert!(load(dir.path()).is_err()); } + +#[test] +fn version_absent_means_one() { + let dir = workspace( + "", + &[ + ("profiles/base.yaml", TEAM), + ( + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [base]\n", + ), + ], + ); + assert!( + load(dir.path()) + .unwrap() + .unwrap() + .people + .contains_key("bob@example.com") + ); +} + +#[test] +fn version_one_loads() { + let dir = workspace( + "", + &[ + ("profiles/base.yaml", &format!("version: 1\n{TEAM}")), + ( + "people/bob.yaml", + "version: 1\nidentity: bob@example.com\nprofiles: [base]\n", + ), + ], + ); + assert!( + load(dir.path()) + .unwrap() + .unwrap() + .people + .contains_key("bob@example.com") + ); +} + +#[test] +fn unknown_version_names_file() { + for (name, body) in [ + ( + "people/a.yaml", + "version: 2\nidentity: a@example.com\nprofiles: [base]\n", + ), + ("profiles/b.yaml", "version: 0\nprofile: b\ngrants: []\n"), + ] { + let mut files = vec![("profiles/base.yaml", TEAM)]; + files.push((name, body)); + let dir = workspace("", &files); + let message = load(dir.path()).unwrap_err().to_string(); + let version = if name.starts_with("people") { 2 } else { 0 }; + assert_eq!( + message, + format!( + "{name}: unsupported format version {version}; this Permesh reads version 1. Remove the version line or upgrade Permesh." + ) + ); + } +} From b61435c26b83d1adfcf449d4e81d2f80989df0e3 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:08:30 +0200 Subject: [PATCH 2/6] test: golden fixtures freeze check, profile and policy v2 formats --- .../tests/fixtures/stable/README.md | 17 ++ .../stable/check-report-incomplete.json | 139 +++++++++++ .../tests/fixtures/stable/check-report.json | 147 ++++++++++++ .../stable/config-check-profiles.json | 74 ++++++ .../stable/config-check-profiles.yaml | 29 +++ .../tests/fixtures/stable/policy-v2.json | 16 ++ .../tests/fixtures/stable/profile-check.json | 97 ++++++++ .../fixtures/stable/profile-suggest.json | 32 +++ crates/permesh-cli/tests/stable_formats.rs | 224 ++++++++++++++++++ 9 files changed, 775 insertions(+) create mode 100644 crates/permesh-cli/tests/fixtures/stable/README.md create mode 100644 crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/check-report.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/config-check-profiles.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/config-check-profiles.yaml create mode 100644 crates/permesh-cli/tests/fixtures/stable/policy-v2.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/profile-check.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/profile-suggest.json create mode 100644 crates/permesh-cli/tests/stable_formats.rs diff --git a/crates/permesh-cli/tests/fixtures/stable/README.md b/crates/permesh-cli/tests/fixtures/stable/README.md new file mode 100644 index 0000000..a688150 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/README.md @@ -0,0 +1,17 @@ +# Stable format fixtures + +These files freeze the formats declared stable by the second stability +declaration ([stability](../../../../../docs/stability.md)). `tests/stable_formats.rs` builds each +format from the synthetic demo and compares it exactly: field names, types, enum +spellings, array order and `null`s. + +Only run-specific values are replaced with `""`: string values of keys +ending in `_at`, `sha256` or `digest`, numbers under keys ending in `_ms`, and +strings starting with the temporary workspace path. Human-readable text, such as +the `profile suggest` draft, is normalized too and checked for validity instead. + +A failing fixture means a stable format changed. Additive changes (new optional +fields) are allowed; anything else needs a version bump and a migration guide in +`docs/migrations/`. After such a reviewed change, regenerate with +`PERMESH_BLESS=1 cargo test -p permesh-cli --test stable_formats` and review the +diff. Never regenerate to make a test pass. diff --git a/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json b/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json new file mode 100644 index 0000000..65c29af --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json @@ -0,0 +1,139 @@ +{ + "changes": "no_baseline", + "changes_state": { + "reasons": [], + "state": "no_baseline" + }, + "check_report_version": 1, + "complete": false, + "counts": { + "departed_with_access": 1, + "incomplete": 1, + "missing_access": 0, + "policy_finding": 0, + "provider_failed": 0, + "unexplained_access": 1 + }, + "departed": [ + { + "access_paths": 1, + "account": "102", + "instance": "demo", + "login": "former-dev", + "reason": "inactive_identity" + } + ], + "departed_assessed": true, + "files": { + "report": "out/check.json", + "snapshot": "out/snapshot.json", + "summary": "out/summary.md" + }, + "findings": [], + "notifications": [], + "policy": { + "rules": [ + "inactive_access", + "external_privileged", + "machine_owner" + ] + }, + "profiles": { + "complete": false, + "counts": { + "ambiguous_accounts": 1, + "cannot_tell": 3, + "covered": 2, + "excepted": 0, + "missing": 0, + "not_covered": 2, + "unexplained": 1, + "unresolved_members": 0 + }, + "enforce": false, + "identities": [ + { + "accounts": [], + "explained": 0, + "identity": "alice@example.com", + "missing": [ + { + "entry": { + "instance": "demo", + "kind": "role", + "resource": "repo-21", + "role": "Read" + }, + "reason": "ambiguous_account", + "state": "cannot_tell" + } + ], + "profiles": [ + "base" + ], + "state": "compared", + "unexplained": [] + }, + { + "accounts": [ + { + "account": "101", + "instance": "demo" + } + ], + "explained": 0, + "identity": "bob@example.com", + "missing": [ + { + "entry": { + "group": "team-10", + "instance": "demo", + "kind": "group" + }, + "reason": "ambiguous_account", + "state": "cannot_tell" + }, + { + "entry": { + "instance": "demo", + "kind": "role", + "resource": "repo-21", + "role": "Read" + }, + "reason": "ambiguous_account", + "state": "cannot_tell" + } + ], + "profiles": [ + "backend" + ], + "state": "compared", + "unexplained": [ + { + "account": "101", + "certainty": "observed", + "grant_id": "grant-3", + "instance": "demo", + "resource": "repo-21", + "resource_name": "acme/infrastructure", + "role": "Admin", + "state": "finding", + "via": [] + } + ] + } + ], + "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "profile_review_version": 1, + "unresolved_assignments": [] + }, + "providers": [ + { + "id": "demo", + "limitations": [], + "state": "complete" + } + ], + "run_url": null, + "status": "incomplete" +} diff --git a/crates/permesh-cli/tests/fixtures/stable/check-report.json b/crates/permesh-cli/tests/fixtures/stable/check-report.json new file mode 100644 index 0000000..bffdb4a --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/check-report.json @@ -0,0 +1,147 @@ +{ + "changes": "no_baseline", + "changes_state": { + "reasons": [], + "state": "no_baseline" + }, + "check_report_version": 1, + "complete": true, + "counts": { + "departed_with_access": 1, + "incomplete": 0, + "missing_access": 2, + "policy_finding": 0, + "provider_failed": 0, + "unexplained_access": 2 + }, + "departed": [ + { + "access_paths": 1, + "account": "102", + "instance": "demo", + "login": "former-dev", + "reason": "inactive_identity" + } + ], + "departed_assessed": true, + "files": { + "report": "out/check.json", + "snapshot": "out/snapshot.json", + "summary": "out/summary.md" + }, + "findings": [], + "notifications": [], + "policy": { + "rules": [ + "inactive_access", + "external_privileged", + "machine_owner" + ] + }, + "profiles": { + "complete": true, + "counts": { + "ambiguous_accounts": 0, + "cannot_tell": 0, + "covered": 2, + "excepted": 0, + "missing": 2, + "not_covered": 2, + "unexplained": 2, + "unresolved_members": 0 + }, + "enforce": false, + "identities": [ + { + "accounts": [ + { + "account": "100", + "instance": "demo" + } + ], + "explained": 1, + "identity": "alice@example.com", + "missing": [], + "profiles": [ + "base" + ], + "state": "compared", + "unexplained": [ + { + "account": "100", + "certainty": "derived", + "grant_id": "grant-1", + "instance": "demo", + "resource": "repo-20", + "resource_name": "acme/payments-api", + "role": "Write", + "state": "finding", + "via": [ + "team-10" + ] + } + ] + }, + { + "accounts": [ + { + "account": "101", + "instance": "demo" + } + ], + "explained": 0, + "identity": "bob@example.com", + "missing": [ + { + "entry": { + "group": "team-10", + "instance": "demo", + "kind": "group" + }, + "reason": "not_observed", + "state": "finding" + }, + { + "entry": { + "instance": "demo", + "kind": "role", + "resource": "repo-21", + "role": "Read" + }, + "reason": "not_observed", + "state": "finding" + } + ], + "profiles": [ + "backend" + ], + "state": "compared", + "unexplained": [ + { + "account": "101", + "certainty": "observed", + "grant_id": "grant-3", + "instance": "demo", + "resource": "repo-21", + "resource_name": "acme/infrastructure", + "role": "Admin", + "state": "finding", + "via": [] + } + ] + } + ], + "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "profile_review_version": 1, + "unresolved_assignments": [] + }, + "providers": [ + { + "id": "demo", + "limitations": [], + "state": "complete" + } + ], + "run_url": null, + "status": "findings" +} diff --git a/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.json b/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.json new file mode 100644 index 0000000..9dab1e8 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.json @@ -0,0 +1,74 @@ +{ + "check": { + "notifications": [ + { + "id": "slack", + "on": [ + "departed_with_access", + "new_admin" + ], + "type": "slack_webhook", + "url": "env://PERMESH_SLACK_WEBHOOK" + }, + { + "id": "teams", + "type": "teams_webhook", + "url": "env://PERMESH_TEAMS_WEBHOOK" + }, + { + "detail": true, + "id": "siem", + "type": "webhook", + "url": "keychain://permesh/siem-hook" + }, + { + "from": "Permesh ", + "id": "email", + "on": [ + "removed_admin", + "no_longer_observed", + "policy_finding", + "unexplained_access", + "missing_access", + "incomplete", + "provider_failed" + ], + "password": "env://PERMESH_SMTP_PASSWORD", + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "to": [ + "it@example.com" + ], + "type": "email", + "username": "permesh@example.com" + } + ] + }, + "identity": { + "aliases": {}, + "sources": [] + }, + "organization": { + "name": "Stable fixture" + }, + "profiles": { + "directory_groups": [ + { + "group": "team-10", + "instance": "demo", + "profiles": [ + "backend" + ] + } + ], + "enforce": true + }, + "providers": [ + { + "id": "demo", + "organizations": [], + "type": "demo" + } + ], + "version": 1 +} diff --git a/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.yaml b/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.yaml new file mode 100644 index 0000000..1491d1b --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/config-check-profiles.yaml @@ -0,0 +1,29 @@ +version: 1 +organization: {name: Stable fixture} +providers: [{id: demo, type: demo}] +check: + notifications: + - id: slack + type: slack_webhook + url: env://PERMESH_SLACK_WEBHOOK + on: [departed_with_access, new_admin] + - id: teams + type: teams_webhook + url: env://PERMESH_TEAMS_WEBHOOK + - id: siem + type: webhook + url: keychain://permesh/siem-hook + detail: true + - id: email + type: email + smtp_host: smtp.example.com + smtp_port: 587 + username: permesh@example.com + password: env://PERMESH_SMTP_PASSWORD + from: Permesh + to: [it@example.com] + on: [removed_admin, no_longer_observed, policy_finding, unexplained_access, missing_access, incomplete, provider_failed] +profiles: + enforce: true + directory_groups: + - {instance: demo, group: team-10, profiles: [backend]} diff --git a/crates/permesh-cli/tests/fixtures/stable/policy-v2.json b/crates/permesh-cli/tests/fixtures/stable/policy-v2.json new file mode 100644 index 0000000..01373c1 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/policy-v2.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "rules": ["inactive_access"], + "exceptions": [ + { + "id": "alice-team", + "rule": "unexplained_access", + "instance": "demo", + "account": "100", + "group": "team-10", + "reason": "Pairing on payments", + "owner": "it@example.com", + "expires_at": "2099-01-01T00:00:00Z" + } + ] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/profile-check.json b/crates/permesh-cli/tests/fixtures/stable/profile-check.json new file mode 100644 index 0000000..0204db3 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/profile-check.json @@ -0,0 +1,97 @@ +{ + "complete": true, + "counts": { + "ambiguous_accounts": 0, + "cannot_tell": 0, + "covered": 2, + "excepted": 0, + "missing": 2, + "not_covered": 2, + "unexplained": 2, + "unresolved_members": 0 + }, + "enforce": false, + "identities": [ + { + "accounts": [ + { + "account": "100", + "instance": "demo" + } + ], + "explained": 1, + "identity": "alice@example.com", + "missing": [], + "profiles": [ + "base" + ], + "state": "compared", + "unexplained": [ + { + "account": "100", + "certainty": "derived", + "grant_id": "grant-1", + "instance": "demo", + "resource": "repo-20", + "resource_name": "acme/payments-api", + "role": "Write", + "state": "finding", + "via": [ + "team-10" + ] + } + ] + }, + { + "accounts": [ + { + "account": "101", + "instance": "demo" + } + ], + "explained": 0, + "identity": "bob@example.com", + "missing": [ + { + "entry": { + "group": "team-10", + "instance": "demo", + "kind": "group" + }, + "reason": "not_observed", + "state": "finding" + }, + { + "entry": { + "instance": "demo", + "kind": "role", + "resource": "repo-21", + "role": "Read" + }, + "reason": "not_observed", + "state": "finding" + } + ], + "profiles": [ + "backend" + ], + "state": "compared", + "unexplained": [ + { + "account": "101", + "certainty": "observed", + "grant_id": "grant-3", + "instance": "demo", + "resource": "repo-21", + "resource_name": "acme/infrastructure", + "role": "Admin", + "state": "finding", + "via": [] + } + ] + } + ], + "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "profile_review_version": 1, + "unresolved_assignments": [] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/profile-suggest.json b/crates/permesh-cli/tests/fixtures/stable/profile-suggest.json new file mode 100644 index 0000000..5b935be --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/profile-suggest.json @@ -0,0 +1,32 @@ +{ + "draft": "", + "entries": [ + { + "count": 1, + "entry": { + "group": "team-10", + "instance": "demo", + "kind": "group" + }, + "label": "team/backend" + }, + { + "count": 1, + "entry": { + "instance": "demo", + "kind": "role", + "resource": "repo-21", + "role": "Read" + }, + "label": "acme/infrastructure" + } + ], + "group": { + "id": "team-10", + "instance": "demo", + "name": "team/backend" + }, + "members": 1, + "profile_suggestion_version": 1, + "unresolved_members": 0 +} diff --git a/crates/permesh-cli/tests/stable_formats.rs b/crates/permesh-cli/tests/stable_formats.rs new file mode 100644 index 0000000..f85eeea --- /dev/null +++ b/crates/permesh-cli/tests/stable_formats.rs @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +//! Golden fixtures for the formats frozen by the second stability declaration +//! (ADR 0036). See fixtures/stable/README.md before changing any fixture. +#![allow(clippy::unwrap_used)] +use serde_json::{Value, json}; +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +const BACKEND: &str = "profile: backend\ngrants:\n - {instance: demo, group: team-10}\n - {instance: demo, role: Read, resource: repo-21}\n"; +const BASE: &str = "profile: base\ngrants:\n - {instance: demo, role: Read, resource: repo-21}\n"; +const NORMALIZED: &str = ""; + +fn run(dir: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_permesh")) + .current_dir(dir) + .env("NO_COLOR", "1") + .env("PERMESH_DATA_DIR", dir.join("state")) + .args(args) + .output() + .unwrap() +} +fn json(output: &Output) -> Value { + serde_json::from_slice(&output.stdout).unwrap_or_else(|_| panic!("{output:?}")) +} +fn write(dir: &Path, name: &str, body: &str) { + let path = dir.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); +} +fn read(dir: &Path, name: &str) -> Value { + serde_json::from_slice(&fs::read(dir.join(name)).unwrap()).unwrap() +} +/// A demo workspace where bob holds `backend` and alice holds `base`. +fn workspace() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + assert!(run(dir.path(), &["init", "--demo"]).status.success()); + write(dir.path(), "profiles/backend.yaml", BACKEND); + write(dir.path(), "profiles/base.yaml", BASE); + write( + dir.path(), + "people/bob.yaml", + "identity: bob@example.com\nprofiles: [backend]\n", + ); + write( + dir.path(), + "people/alice.yaml", + "identity: alice@example.com\nprofiles: [base]\n", + ); + dir +} +fn stable(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/stable") + .join(name) +} + +/// Only run-specific values change between runs: times, durations, digests over them and +/// absolute paths. Everything else, including order and nulls, is compared exactly. +fn normalize(root: &Path, value: &mut Value) { + let root = root.canonicalize().unwrap(); + let roots = [root.to_string_lossy().into_owned()]; + fn walk(roots: &[String], key: Option<&str>, value: &mut Value) { + match value { + Value::Object(map) => { + for (key, value) in map.iter_mut() { + walk(roots, Some(key), value); + } + } + Value::Array(items) => items.iter_mut().for_each(|item| walk(roots, key, item)), + Value::String(text) => { + let run_specific = key.is_some_and(|key| { + key.ends_with("_at") || key.ends_with("sha256") || key.ends_with("digest") + }); + if run_specific || roots.iter().any(|root| text.starts_with(root.as_str())) { + *value = json!(NORMALIZED); + } + } + Value::Number(_) if key.is_some_and(|key| key.ends_with("_ms")) => { + *value = json!(NORMALIZED); + } + _ => {} + } + } + walk(&roots, None, value); +} + +/// Compares without writing; `Err` carries the reason. +fn compare(name: &str, value: &Value) -> Result<(), String> { + let expected: Value = serde_json::from_slice( + &fs::read(stable(name)).map_err(|_| format!("missing fixture {name}"))?, + ) + .map_err(|_| format!("fixture {name} is not JSON"))?; + if &expected == value { + Ok(()) + } else { + Err(format!( + "{name} differs from the frozen format; a change needs a version bump and migration (fixtures/stable/README.md).\nactual: {}", + serde_json::to_string_pretty(value).unwrap() + )) + } +} +/// `PERMESH_BLESS=1` writes the fixture instead; only for an intended, reviewed change. +fn assert_fixture(name: &str, value: &Value) { + if std::env::var_os("PERMESH_BLESS").is_some() { + let mut text = serde_json::to_string_pretty(value).unwrap(); + text.push('\n'); + fs::write(stable(name), text).unwrap(); + return; + } + if let Err(message) = compare(name, value) { + panic!("{message}"); + } +} +fn normalized(root: &Path, mut value: Value) -> Value { + normalize(root, &mut value); + value +} + +#[test] +fn check_report_fixture() { + let dir = workspace(); + let output = run(dir.path(), &["check", "--out", "out", "--no-notify"]); + assert_eq!(output.status.code(), Some(6), "{output:?}"); + let report = read(dir.path(), "out/check.json"); + assert_eq!(report["complete"], true); + assert_fixture("check-report.json", &normalized(dir.path(), report)); +} + +#[test] +fn check_report_incomplete_fixture() { + let dir = workspace(); + let config = fs::read_to_string(dir.path().join("permesh.yaml")).unwrap(); + let config = config.replace( + "aliases: {}", + "aliases:\n bob@example.com:\n demo: ['100']", + ); + fs::write(dir.path().join("permesh.yaml"), config).unwrap(); + let output = run(dir.path(), &["check", "--out", "out", "--no-notify"]); + assert_eq!(output.status.code(), Some(4), "{output:?}"); + let report = read(dir.path(), "out/check.json"); + assert_eq!(report["complete"], false); + assert_fixture( + "check-report-incomplete.json", + &normalized(dir.path(), report), + ); +} + +#[test] +fn profile_check_fixture() { + let dir = workspace(); + let report = json(&run(dir.path(), &["profile", "check", "--json"])); + assert_eq!(report["command"], "profile_check"); + assert_fixture( + "profile-check.json", + &normalized(dir.path(), report["result"].clone()), + ); +} + +#[test] +fn profile_suggest_fixture() { + let dir = workspace(); + let report = json(&run( + dir.path(), + &[ + "profile", + "suggest", + "--group", + "demo:team-10", + "--name", + "team", + "--json", + ], + )); + assert_eq!(report["command"], "profile_suggest"); + let mut result = report["result"].clone(); + // The draft is review text (human output, not frozen); it must stay a loadable profile. + let draft = result["draft"].as_str().unwrap().to_owned(); + write(dir.path(), "profiles/team.yaml", &draft); + assert_ne!( + run(dir.path(), &["profile", "check"]).status.code(), + Some(2) + ); + result["draft"] = json!(NORMALIZED); + assert_fixture("profile-suggest.json", &normalized(dir.path(), result)); +} + +#[test] +fn config_with_every_new_key_parses() { + let dir = tempfile::tempdir().unwrap(); + fs::copy( + stable("config-check-profiles.yaml"), + dir.path().join("permesh.yaml"), + ) + .unwrap(); + write(dir.path(), "profiles/backend.yaml", BACKEND); + let config = permesh_config::Config::load(&dir.path().join("permesh.yaml")).unwrap(); + let value = serde_json::to_value(&config).unwrap(); + assert_fixture("config-check-profiles.json", &value); +} + +#[test] +fn policy_v2_fixture_is_applied() { + let dir = workspace(); + fs::copy(stable("policy-v2.json"), dir.path().join("policy.json")).unwrap(); + let report = json(&run( + dir.path(), + &["profile", "check", "--policy", "policy.json", "--json"], + )); + assert_eq!(report["result"]["counts"]["excepted"], 1, "{report}"); +} + +#[test] +fn fixture_comparison_detects_renamed_field() { + let mut value: Value = + serde_json::from_slice(&fs::read(stable("profile-check.json")).unwrap()).unwrap(); + assert!(compare("profile-check.json", &value).is_ok()); + let counts = value["counts"].as_object_mut().unwrap(); + let covered = counts.remove("covered").unwrap(); + counts.insert("covered_count".into(), covered); + assert!(compare("profile-check.json", &value).is_err()); +} From 8fb932014f0643a7e0a8c3e409b3497782fb597b Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:10:39 +0200 Subject: [PATCH 3/6] feat(access): versioned review index; golden fixtures freeze access plan formats --- crates/permesh-cli/src/access/mod.rs | 3 +- crates/permesh-cli/src/access/review.rs | 27 ++- crates/permesh-cli/tests/access.rs | 47 ++++- .../stable/access-execution-plan.json | 70 +++++++ .../fixtures/stable/access-plan-join.json | 70 +++++++ .../fixtures/stable/access-plan-leave.json | 91 +++++++++ .../fixtures/stable/access-plan-result.json | 83 ++++++++ .../fixtures/stable/access-review-index.json | 12 ++ .../fixtures/stable/access-review-result.json | 20 ++ .../stable/access-verify-partial.json | 51 +++++ .../tests/fixtures/stable/access-verify.json | 51 +++++ crates/permesh-cli/tests/stable_formats.rs | 193 +++++++++++++++++- docs/output-schema.md | 5 +- 13 files changed, 712 insertions(+), 11 deletions(-) create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-execution-plan.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-plan-join.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-plan-leave.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-plan-result.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-review-index.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-review-result.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-verify-partial.json create mode 100644 crates/permesh-cli/tests/fixtures/stable/access-verify.json diff --git a/crates/permesh-cli/src/access/mod.rs b/crates/permesh-cli/src/access/mod.rs index d7f12cc..d728f55 100644 --- a/crates/permesh-cli/src/access/mod.rs +++ b/crates/permesh-cli/src/access/mod.rs @@ -497,7 +497,8 @@ async fn review( let rows: Vec<&review::IndexRow> = regenerated.plans.iter().map(|(r, _)| r).collect(); write( review::INDEX, - &serde_json::to_vec_pretty(&rows).map_err(|_| model::invalid())?, + &serde_json::to_vec_pretty(&review::index_value(&rows)) + .map_err(|_| model::invalid())?, )?; let summary = review::summary( ®enerated.plans, diff --git a/crates/permesh-cli/src/access/review.rs b/crates/permesh-cli/src/access/review.rs index 2badb58..72a96d3 100644 --- a/crates/permesh-cli/src/access/review.rs +++ b/crates/permesh-cli/src/access/review.rs @@ -24,6 +24,31 @@ use std::{ pub(crate) const INDEX: &str = "index.json"; pub(crate) const SUMMARY: &str = "summary.md"; +/// `index.json` format: `{"access_review_index_version": 1, "plans": [IndexRow…]}`. +pub(crate) const INDEX_VERSION: u32 = 1; + +/// The index document as written; rows stay in plan order. +pub(crate) fn index_value(rows: &[&IndexRow]) -> Value { + json!({"access_review_index_version": INDEX_VERSION, "plans": rows}) +} +fn index_rows(index: Value) -> Result, AppError> { + let unsupported = || { + AppError::input( + "Unsupported index.json format; regenerate the reviewed plans with this Permesh version", + ) + }; + let Value::Object(mut index) = index else { + return Err(unsupported()); + }; + if index.remove("access_review_index_version") != Some(json!(INDEX_VERSION)) { + return Err(unsupported()); + } + let plans = index.remove("plans").ok_or_else(unsupported)?; + if !index.is_empty() { + return Err(unsupported()); + } + serde_json::from_value(plans).map_err(|_| unsupported()) +} #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -184,7 +209,7 @@ fn valid_file(name: &str) -> bool { /// The reviewed plans by (identity, kind), checked against their index. pub(crate) fn load_reviewed(dir: &Path) -> Result { let mismatch = || AppError::input("Reviewed plans do not match their index.json"); - let rows: Vec = artifact::load_document(&dir.join(INDEX))?; + let rows = index_rows(artifact::load_document(&dir.join(INDEX))?)?; let mut reviewed = BTreeMap::new(); for row in rows { if !valid_file(&row.file) { diff --git a/crates/permesh-cli/tests/access.rs b/crates/permesh-cli/tests/access.rs index 0664561..7b55235 100644 --- a/crates/permesh-cli/tests/access.rs +++ b/crates/permesh-cli/tests/access.rs @@ -781,8 +781,8 @@ fn review_plans_changed_people_only() { let plan = saved(head.path(), "plans/000.json"); assert_eq!(plan["identity"], "bob@example.com"); let index = saved(head.path(), "plans/index.json"); - assert_eq!(index[0]["file"], "000.json"); - assert_eq!(index[0]["steps"], 2); + assert_eq!(index["plans"][0]["file"], "000.json"); + assert_eq!(index["plans"][0]["steps"], 2); let summary = fs::read_to_string(head.path().join("plans/summary.md")).unwrap(); assert!(summary.starts_with("# Permesh access plan"), "{summary}"); assert!( @@ -944,7 +944,7 @@ fn reviewed_index_cannot_name_files_outside_its_directory() { let (code, _) = review(head.path(), base.path(), "r1", &[]); assert_eq!(code, Some(0)); let mut index = saved(head.path(), "r1/index.json"); - index[0]["file"] = "../permesh.yaml".into(); + index["plans"][0]["file"] = "../permesh.yaml".into(); save(head.path(), "r1/index.json", &index); let (code, _) = review(head.path(), base.path(), "r2", &["--reviewed", "r1"]); assert_eq!(code, Some(2)); @@ -1032,3 +1032,44 @@ fn deleting_a_departed_persons_file_is_no_change() { assert_eq!(code, Some(0), "{result}"); assert!(plans(&result).is_empty(), "{result}"); } + +#[test] +fn review_index_has_version() { + let head = demo(&[ + ("profiles/backend.yaml", BACKEND), + ("people/bob.yaml", &person("bob@example.com", "backend")), + ]); + let base = base_checkout(head.path(), &[("profiles/backend.yaml", BACKEND)]); + let (code, _) = review(head.path(), base.path(), "plans", &[]); + assert_eq!(code, Some(0)); + let index = saved(head.path(), "plans/index.json"); + assert_eq!(index["access_review_index_version"], 1, "{index}"); + assert_eq!(index["plans"][0]["identity"], "bob@example.com"); +} + +#[test] +fn bare_array_index_is_refused() { + let head = demo(&[ + ("profiles/backend.yaml", BACKEND), + ("people/bob.yaml", &person("bob@example.com", "backend")), + ]); + snapshot(head.path()); + let base = base_checkout(head.path(), &[("profiles/backend.yaml", BACKEND)]); + let (code, _) = review(head.path(), base.path(), "r1", &["--snapshot", "s.json"]); + assert_eq!(code, Some(0)); + let index = saved(head.path(), "r1/index.json"); + let rows = index.get("plans").cloned().unwrap_or(index); + save(head.path(), "r1/index.json", &rows); + let (code, result) = review( + head.path(), + base.path(), + "r2", + &["--snapshot", "s.json", "--reviewed", "r1"], + ); + assert_eq!(code, Some(2), "{result}"); + assert_eq!( + result["error"]["message"], + "Unsupported index.json format; regenerate the reviewed plans with this Permesh version" + ); + assert!(!head.path().join("r2/000.execution.json").exists()); +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-execution-plan.json b/crates/permesh-cli/tests/fixtures/stable/access-execution-plan.json new file mode 100644 index 0000000..f9edbee --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-execution-plan.json @@ -0,0 +1,70 @@ +{ + "accounts": [ + { + "id": "101", + "provider": "demo" + } + ], + "complete": true, + "created_at": "", + "expires_at": "", + "format": "permesh_access_plan", + "format_version": 1, + "identity": "bob@example.com", + "identity_authorities": [ + "demo" + ], + "identity_bindings": [], + "identity_context_sha256": "", + "kept": [], + "kind": "join", + "max_age_hours": 24, + "mode": "snapshot_replay", + "profiles_after": [ + "backend" + ], + "profiles_removed": [], + "profiles_sha256": "", + "reviewed_plan_sha256": "", + "source_snapshot_sha256": "", + "state": "compared", + "steps": [ + { + "evidence": [], + "id": "4d3f0716c4f6bfc72ede969a5bbc4113d3e269113be2a16be07977eb2be2b753", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "group": "team-10", + "instance": "demo", + "type": "add_membership" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_membership" + }, + { + "evidence": [], + "id": "1dddccdef53956e1f92f7f63edbc2ebf3784c5a985bea0d81caf4d083ae2bbb3", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "add_role" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_role" + } + ] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-plan-join.json b/crates/permesh-cli/tests/fixtures/stable/access-plan-join.json new file mode 100644 index 0000000..feab6fe --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-plan-join.json @@ -0,0 +1,70 @@ +{ + "accounts": [ + { + "id": "101", + "provider": "demo" + } + ], + "complete": true, + "created_at": "", + "expires_at": "", + "format": "permesh_access_plan", + "format_version": 1, + "identity": "bob@example.com", + "identity_authorities": [ + "demo" + ], + "identity_bindings": [], + "identity_context_sha256": "", + "kept": [], + "kind": "join", + "max_age_hours": 24, + "mode": "snapshot_replay", + "profiles_after": [ + "backend" + ], + "profiles_removed": [], + "profiles_sha256": "", + "reviewed_plan_sha256": null, + "source_snapshot_sha256": "", + "state": "compared", + "steps": [ + { + "evidence": [], + "id": "4d3f0716c4f6bfc72ede969a5bbc4113d3e269113be2a16be07977eb2be2b753", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "group": "team-10", + "instance": "demo", + "type": "add_membership" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_membership" + }, + { + "evidence": [], + "id": "1dddccdef53956e1f92f7f63edbc2ebf3784c5a985bea0d81caf4d083ae2bbb3", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "add_role" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_role" + } + ] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-plan-leave.json b/crates/permesh-cli/tests/fixtures/stable/access-plan-leave.json new file mode 100644 index 0000000..a5274fc --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-plan-leave.json @@ -0,0 +1,91 @@ +{ + "accounts": [ + { + "id": "100", + "provider": "demo" + } + ], + "complete": true, + "created_at": "", + "expires_at": "", + "format": "permesh_access_plan", + "format_version": 1, + "identity": "alice@example.com", + "identity_authorities": [ + "demo" + ], + "identity_bindings": [], + "identity_context_sha256": "", + "kept": [], + "kind": "leave", + "max_age_hours": 24, + "mode": "snapshot_replay", + "profiles_after": [], + "profiles_removed": [ + "base" + ], + "profiles_sha256": "", + "reviewed_plan_sha256": null, + "source_snapshot_sha256": "", + "state": "compared", + "steps": [ + { + "evidence": [ + "grant-1" + ], + "id": "327a3c3d2f81a4b3cd3e8d062159c89e8902d61ab0ffbe1933d82f783c509679", + "justification": "unexplained", + "operation": { + "account": "100", + "group": "team-10", + "instance": "demo", + "type": "remove_membership" + }, + "prerequisites": [], + "reason": "observed", + "state": "planned", + "verification": "observe_absence" + }, + { + "evidence": [ + "grant-2" + ], + "id": "8bf5091b30c0a6c56d35f1dce6da9a5c77d952cc98bcae687f5d75cf6b4e52ee", + "justification": { + "profiles": [ + "base" + ] + }, + "operation": { + "account": "100", + "grant_id": "grant-2", + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "remove_role" + }, + "prerequisites": [], + "reason": "observed", + "state": "planned", + "verification": "observe_absence" + }, + { + "evidence": [], + "id": "4306dbc3bb6731de183889c9ea725ddbcd17a77e3243b44307a7636ef999f0f0", + "justification": { + "profiles": [ + "base" + ] + }, + "operation": { + "account": "100", + "instance": "demo", + "type": "review_account_lifecycle" + }, + "prerequisites": [], + "reason": "manual_review", + "state": "planned", + "verification": "manual_review" + } + ] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-plan-result.json b/crates/permesh-cli/tests/fixtures/stable/access-plan-result.json new file mode 100644 index 0000000..3494e81 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-plan-result.json @@ -0,0 +1,83 @@ +{ + "access_plan": { + "accounts": [ + { + "id": "101", + "provider": "demo" + } + ], + "complete": true, + "created_at": "", + "expires_at": "", + "format": "permesh_access_plan", + "format_version": 1, + "identity": "bob@example.com", + "identity_authorities": [ + "demo" + ], + "identity_bindings": [], + "identity_context_sha256": "", + "kept": [], + "kind": "join", + "max_age_hours": 24, + "mode": "snapshot_replay", + "profiles_after": [ + "backend" + ], + "profiles_removed": [], + "profiles_sha256": "", + "reviewed_plan_sha256": null, + "source_snapshot_sha256": "", + "state": "compared", + "steps": [ + { + "evidence": [], + "id": "4d3f0716c4f6bfc72ede969a5bbc4113d3e269113be2a16be07977eb2be2b753", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "group": "team-10", + "instance": "demo", + "type": "add_membership" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_membership" + }, + { + "evidence": [], + "id": "1dddccdef53956e1f92f7f63edbc2ebf3784c5a985bea0d81caf4d083ae2bbb3", + "justification": { + "profiles": [ + "backend" + ] + }, + "operation": { + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "add_role" + }, + "prerequisites": [], + "reason": "not_observed", + "state": "planned", + "verification": "observe_role" + } + ] + }, + "counts": { + "account_needed": 0, + "cannot_tell": 0, + "kept": 0, + "steps": 2 + }, + "files": { + "html": null, + "plan": "p.json" + }, + "plan_sha256": "" +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-review-index.json b/crates/permesh-cli/tests/fixtures/stable/access-review-index.json new file mode 100644 index 0000000..28b2a56 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-review-index.json @@ -0,0 +1,12 @@ +{ + "access_review_index_version": 1, + "plans": [ + { + "file": "000.json", + "identity": "bob@example.com", + "kind": "join", + "plan_sha256": "", + "steps": 2 + } + ] +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-review-result.json b/crates/permesh-cli/tests/fixtures/stable/access-review-result.json new file mode 100644 index 0000000..07a0104 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-review-result.json @@ -0,0 +1,20 @@ +{ + "access_review_version": 1, + "complete": true, + "executions": [], + "files": { + "out": "r1" + }, + "plans": [ + { + "file": "000.json", + "identity": "bob@example.com", + "kind": "join", + "plan_sha256": "", + "steps": 2 + } + ], + "profiles_changed": false, + "rereview": [], + "summary": "# Permesh access plan\n\nPeople affected: 1 (join 1, move 0, leave 0)\nSteps: 2 (add membership 1, add role 1, remove membership 0, remove role 0, account needed 0, review 0)\nCannot tell: 0\n" +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-verify-partial.json b/crates/permesh-cli/tests/fixtures/stable/access-verify-partial.json new file mode 100644 index 0000000..afc6377 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-verify-partial.json @@ -0,0 +1,51 @@ +{ + "access_verification_version": 1, + "checks": [ + { + "operation": { + "group": "team-10", + "instance": "demo", + "type": "add_membership" + }, + "reason": "instance_not_observable", + "state": "cannot_verify", + "step": "4d3f0716c4f6bfc72ede969a5bbc4113d3e269113be2a16be07977eb2be2b753" + }, + { + "operation": { + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "add_role" + }, + "reason": "instance_not_observable", + "state": "cannot_verify", + "step": "1dddccdef53956e1f92f7f63edbc2ebf3784c5a985bea0d81caf4d083ae2bbb3" + } + ], + "complete": false, + "counts": { + "cannot_verify": 2, + "done": 0, + "extra": 1, + "manual_review": 0, + "not_done": 0 + }, + "extra": [ + { + "account": "101", + "certainty": "observed", + "grant_id": "grant-3", + "instance": "demo", + "resource": "repo-21", + "resource_name": "acme/infrastructure", + "role": "Admin", + "state": "finding", + "via": [] + } + ], + "gaps": [], + "identity": "bob@example.com", + "kind": "join", + "plan_sha256": "" +} diff --git a/crates/permesh-cli/tests/fixtures/stable/access-verify.json b/crates/permesh-cli/tests/fixtures/stable/access-verify.json new file mode 100644 index 0000000..6343c16 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/access-verify.json @@ -0,0 +1,51 @@ +{ + "access_verification_version": 1, + "checks": [ + { + "operation": { + "group": "team-10", + "instance": "demo", + "type": "add_membership" + }, + "reason": "not_observed", + "state": "not_done", + "step": "4d3f0716c4f6bfc72ede969a5bbc4113d3e269113be2a16be07977eb2be2b753" + }, + { + "operation": { + "instance": "demo", + "resource": "repo-21", + "role": "Read", + "type": "add_role" + }, + "reason": "not_observed", + "state": "not_done", + "step": "1dddccdef53956e1f92f7f63edbc2ebf3784c5a985bea0d81caf4d083ae2bbb3" + } + ], + "complete": true, + "counts": { + "cannot_verify": 0, + "done": 0, + "extra": 1, + "manual_review": 0, + "not_done": 2 + }, + "extra": [ + { + "account": "101", + "certainty": "observed", + "grant_id": "grant-3", + "instance": "demo", + "resource": "repo-21", + "resource_name": "acme/infrastructure", + "role": "Admin", + "state": "finding", + "via": [] + } + ], + "gaps": [], + "identity": "bob@example.com", + "kind": "join", + "plan_sha256": "" +} diff --git a/crates/permesh-cli/tests/stable_formats.rs b/crates/permesh-cli/tests/stable_formats.rs index f85eeea..41fef4c 100644 --- a/crates/permesh-cli/tests/stable_formats.rs +++ b/crates/permesh-cli/tests/stable_formats.rs @@ -60,8 +60,12 @@ fn stable(name: &str) -> PathBuf { /// Only run-specific values change between runs: times, durations, digests over them and /// absolute paths. Everything else, including order and nulls, is compared exactly. fn normalize(root: &Path, value: &mut Value) { - let root = root.canonicalize().unwrap(); - let roots = [root.to_string_lossy().into_owned()]; + // Any temporary directory (workspace, base checkout) is run-specific. + let roots: Vec = [root.to_path_buf(), std::env::temp_dir()] + .iter() + .flat_map(|path| [path.clone(), path.canonicalize().unwrap()]) + .map(|path| path.to_string_lossy().into_owned()) + .collect(); fn walk(roots: &[String], key: Option<&str>, value: &mut Value) { match value { Value::Object(map) => { @@ -71,9 +75,26 @@ fn normalize(root: &Path, value: &mut Value) { } Value::Array(items) => items.iter_mut().for_each(|item| walk(roots, key, item)), Value::String(text) => { - let run_specific = key.is_some_and(|key| { - key.ends_with("_at") || key.ends_with("sha256") || key.ends_with("digest") - }); + let time = key.is_some_and(|key| key.ends_with("_at")); + let digest = + key.is_some_and(|key| key.ends_with("sha256") || key.ends_with("digest")); + // Shapes stay frozen even where values are normalized. + if time { + assert!( + text.len() >= 20 && text.as_bytes()[10] == b'T' && text.ends_with('Z'), + "{key:?} is not an RFC 3339 UTC time: {text}" + ); + } + if digest { + assert!( + text.len() == 64 + && text + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()), + "{key:?} is not a lowercase SHA-256 hex digest: {text}" + ); + } + let run_specific = time || digest; if run_specific || roots.iter().any(|root| text.starts_with(root.as_str())) { *value = json!(NORMALIZED); } @@ -222,3 +243,165 @@ fn fixture_comparison_detects_renamed_field() { counts.insert("covered_count".into(), covered); assert!(compare("profile-check.json", &value).is_err()); } + +fn snapshot(dir: &Path) { + let output = run(dir, &["snapshot", "create", "--output", "s.json"]); + assert!(output.status.success(), "{output:?}"); +} +fn access(dir: &Path, args: &[&str]) -> (Option, Value) { + let mut all = vec!["access"]; + all.extend_from_slice(args); + all.push("--json"); + let output = run(dir, &all); + (output.status.code(), json(&output)) +} + +#[test] +fn access_plan_join_fixture() { + let dir = workspace(); + snapshot(dir.path()); + let (code, report) = access( + dir.path(), + &[ + "join", + "bob@example.com", + "--profile", + "backend", + "--snapshot", + "s.json", + "--output", + "p.json", + ], + ); + assert_eq!(code, Some(0), "{report}"); + assert_fixture( + "access-plan-join.json", + &normalized(dir.path(), read(dir.path(), "p.json")), + ); + assert_fixture( + "access-plan-result.json", + &normalized(dir.path(), report["result"].clone()), + ); +} + +#[test] +fn access_plan_leave_fixture() { + let dir = workspace(); + snapshot(dir.path()); + let (code, report) = access( + dir.path(), + &[ + "leave", + "alice@example.com", + "--snapshot", + "s.json", + "--output", + "p.json", + ], + ); + assert_eq!(code, Some(0), "{report}"); + assert_fixture( + "access-plan-leave.json", + &normalized(dir.path(), read(dir.path(), "p.json")), + ); +} + +fn joined_and_verified(partial: bool) -> (Option, Value, tempfile::TempDir) { + let dir = workspace(); + snapshot(dir.path()); + let (code, _) = access( + dir.path(), + &[ + "join", + "bob@example.com", + "--profile", + "backend", + "--snapshot", + "s.json", + "--output", + "p.json", + ], + ); + assert_eq!(code, Some(0)); + if partial { + let mut s = read(dir.path(), "s.json"); + let limitations = json!(["Fixture visibility incomplete"]); + s["providers"][0]["state"] = "partial".into(); + s["providers"][0]["limitations"] = limitations.clone(); + s["providers"][0]["data"]["complete"] = false.into(); + s["providers"][0]["data"]["limitations"] = limitations; + fs::write(dir.path().join("s.json"), s.to_string()).unwrap(); + } + let (code, report) = access(dir.path(), &["verify", "p.json", "--snapshot", "s.json"]); + (code, report, dir) +} + +#[test] +fn access_verify_fixture() { + let (code, report, dir) = joined_and_verified(false); + assert_eq!(code, Some(6), "{report}"); + assert_fixture( + "access-verify.json", + &normalized(dir.path(), report["result"].clone()), + ); +} + +#[test] +fn access_verify_partial_fixture() { + let (code, report, dir) = joined_and_verified(true); + assert_eq!(code, Some(4), "{report}"); + assert_eq!(report["complete"], false); + assert_fixture( + "access-verify-partial.json", + &normalized(dir.path(), report["result"].clone()), + ); +} + +#[test] +fn access_review_and_execution_fixtures() { + let head = workspace(); + snapshot(head.path()); + let base = tempfile::tempdir().unwrap(); + fs::copy( + head.path().join("permesh.yaml"), + base.path().join("permesh.yaml"), + ) + .unwrap(); + write(base.path(), "profiles/backend.yaml", BACKEND); + write(base.path(), "profiles/base.yaml", BASE); + write( + base.path(), + "people/alice.yaml", + "identity: alice@example.com\nprofiles: [base]\n", + ); + let base_dir = base.path().display().to_string(); + let review = |out: &str, extra: &[&str]| { + let mut args = vec![ + "review", + "--base", + base_dir.as_str(), + "--out", + out, + "--snapshot", + "s.json", + ]; + args.extend_from_slice(extra); + access(head.path(), &args) + }; + let (code, report) = review("r1", &[]); + assert_eq!(code, Some(0), "{report}"); + assert_fixture( + "access-review-index.json", + &normalized(head.path(), read(head.path(), "r1/index.json")), + ); + assert_fixture( + "access-review-result.json", + &normalized(head.path(), report["result"].clone()), + ); + let (code, report) = review("r2", &["--reviewed", "r1"]); + assert_eq!(code, Some(0), "{report}"); + assert_fixture( + "access-execution-plan.json", + &normalized(head.path(), read(head.path(), "r2/000.execution.json")), + ); +} diff --git a/docs/output-schema.md b/docs/output-schema.md index fcac7f3..3e67093 100644 --- a/docs/output-schema.md +++ b/docs/output-schema.md @@ -298,6 +298,9 @@ may contain `identity_context_changed`, `plan_incomplete`, done, or `extra` after a leave) > 0; an invalid plan is 2 before any capture. **`access_review`**: `{access_review_version: 1, plans: [{file, identity, kind, plan_sha256, steps}], executions, rereview: [{identity, kind, added_steps, removed_steps}], profiles_changed, complete, summary, files: {out}}`. -The output directory holds `NNN.json`, `index.json` (the `plans` rows), +The output directory holds `NNN.json`, `index.json` +(`{access_review_index_version: 1, plans: [rows]}`, the same `plans` rows), `summary.md` and, with `--reviewed`, `NNN.execution.json`. Exit 3 or 4 from collection or incomplete plans, else 6 when `rereview` is non-empty, else 0. +`--reviewed` accepts only `access_review_index_version: 1`; any other `index.json` +is an input error (exit 2) asking to regenerate the reviewed plans. From d98eee02a11755a06bb2bab9c405975668896f49 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:12:54 +0200 Subject: [PATCH 4/6] docs: second stability declaration, ADR 0036 --- CHANGELOG.md | 9 ++++ crates/permesh-cli/src/access/mod.rs | 2 +- crates/permesh-cli/src/check/mod.rs | 2 +- crates/permesh-cli/src/check/report.rs | 2 +- crates/permesh-cli/src/profile_command.rs | 2 +- .../tests/fixtures/stable/README.md | 3 +- crates/permesh-config/src/check.rs | 2 +- crates/permesh-config/src/lib.rs | 4 +- crates/permesh-config/src/profiles.rs | 2 +- docs/ROADMAP.md | 4 +- docs/access-plans.md | 5 +- docs/adr/0032-explicit-check-notifications.md | 2 +- docs/adr/0033-access-profiles.md | 2 +- docs/adr/0034-access-plans.md | 2 +- docs/adr/0036-second-stability-declaration.md | 47 +++++++++++++++++++ docs/check.md | 4 +- docs/notifications.md | 4 +- docs/output-schema.md | 5 +- docs/profiles.md | 5 +- docs/resource-policies.md | 2 +- docs/stability.md | 34 ++++++++++---- 21 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 docs/adr/0036-second-stability-declaration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e85247e..4e35a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl ## Unreleased +- Declare the formats added since the first stability declaration stable: the + `check` and `profiles` configuration, people and profile files, policy + `version: 2`, the check report, profile results, access plan files and the + `access` JSON results. Golden fixtures pin them. `permesh mcp`, starter templates + and human-readable output stay experimental + ([ADR 0036](docs/adr/0036-second-stability-declaration.md)). +- People and profile files accept an optional `version: 1`; any other value is + an error that says what to do. `access review` writes `index.json` as + `{"access_review_index_version": 1, "plans": [...]}`. - Add experimental `permesh check`: one unattended review run that writes a snapshot, a JSON report and a counts-only summary. It compares with a previous snapshot, including admin access gained through groups, and exits 6 when something needs diff --git a/crates/permesh-cli/src/access/mod.rs b/crates/permesh-cli/src/access/mod.rs index d728f55..8842540 100644 --- a/crates/permesh-cli/src/access/mod.rs +++ b/crates/permesh-cli/src/access/mod.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -//! Joiner, mover and leaver plans (experimental). Plans are data for a person to +//! Joiner, mover and leaver plans (stable formats, ADR 0036). Plans are data for a person to //! carry out, never commands; nothing here changes provider access. pub(crate) mod model; mod review; diff --git a/crates/permesh-cli/src/check/mod.rs b/crates/permesh-cli/src/check/mod.rs index 30e6db4..abb16d4 100644 --- a/crates/permesh-cli/src/check/mod.rs +++ b/crates/permesh-cli/src/check/mod.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -//! `permesh check`: one unattended review run (experimental report format). +//! `permesh check`: one unattended review run (stable report format, ADR 0036). //! Collect a snapshot, evaluate policy and departures, write files, then notify. pub mod notify; pub mod report; diff --git a/crates/permesh-cli/src/check/report.rs b/crates/permesh-cli/src/check/report.rs index bcde1d1..0c7e839 100644 --- a/crates/permesh-cli/src/check/report.rs +++ b/crates/permesh-cli/src/check/report.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -//! Check report version 1 (experimental). `summary.md` carries counts only. +//! Check report version 1 (stable, ADR 0036). `summary.md` carries counts only. use permesh_config::FindingKind; use serde::{Serialize, Serializer}; use serde_json::{Value, json}; diff --git a/crates/permesh-cli/src/profile_command.rs b/crates/permesh-cli/src/profile_command.rs index 37cf2f3..9567983 100644 --- a/crates/permesh-cli/src/profile_command.rs +++ b/crates/permesh-cli/src/profile_command.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -//! Declared access profiles against captured access (experimental report format). +//! Declared access profiles against captured access (stable report format, ADR 0036). use crate::{ args::Cli, artifact::{Artifact, State}, diff --git a/crates/permesh-cli/tests/fixtures/stable/README.md b/crates/permesh-cli/tests/fixtures/stable/README.md index a688150..3ba4e9e 100644 --- a/crates/permesh-cli/tests/fixtures/stable/README.md +++ b/crates/permesh-cli/tests/fixtures/stable/README.md @@ -1,7 +1,8 @@ # Stable format fixtures These files freeze the formats declared stable by the second stability -declaration ([stability](../../../../../docs/stability.md)). `tests/stable_formats.rs` builds each +declaration ([ADR 0036](../../../../../docs/adr/0036-second-stability-declaration.md), +[stability](../../../../../docs/stability.md)). `tests/stable_formats.rs` builds each format from the synthetic demo and compares it exactly: field names, types, enum spellings, array order and `null`s. diff --git a/crates/permesh-config/src/check.rs b/crates/permesh-config/src/check.rs index 56a2aad..004be7e 100644 --- a/crates/permesh-config/src/check.rs +++ b/crates/permesh-config/src/check.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT //! Optional `check` section: explicit notification destinations (ADR 0032). -//! Experimental format. Destinations are credential references, never inline values. +//! Stable format (ADR 0036). Destinations are credential references, never inline values. use crate::{Result, invalid, valid_id, valid_text}; use permesh_secrets::SecretRef; use serde::{Deserialize, Serialize}; diff --git a/crates/permesh-config/src/lib.rs b/crates/permesh-config/src/lib.rs index 7f2e913..9c83288 100644 --- a/crates/permesh-config/src/lib.rs +++ b/crates/permesh-config/src/lib.rs @@ -59,10 +59,10 @@ pub struct Config { pub providers: Vec, #[serde(default)] pub identity: IdentityConfig, - /// Experimental scheduled-check settings (ADR 0032). + /// Scheduled-check settings (ADR 0032; stable, ADR 0036). #[serde(default, skip_serializing_if = "Option::is_none")] pub check: Option, - /// Experimental access-profile settings (ADR 0033). + /// Access-profile settings (ADR 0033; stable, ADR 0036). #[serde(default, skip_serializing_if = "Option::is_none")] pub profiles: Option, } diff --git a/crates/permesh-config/src/profiles.rs b/crates/permesh-config/src/profiles.rs index bc2e274..7d4fe89 100644 --- a/crates/permesh-config/src/profiles.rs +++ b/crates/permesh-config/src/profiles.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT //! Access profiles: declared expected access (`profiles/*.yaml`), people //! assignments (`people/*.yaml`) and directory group mappings (ADR 0033). -//! Experimental format. Entries reference immutable provider IDs, never labels. +//! Stable format 1 (ADR 0036). Entries reference immutable provider IDs, never labels. use crate::{Config, Error, MAX_CONFIG_BYTES, Result, data, invalid, valid_id, valid_text}; use serde::{Deserialize, Serialize}; use std::{ diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a1b0863..da0fae0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -89,7 +89,9 @@ for validation evidence and remaining prerequisites. ## Release and adoption gates - The [stability declaration](stability.md) defines which surfaces are stable - for the beta line and which remain experimental. + for the beta line and which remain experimental. The second declaration + ([ADR 0036](adr/0036-second-stability-declaration.md)) froze the check, profile + and access plan formats; Stage 2 may now be designed. - The exact alpha.3 candidate passed signed-artifact verification, local provider package/setup/trust/approval/pin checks, and limited authorized GitHub queries; diff --git a/docs/access-plans.md b/docs/access-plans.md index a519526..a9bb932 100644 --- a/docs/access-plans.md +++ b/docs/access-plans.md @@ -3,8 +3,9 @@ When someone joins, moves to another project or leaves, `permesh access` turns the change in their [access profiles](profiles.md) into a plan file you can review. A person carries out each step in the provider; Permesh never changes access. Later, -`permesh access verify` checks what was done. Everything here is -[experimental](stability.md). +`permesh access verify` checks what was done. The plan +file and JSON results are [stable](stability.md); summaries and HTML views are for +people and may change wording. The examples use the demo workspace (`permesh init --demo`) with two profiles: diff --git a/docs/adr/0032-explicit-check-notifications.md b/docs/adr/0032-explicit-check-notifications.md index c251382..80513ed 100644 --- a/docs/adr/0032-explicit-check-notifications.md +++ b/docs/adr/0032-explicit-check-notifications.md @@ -1,6 +1,6 @@ # ADR 0032: Explicit check notifications -Status: accepted for the unreleased source candidate (experimental format) +Status: accepted for the unreleased source candidate; format stable since [ADR 0036](0036-second-stability-declaration.md) ## Decision diff --git a/docs/adr/0033-access-profiles.md b/docs/adr/0033-access-profiles.md index 2f29527..b3fa171 100644 --- a/docs/adr/0033-access-profiles.md +++ b/docs/adr/0033-access-profiles.md @@ -1,6 +1,6 @@ # ADR 0033: Access profiles -Status: accepted for the unreleased source candidate (experimental format) +Status: accepted for the unreleased source candidate; format stable since [ADR 0036](0036-second-stability-declaration.md) ## Decision diff --git a/docs/adr/0034-access-plans.md b/docs/adr/0034-access-plans.md index 395372a..d145c19 100644 --- a/docs/adr/0034-access-plans.md +++ b/docs/adr/0034-access-plans.md @@ -1,6 +1,6 @@ # ADR 0034: Joiner, mover and leaver access plans -Status: accepted for the unreleased source candidate (experimental format) +Status: accepted for the unreleased source candidate; format stable since [ADR 0036](0036-second-stability-declaration.md) ## Decision diff --git a/docs/adr/0036-second-stability-declaration.md b/docs/adr/0036-second-stability-declaration.md new file mode 100644 index 0000000..c5666a4 --- /dev/null +++ b/docs/adr/0036-second-stability-declaration.md @@ -0,0 +1,47 @@ +# ADR 0036: Second stability declaration + +Status: accepted for the unreleased source candidate + +## Decision + +The formats added after the first declaration (Phases 1–3 of the access +operations program) are stable. They change only with a version bump, a migration +guide and a release that still reads the previous version, as described in +[stability](../stability.md). Additive changes stay allowed. + +- **Stable now:** the `check` and `profiles` sections of `permesh.yaml`; + `profiles/` and `people/` files, including `status`; policy file `version: 2`; + check report version 1; `profile_check` and `profile_suggest` results version 1; + the `permesh_access_plan` version 1 file and execution plans; the + `access_plan`, `access_verify` and `access_review` results and `index.json` + version 1; and the command names `check`, `profile`, `access` and + `init --starter`. +- **Still experimental:** `permesh mcp` ([ADR 0035](0035-read-only-mcp-server.md)), + which is new and built on an MCP revision whose client support is still + arriving; the provider protocol and SDK; `provider dev`; and the starter + workflow templates, which belong to the user once written. +- **Human-readable output is not frozen:** terminal text, `summary.md`, HTML views, + notification wording and `profile suggest` draft comments. The counts-only and + "missing observation is not proof of absence" promises still hold. +- **Two version markers, added before freezing.** People and profile files accept + an optional `version: 1`; absent means 1, so hand-written files stay minimal. + Another value is an input error that says to remove the line or upgrade. + `index.json` becomes `{"access_review_index_version": 1, "plans": [...]}`. +- **Golden fixtures** in `crates/permesh-cli/tests/fixtures/stable/` pin every + frozen format. Only times, durations, digests and temporary paths are + normalized, and their shapes are still checked. + +## Why + +Small teams build CI jobs, scripts and reviews on these files and reports. A +format that can change silently makes every upgrade a risk. Freezing them now, +while there are no users, costs nothing, and the version markers make a later +migration detectable instead of guessed. Keeping people files free of required +boilerplate keeps them easy to write by hand. + +## Consequences + +Stage 2 (applying reviewed plans, [ADR 0028](0028-separate-opt-in-remediation.md)) +may now be designed; access plan version 1 is its input. Changing a frozen format +now needs a fixture update reviewed as a breaking change. Live tenant acceptance +is still tracked separately per provider and does not affect these formats. diff --git a/docs/check.md b/docs/check.md index 5993e8d..02ff9c7 100644 --- a/docs/check.md +++ b/docs/check.md @@ -1,8 +1,8 @@ # Scheduled checks `permesh check` runs one complete review and is designed to run unattended, for -example weekly in CI. It is an **experimental** command: its report format may -change before the next stability declaration ([stability](stability.md)). +example weekly in CI. Its JSON report is stable ([stability](stability.md)); `summary.md` is +human-readable and may change wording. ```bash permesh check --out out diff --git a/docs/notifications.md b/docs/notifications.md index 095a8dd..a50ecd0 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -1,8 +1,8 @@ # Check notifications `permesh check` can send its result to channels your team already uses. A red CI run -is always the baseline signal; notifications are optional and **experimental** -([ADR 0032](adr/0032-explicit-check-notifications.md)). +is always the baseline signal; notifications are optional. The configuration is stable; message wording may change +([ADR 0032](adr/0032-explicit-check-notifications.md), [stability](stability.md)). ```yaml check: diff --git a/docs/output-schema.md b/docs/output-schema.md index 3e67093..f90a522 100644 --- a/docs/output-schema.md +++ b/docs/output-schema.md @@ -78,7 +78,7 @@ When any authority is absent or partial, every observed account has reason `unas ## Other results -`check` (experimental): `result` is the check report version 1 described in [scheduled checks](check.md): `{check_report_version, status, complete, departed_assessed, counts, providers, changes, departed, findings, policy, notifications, run_url, files}`. `changes` is `"no_baseline"`, `{inconclusive: {reasons}}` or `{compared: {new_admins, removed_admins, no_longer_observed}}`. `profiles` is `"not_configured"` or the `profile_check` result below; `counts.unexplained_access` and `counts.missing_access` are present only when profiles were compared. `profile_check` (experimental): `{profile_review_version: 1, enforce, complete, counts: {covered, not_covered, unexplained, missing, cannot_tell, excepted, ambiguous_accounts, unresolved_members}, identities, unresolved_assignments, message}`. Each identity has `identity`, `state` (`compared`, `not_found` or `ambiguous`), `profiles`, `accounts`, `explained`, `missing` (`{state, entry, reason}`) and `unexplained` (`{state, instance, account, resource, resource_name, role, grant_id, via, certainty, exception?}`); row states are `finding`, `cannot_tell` or, for unexplained rows, `excepted`. `profile_suggest` (experimental): `{profile_suggestion_version: 1, group: {instance, id, name}, members, unresolved_members, entries: [{entry, label, count}], draft}`; human output is the bare `draft` YAML. See [access profiles](profiles.md). `provider_list`: `{providers: [{id,type,provider,live_tested}]}`; `provider` is the external provider ID (`null` for built-ins) and `live_tested` says whether an official provider with that ID has recorded [live tenant acceptance](live-acceptance.md) (`null` for built-ins). It is keyed by provider ID only and does not verify that the configured binary is the official package; use `provider external review` for that. `provider_capabilities`: `{id, metadata: {kind,capabilities}}`; capability strings enumerate supported read operations. `doctor`/`provider_status`: `{workspace_schema, organization, identity_sources, message, external_providers, local_overrides}`. `auth_status`: `{message}` plus provider status entries; availability does not mean remote authentication succeeded. `init`: `{message,file,next}`; `provider_add` for bundled/manual instances: `{message,next}`; login/logout: `{message}`. `version`: `{version,telemetry:false,backend:false}`. +`check`: `result` is the check report version 1 described in [scheduled checks](check.md): `{check_report_version, status, complete, departed_assessed, counts, providers, changes, departed, findings, policy, notifications, run_url, files}`. `changes` is `"no_baseline"`, `{inconclusive: {reasons}}` or `{compared: {new_admins, removed_admins, no_longer_observed}}`. `profiles` is `"not_configured"` or the `profile_check` result below; `counts.unexplained_access` and `counts.missing_access` are present only when profiles were compared. `profile_check`: `{profile_review_version: 1, enforce, complete, counts: {covered, not_covered, unexplained, missing, cannot_tell, excepted, ambiguous_accounts, unresolved_members}, identities, unresolved_assignments, message}`. Each identity has `identity`, `state` (`compared`, `not_found` or `ambiguous`), `profiles`, `accounts`, `explained`, `missing` (`{state, entry, reason}`) and `unexplained` (`{state, instance, account, resource, resource_name, role, grant_id, via, certainty, exception?}`); row states are `finding`, `cannot_tell` or, for unexplained rows, `excepted`. `profile_suggest`: `{profile_suggestion_version: 1, group: {instance, id, name}, members, unresolved_members, entries: [{entry, label, count}], draft}`; human output is the bare `draft` YAML. See [access profiles](profiles.md). `provider_list`: `{providers: [{id,type,provider,live_tested}]}`; `provider` is the external provider ID (`null` for built-ins) and `live_tested` says whether an official provider with that ID has recorded [live tenant acceptance](live-acceptance.md) (`null` for built-ins). It is keyed by provider ID only and does not verify that the configured binary is the official package; use `provider external review` for that. `provider_capabilities`: `{id, metadata: {kind,capabilities}}`; capability strings enumerate supported read operations. `doctor`/`provider_status`: `{workspace_schema, organization, identity_sources, message, external_providers, local_overrides}`. `auth_status`: `{message}` plus provider status entries; availability does not mean remote authentication succeeded. `init`: `{message,file,next}`; `provider_add` for bundled/manual instances: `{message,next}`; login/logout: `{message}`. `version`: `{version,telemetry:false,backend:false}`. ## Error envelope @@ -260,7 +260,8 @@ See [resource and local policy review](resource-policies.md) for declarative pol ## Access plans -Experimental; see [joiner, mover and leaver plans](access-plans.md) and +Stable ([ADR 0036](adr/0036-second-stability-declaration.md)); see +[joiner, mover and leaver plans](access-plans.md) and [ADR 0034](adr/0034-access-plans.md). The commands use the schema-1 envelope. **Plan file** `permesh_access_plan` version 1 (written by `access join`, `move`, diff --git a/docs/profiles.md b/docs/profiles.md index c1b4c7f..8ff0b85 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -7,8 +7,9 @@ compares profiles with captured access and shows two things per person: - **unexplained**: access no assigned profile explains, with the group route that grants it. -Profiles are **experimental** ([stability](stability.md), -[ADR 0033](adr/0033-access-profiles.md)). Permesh only reads and reports; it never +Profile and people files are stable ([stability](stability.md), +[ADR 0033](adr/0033-access-profiles.md)). You don't need a version line: a file may +start with `version: 1`, and leaving it out means the same. Permesh only reads and reports; it never grants or removes access. ## Try it with the demo diff --git a/docs/resource-policies.md b/docs/resource-policies.md index 47eb3b9..a4de389 100644 --- a/docs/resource-policies.md +++ b/docs/resource-policies.md @@ -78,7 +78,7 @@ A `pass` applies only to the declared check and observation scope. Unknown privi An exception matches exactly one access rule, provider instance, immutable account, immutable resource and source grant. There are no wildcards or label matches. It applies only before its expiry, only to an established finding, and never suppresses uncertainty. An accepted exception produces `excepted` with the original finding state retained. Expired exceptions do not apply. Coverage/expiry rules themselves cannot be excepted. The source grant and account evidence are unchanged. -Policy `version: 2` (experimental) adds the exception rule `unexplained_access` for [access profiles](profiles.md). It is valid only in `exceptions`, never in `rules`, and is used by `profile check` and `check`. Its scope is either `{instance, account, group}`, which accepts every path from that account through that group, or `{instance, account, resource, grant_id}`, which accepts one direct grant. Everything else, including expiry and `expired_exception`, behaves as in version 1, and version 1 documents are read and written unchanged. +Policy `version: 2` adds the exception rule `unexplained_access` for [access profiles](profiles.md). It is valid only in `exceptions`, never in `rules`, and is used by `profile check` and `check`. Its scope is either `{instance, account, group}`, which accepts every path from that account through that group, or `{instance, account, resource, grant_id}`, which accepts one direct grant. Everything else, including expiry and `expired_exception`, behaves as in version 1, and version 1 documents are read and written unchanged. Reports are deterministic for the same artifact, policy and review time, collapse duplicate paths to one account/grant check per rule, and enforce 100,000-check and 64 MiB encoded-check budgets. Expiry uses the current review time; the underlying saved access evidence remains historical. No recurring scheduler or general expression engine is included. diff --git a/docs/stability.md b/docs/stability.md index 69c5186..23ec8a6 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -20,6 +20,19 @@ previous version. | Policy file `version: 1` | [Policies](resource-policies.md) | `tests/resource_policy.rs` and policy unit tests | | Offboarding plan v1 | [Offboarding](offboarding.md) | `tests/offboarding.rs` | | Command names: `init`, `doctor`, `provider`, `auth`, `user`, `admins`, `orphaned`, `identity`, `resource`, `policy`, `snapshot`, `diff`, `offboard` | `permesh --help` | CLI integration tests | +| `permesh.yaml` `check` section (notifications) and `profiles` section | [Notifications](notifications.md), [access profiles](profiles.md) | `fixtures/stable/config-check-profiles.*` | +| `profiles/*.yaml` and `people/*.yaml` format 1, including `status: active \| left` and the optional `version: 1` | [Access profiles](profiles.md) | `permesh-config` profile tests | +| Policy file `version: 2` (`unexplained_access` exceptions) | [Policies](resource-policies.md) | `fixtures/stable/policy-v2.json` | +| `check` report version 1 (`check.json` and the `check` result) | [Scheduled checks](check.md) | `fixtures/stable/check-report*.json` | +| `profile_check` and `profile_suggest` results, version 1 | [Output schema](output-schema.md#other-results) | `fixtures/stable/profile-*.json` | +| Access plan file `permesh_access_plan` version 1, including execution plans | [Access plans](access-plans.md) | `fixtures/stable/access-plan-*.json`, `access-execution-plan.json` | +| `access_plan`, `access_verify` and `access_review` results, and `index.json` version 1 | [Output schema](output-schema.md#access-plans) | `fixtures/stable/access-*.json` | +| Command names `check`, `profile`, `access` and `init --starter` | `permesh --help` | CLI integration tests | + +Fixtures under `crates/permesh-cli/tests/fixtures/stable/` are checked by +`tests/stable_formats.rs`; see the README there. The second declaration +([ADR 0036](adr/0036-second-stability-declaration.md)) added the rows from +`permesh.yaml` `check` onward. Additive changes are allowed on stable surfaces: new optional configuration keys, new JSON fields, new commands and flags. Scripts should ignore JSON fields they @@ -32,17 +45,19 @@ These may change between releases without a migration guide. - Provider protocol v1 and the provider SDK (still draft; see [provider protocol](provider-protocol.md)). - `provider dev` commands. -- `permesh check`, its `check.json` report and `summary.md`, the `check` section of - `permesh.yaml` (notifications) and `init --starter`. -- [Access profiles](profiles.md): `profile check`, `profile suggest`, the - `profiles/` and `people/` files, the `profiles` section of `permesh.yaml`, the - profile counts in `check`, and policy file `version: 2` exceptions. -- [Joiner, mover and leaver plans](access-plans.md): the `access` command - (`join`, `move`, `leave`, `verify`, `review`), the `permesh_access_plan` v1 file, - people files' `status`, and the plan and verify starter workflows. A second - stability declaration will freeze these formats. - [AI assistants](mcp.md): `permesh mcp`, its tool names, arguments and result shape, and the supported MCP protocol version. +- Starter workflow templates written by `init --starter` (`.github/workflows/`, + `.gitlab-ci.yml`, `ci/`). They are copied once and then belong to you; new + releases may write different templates into new workspaces. + +## Human-readable output + +Terminal text, `summary.md`, HTML views, notification message text and the +`profile suggest` draft comments are for people and may change in any release. +Two promises hold: summaries and notifications carry counts only unless you ask +for `--detail` or `detail: true`, and nothing is reported as clean or removed when +a provider could not be fully read. Automate against the JSON instead. ## Provider maturity @@ -57,6 +72,7 @@ files. A change there is still a breaking change, but CI may not catch it automatically: - Policy file v1 and offboarding plan v1 have no golden fixture. +- People and profile files are pinned by parsing tests rather than a golden file. - Access JSON for `resource`, and control JSON for `init`, `auth`, `provider list`, `provider add` and `version`, have no golden fixture. - Command flags are not checked against a recorded `--help` listing. From b5f4492ae1ed675da44c1c8950bf206e99145460 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:22:37 +0200 Subject: [PATCH 5/6] fix: platform-neutral stable fixtures, human text unfrozen, webhook body pinned, clearer version errors --- CHANGELOG.md | 6 +- crates/permesh-cli/src/check/notify.rs | 19 +++++ crates/permesh-cli/src/error.rs | 7 ++ .../tests/fixtures/stable/README.md | 6 +- .../fixtures/stable/access-review-result.json | 2 +- .../stable/check-report-incomplete.json | 2 +- .../tests/fixtures/stable/check-report.json | 2 +- .../tests/fixtures/stable/profile-check.json | 2 +- .../fixtures/stable/webhook-payload.json | 75 +++++++++++++++++++ crates/permesh-cli/tests/profiles.rs | 8 +- crates/permesh-cli/tests/stable_formats.rs | 50 ++++++++++++- crates/permesh-config/src/lib.rs | 8 +- crates/permesh-config/src/profiles.rs | 7 +- crates/permesh-config/tests/profiles.rs | 13 ++-- docs/stability.md | 7 +- 15 files changed, 186 insertions(+), 28 deletions(-) create mode 100644 crates/permesh-cli/tests/fixtures/stable/webhook-payload.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e35a83..16e09e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl - People and profile files accept an optional `version: 1`; any other value is an error that says what to do. `access review` writes `index.json` as `{"access_review_index_version": 1, "plans": [...]}`. -- Add experimental `permesh check`: one unattended review run that writes a snapshot, +- Add `permesh check`: one unattended review run that writes a snapshot, a JSON report and a counts-only summary. It compares with a previous snapshot, including admin access gained through groups, and exits 6 when something needs review. See [scheduled checks](docs/check.md). @@ -21,14 +21,14 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl email, counts only by default ([ADR 0032](docs/adr/0032-explicit-check-notifications.md)). - Add `permesh init --starter` with GitHub Actions and GitLab CI workflows for weekly checks ([starter](docs/starter.md)). -- Add experimental access profiles: `permesh profile check` compares declared +- Add access profiles: `permesh profile check` compares declared `profiles/` and `people/` files with observed access and reports missing and unexplained access, including the group route; `permesh profile suggest` drafts a profile from a group. `permesh check` reports profile counts, report-only unless `profiles.enforce: true`. Policy files gain `version: 2` with `unexplained_access` exceptions ([access profiles](docs/profiles.md), [ADR 0033](docs/adr/0033-access-profiles.md)). -- Add experimental joiner, mover and leaver plans: `permesh access join|move|leave` +- Add joiner, mover and leaver plans: `permesh access join|move|leave` write a `permesh_access_plan` v1 file with operation-shaped steps and stable step IDs; moves keep access a remaining profile justifies and plan derived access at its membership; `permesh access verify` checks the steps and flags extra access; diff --git a/crates/permesh-cli/src/check/notify.rs b/crates/permesh-cli/src/check/notify.rs index 8738c1d..f68c1a8 100644 --- a/crates/permesh-cli/src/check/notify.rs +++ b/crates/permesh-cli/src/check/notify.rs @@ -398,6 +398,25 @@ mod tests { assert!(selected(&explicit, &quiet)); } + /// The generic webhook and email JSON body is stable (ADR 0036). + #[test] + fn webhook_payload_fixture() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/stable/webhook-payload.json" + ); + let hook = channels().remove(2); + let value = json!({ + "default": payload(¬ification(hook.clone(), false, vec![]), &profiled(false)), + "detail": payload(¬ification(hook, true, vec![]), &profiled(false)), + }); + if std::env::var_os("PERMESH_BLESS").is_some() { + std::fs::write(path, serde_json::to_string_pretty(&value).unwrap() + "\n").unwrap(); + } + let expected: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(value, expected, "webhook payload is a stable format"); + } + #[test] fn detail_payload_lists_profile_findings_without_names_in_summary() { let hook = channels().remove(2); diff --git a/crates/permesh-cli/src/error.rs b/crates/permesh-cli/src/error.rs index 441e380..741fbce 100644 --- a/crates/permesh-cli/src/error.rs +++ b/crates/permesh-cli/src/error.rs @@ -25,6 +25,13 @@ impl AppError { } impl From for AppError { fn from(error: permesh_config::Error) -> Self { + use permesh_config::Error; + // Version errors already name their file and the fix; the permesh.yaml hint would mislead. + if let Error::File { source, .. } = &error + && matches!(**source, Error::NewerVersion(_) | Error::InvalidVersion(_)) + { + return Self::input(error.to_string()); + } Self::input(format!( "{error}. Check permesh.yaml or pass --config FILE." )) diff --git a/crates/permesh-cli/tests/fixtures/stable/README.md b/crates/permesh-cli/tests/fixtures/stable/README.md index 3ba4e9e..215d763 100644 --- a/crates/permesh-cli/tests/fixtures/stable/README.md +++ b/crates/permesh-cli/tests/fixtures/stable/README.md @@ -8,8 +8,10 @@ spellings, array order and `null`s. Only run-specific values are replaced with `""`: string values of keys ending in `_at`, `sha256` or `digest`, numbers under keys ending in `_ms`, and -strings starting with the temporary workspace path. Human-readable text, such as -the `profile suggest` draft, is normalized too and checked for validity instead. +strings starting with the temporary workspace path. Paths under `files` compare +with `/` separators, so Windows output matches. Human-readable text (`summary`, +`message` and the `profile suggest` draft) is normalized too; the draft is checked +to load as a profile instead. A failing fixture means a stable format changed. Additive changes (new optional fields) are allowed; anything else needs a version bump and a migration guide in diff --git a/crates/permesh-cli/tests/fixtures/stable/access-review-result.json b/crates/permesh-cli/tests/fixtures/stable/access-review-result.json index 07a0104..bc173c3 100644 --- a/crates/permesh-cli/tests/fixtures/stable/access-review-result.json +++ b/crates/permesh-cli/tests/fixtures/stable/access-review-result.json @@ -16,5 +16,5 @@ ], "profiles_changed": false, "rereview": [], - "summary": "# Permesh access plan\n\nPeople affected: 1 (join 1, move 0, leave 0)\nSteps: 2 (add membership 1, add role 1, remove membership 0, remove role 0, account needed 0, review 0)\nCannot tell: 0\n" + "summary": "" } diff --git a/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json b/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json index 65c29af..c273db3 100644 --- a/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json +++ b/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json @@ -123,7 +123,7 @@ ] } ], - "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "message": "", "profile_review_version": 1, "unresolved_assignments": [] }, diff --git a/crates/permesh-cli/tests/fixtures/stable/check-report.json b/crates/permesh-cli/tests/fixtures/stable/check-report.json index bffdb4a..1382f8a 100644 --- a/crates/permesh-cli/tests/fixtures/stable/check-report.json +++ b/crates/permesh-cli/tests/fixtures/stable/check-report.json @@ -131,7 +131,7 @@ ] } ], - "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "message": "", "profile_review_version": 1, "unresolved_assignments": [] }, diff --git a/crates/permesh-cli/tests/fixtures/stable/profile-check.json b/crates/permesh-cli/tests/fixtures/stable/profile-check.json index 0204db3..c967b8b 100644 --- a/crates/permesh-cli/tests/fixtures/stable/profile-check.json +++ b/crates/permesh-cli/tests/fixtures/stable/profile-check.json @@ -91,7 +91,7 @@ ] } ], - "message": "Read-only comparison of declared profiles with captured access. Unexplained access is a review finding, not proof of wrong access; cannot_tell entries were not fully observed.", + "message": "", "profile_review_version": 1, "unresolved_assignments": [] } diff --git a/crates/permesh-cli/tests/fixtures/stable/webhook-payload.json b/crates/permesh-cli/tests/fixtures/stable/webhook-payload.json new file mode 100644 index 0000000..cf4d95b --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/webhook-payload.json @@ -0,0 +1,75 @@ +{ + "default": { + "changes": { + "reasons": [], + "state": "no_baseline" + }, + "check_report_version": 1, + "complete": true, + "counts": { + "departed_with_access": 1, + "incomplete": 0, + "missing_access": 1, + "policy_finding": 1, + "provider_failed": 0, + "unexplained_access": 1 + }, + "run_url": null, + "status": "findings" + }, + "detail": { + "change_details": "no_baseline", + "changes": { + "reasons": [], + "state": "no_baseline" + }, + "check_report_version": 1, + "complete": true, + "counts": { + "departed_with_access": 1, + "incomplete": 0, + "missing_access": 1, + "policy_finding": 1, + "provider_failed": 0, + "unexplained_access": 1 + }, + "departed": [ + { + "access_paths": 1, + "account": "102", + "instance": "demo", + "login": "former-dev", + "reason": "inactive_identity" + } + ], + "findings": [ + { + "account": "104", + "resource": "acme/payments-api", + "rule": "external_privileged", + "state": "finding" + } + ], + "profile_findings": [ + { + "group": "team-10", + "identity": "bob@example.com", + "instance": "demo", + "kind": "missing_access", + "via": [] + }, + { + "identity": "bob@example.com", + "instance": "demo", + "kind": "unexplained_access", + "resource": "repo-21", + "role": "Admin", + "via": [ + "team-10" + ] + } + ], + "run_url": null, + "status": "findings" + } +} diff --git a/crates/permesh-cli/tests/profiles.rs b/crates/permesh-cli/tests/profiles.rs index 0b959b6..47ae718 100644 --- a/crates/permesh-cli/tests/profiles.rs +++ b/crates/permesh-cli/tests/profiles.rs @@ -334,9 +334,9 @@ fn profile_check_rejects_unknown_version() { ]); let output = run(dir.path(), &["profile", "check", "--json"]); assert_eq!(output.status.code(), Some(2)); - let text = String::from_utf8_lossy(&output.stdout); - assert!( - text.contains("people/bob.yaml: unsupported format version 2"), - "{text}" + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + report["error"]["message"], + "people/bob.yaml: format version 2 is newer than this Permesh reads (version 1); upgrade Permesh" ); } diff --git a/crates/permesh-cli/tests/stable_formats.rs b/crates/permesh-cli/tests/stable_formats.rs index 41fef4c..9dcb202 100644 --- a/crates/permesh-cli/tests/stable_formats.rs +++ b/crates/permesh-cli/tests/stable_formats.rs @@ -58,7 +58,8 @@ fn stable(name: &str) -> PathBuf { } /// Only run-specific values change between runs: times, durations, digests over them and -/// absolute paths. Everything else, including order and nulls, is compared exactly. +/// absolute paths. Human text (`summary`, `message`) and path separators are platform or +/// wording details, not format. Everything else, including order and nulls, is compared exactly. fn normalize(root: &Path, value: &mut Value) { // Any temporary directory (workspace, base checkout) is run-specific. let roots: Vec = [root.to_path_buf(), std::env::temp_dir()] @@ -70,11 +71,37 @@ fn normalize(root: &Path, value: &mut Value) { match value { Value::Object(map) => { for (key, value) in map.iter_mut() { + // Output paths echo `--out` with the platform's separator. + if key == "files" + && let Value::Object(files) = value + { + for path in files.values_mut() { + if let Value::String(text) = path { + let temporary = + roots.iter().any(|root| text.starts_with(root.as_str())); + *text = if temporary { + NORMALIZED.to_owned() + } else { + text.replace('\\', "/") + }; + } + } + continue; + } + // Human-readable text is not frozen (stability.md); only its type is. + if matches!(key.as_str(), "summary" | "message") { + assert!(value.is_string(), "{key} must stay a string"); + *value = json!(NORMALIZED); + continue; + } walk(roots, Some(key), value); } } Value::Array(items) => items.iter_mut().for_each(|item| walk(roots, key, item)), Value::String(text) => { + if text == NORMALIZED { + return; // idempotent on already-normalized fixtures + } let time = key.is_some_and(|key| key.ends_with("_at")); let digest = key.is_some_and(|key| key.ends_with("sha256") || key.ends_with("digest")); @@ -405,3 +432,24 @@ fn access_review_and_execution_fixtures() { &normalized(head.path(), read(head.path(), "r2/000.execution.json")), ); } + +#[test] +fn file_paths_compare_across_platforms() { + let mut value = json!({"files": {"report": "out\\check.json", "summary": "out/summary.md"}}); + normalize(Path::new("."), &mut value); + assert_eq!(value["files"]["report"], "out/check.json"); +} + +#[test] +fn human_text_is_not_frozen() { + let mut value: Value = + serde_json::from_slice(&fs::read(stable("access-review-result.json")).unwrap()).unwrap(); + value["summary"] = json!("# Reworded summary"); + normalize(Path::new("."), &mut value); + assert!(compare("access-review-result.json", &value).is_ok()); + let mut value: Value = + serde_json::from_slice(&fs::read(stable("profile-check.json")).unwrap()).unwrap(); + value["message"] = json!("Reworded"); + normalize(Path::new("."), &mut value); + assert!(compare("profile-check.json", &value).is_ok()); +} diff --git a/crates/permesh-config/src/lib.rs b/crates/permesh-config/src/lib.rs index 9c83288..000608f 100644 --- a/crates/permesh-config/src/lib.rs +++ b/crates/permesh-config/src/lib.rs @@ -44,10 +44,10 @@ pub enum Error { NotFound, #[error("cannot serialize workspace configuration")] Serialize, - #[error( - "unsupported format version {0}; this Permesh reads version 1. Remove the version line or upgrade Permesh." - )] - UnsupportedVersion(u64), + #[error("format version {0} is newer than this Permesh reads (version 1); upgrade Permesh")] + NewerVersion(u64), + #[error("format version {0} is not valid; use version: 1 or remove the line")] + InvalidVersion(u64), #[error("{file}: {source}")] File { file: String, source: Box }, } diff --git a/crates/permesh-config/src/profiles.rs b/crates/permesh-config/src/profiles.rs index 7d4fe89..255a386 100644 --- a/crates/permesh-config/src/profiles.rs +++ b/crates/permesh-config/src/profiles.rs @@ -224,7 +224,12 @@ fn read_dir( }) = data::parse::(&bytes, MAX_CONFIG_BYTES) && version != PROFILE_FORMAT { - return Err(in_file(&label, Error::UnsupportedVersion(version))); + let error = if version > PROFILE_FORMAT { + Error::NewerVersion(version) + } else { + Error::InvalidVersion(version) + }; + return Err(in_file(&label, error)); } files.push(( label.clone(), diff --git a/crates/permesh-config/tests/profiles.rs b/crates/permesh-config/tests/profiles.rs index da8f73d..155b3c4 100644 --- a/crates/permesh-config/tests/profiles.rs +++ b/crates/permesh-config/tests/profiles.rs @@ -297,12 +297,11 @@ fn unknown_version_names_file() { files.push((name, body)); let dir = workspace("", &files); let message = load(dir.path()).unwrap_err().to_string(); - let version = if name.starts_with("people") { 2 } else { 0 }; - assert_eq!( - message, - format!( - "{name}: unsupported format version {version}; this Permesh reads version 1. Remove the version line or upgrade Permesh." - ) - ); + let expected = if name.starts_with("people") { + "people/a.yaml: format version 2 is newer than this Permesh reads (version 1); upgrade Permesh" + } else { + "profiles/b.yaml: format version 0 is not valid; use version: 1 or remove the line" + }; + assert_eq!(message, expected); } } diff --git a/docs/stability.md b/docs/stability.md index 23ec8a6..c55a663 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -24,6 +24,7 @@ previous version. | `profiles/*.yaml` and `people/*.yaml` format 1, including `status: active \| left` and the optional `version: 1` | [Access profiles](profiles.md) | `permesh-config` profile tests | | Policy file `version: 2` (`unexplained_access` exceptions) | [Policies](resource-policies.md) | `fixtures/stable/policy-v2.json` | | `check` report version 1 (`check.json` and the `check` result) | [Scheduled checks](check.md) | `fixtures/stable/check-report*.json` | +| Generic webhook and email JSON body (`check_report_version: 1`), with and without `detail` | [Notifications](notifications.md) | `fixtures/stable/webhook-payload.json` | | `profile_check` and `profile_suggest` results, version 1 | [Output schema](output-schema.md#other-results) | `fixtures/stable/profile-*.json` | | Access plan file `permesh_access_plan` version 1, including execution plans | [Access plans](access-plans.md) | `fixtures/stable/access-plan-*.json`, `access-execution-plan.json` | | `access_plan`, `access_verify` and `access_review` results, and `index.json` version 1 | [Output schema](output-schema.md#access-plans) | `fixtures/stable/access-*.json` | @@ -53,8 +54,10 @@ These may change between releases without a migration guide. ## Human-readable output -Terminal text, `summary.md`, HTML views, notification message text and the -`profile suggest` draft comments are for people and may change in any release. +Terminal text, `summary.md`, HTML views, Slack and Teams message text, email +subject and wording, the `summary` and `message` strings inside JSON results, and +the `profile suggest` draft comments are for people and may change in any release. +Paths under `files` echo the paths you gave, with your platform's separator. Two promises hold: summaries and notifications carry counts only unless you ask for `--detail` or `detail: true`, and nothing is reported as clean or removed when a provider could not be fully read. Automate against the JSON instead. From 3ecc75d393f35212b476ccf13c98df7ec6983925 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Fri, 23 Oct 2026 17:46:39 +0200 Subject: [PATCH 6/6] fix(deps): update tokio to 1.54.1 for RUSTSEC-2026-0386 --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1791886..70e2fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,7 +697,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1843,7 +1843,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2062,7 +2062,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2120,7 +2120,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2499,7 +2499,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2579,9 +2579,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2932,7 +2932,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]]