diff --git a/CHANGELOG.md b/CHANGELOG.md index e85247e..16e09e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,16 @@ User-visible changes are recorded here. Alpha releases are for evaluation; stabl ## Unreleased -- Add experimental `permesh check`: one unattended review run that writes a snapshot, +- 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 `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). @@ -12,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/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]] diff --git a/crates/permesh-cli/src/access/mod.rs b/crates/permesh-cli/src/access/mod.rs index d7f12cc..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; @@ -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/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/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/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/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/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/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/README.md b/crates/permesh-cli/tests/fixtures/stable/README.md new file mode 100644 index 0000000..215d763 --- /dev/null +++ b/crates/permesh-cli/tests/fixtures/stable/README.md @@ -0,0 +1,20 @@ +# Stable format fixtures + +These files freeze the formats declared stable by the second stability +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. + +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. 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 +`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/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..bc173c3 --- /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": "" +} 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/fixtures/stable/check-report-incomplete.json b/crates/permesh-cli/tests/fixtures/stable/check-report-incomplete.json new file mode 100644 index 0000000..c273db3 --- /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": "", + "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..1382f8a --- /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": "", + "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..c967b8b --- /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": "", + "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/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 991d850..47ae718 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 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 new file mode 100644 index 0000000..9dcb202 --- /dev/null +++ b/crates/permesh-cli/tests/stable_formats.rs @@ -0,0 +1,455 @@ +// 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. 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()] + .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) => { + 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")); + // 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); + } + } + 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()); +} + +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")), + ); +} + +#[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/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 7b15bbf..000608f 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("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 }, } @@ -55,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 29cf4a4..255a386 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::{ @@ -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,19 @@ 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 + { + let error = if version > PROFILE_FORMAT { + Error::NewerVersion(version) + } else { + Error::InvalidVersion(version) + }; + return Err(in_file(&label, error)); + } 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..155b3c4 100644 --- a/crates/permesh-config/tests/profiles.rs +++ b/crates/permesh-config/tests/profiles.rs @@ -241,3 +241,67 @@ 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 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/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 fcac7f3..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`, @@ -298,6 +299,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. 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..c55a663 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -20,6 +20,20 @@ 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` | +| 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` | +| 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 +46,21 @@ 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, 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. ## Provider maturity @@ -57,6 +75,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.