Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ 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).
- Add optional check notifications to Slack, Microsoft Teams, a generic webhook or
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;
Expand Down
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions crates/permesh-cli/src/access/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
&regenerated.plans,
Expand Down
27 changes: 26 additions & 1 deletion crates/permesh-cli/src/access/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<IndexRow>, 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)]
Expand Down Expand Up @@ -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<Reviewed, AppError> {
let mismatch = || AppError::input("Reviewed plans do not match their index.json");
let rows: Vec<IndexRow> = 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) {
Expand Down
2 changes: 1 addition & 1 deletion crates/permesh-cli/src/check/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
19 changes: 19 additions & 0 deletions crates/permesh-cli/src/check/notify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&notification(hook.clone(), false, vec![]), &profiled(false)),
"detail": payload(&notification(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);
Expand Down
2 changes: 1 addition & 1 deletion crates/permesh-cli/src/check/report.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down
7 changes: 7 additions & 0 deletions crates/permesh-cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ impl AppError {
}
impl From<permesh_config::Error> 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."
))
Expand Down
2 changes: 1 addition & 1 deletion crates/permesh-cli/src/profile_command.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down
47 changes: 44 additions & 3 deletions crates/permesh-cli/tests/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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());
}
20 changes: 20 additions & 0 deletions crates/permesh-cli/tests/fixtures/stable/README.md
Original file line number Diff line number Diff line change
@@ -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 `"<normalized>"`: 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"accounts": [
{
"id": "101",
"provider": "demo"
}
],
"complete": true,
"created_at": "<normalized>",
"expires_at": "<normalized>",
"format": "permesh_access_plan",
"format_version": 1,
"identity": "bob@example.com",
"identity_authorities": [
"demo"
],
"identity_bindings": [],
"identity_context_sha256": "<normalized>",
"kept": [],
"kind": "join",
"max_age_hours": 24,
"mode": "snapshot_replay",
"profiles_after": [
"backend"
],
"profiles_removed": [],
"profiles_sha256": "<normalized>",
"reviewed_plan_sha256": "<normalized>",
"source_snapshot_sha256": "<normalized>",
"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"
}
]
}
Loading
Loading