diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 43d1718a3..ff8e72ca2 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,7 @@ +## 2026-10-14 — `commit` loses its admin bypass on branch `ccr-0455e606-wmtsor` (unmerged) + +- **Contract:** `ActionInvocation::commit` no longer lets an `admin` role pass an action that requires another role; admin is an ordinary role, as it already was in `commit_via`. One authorization rule for both paths (decision 2, 2026-10-14). Test: `action::tests::commit_admin_is_a_role_not_a_bypass` (red before the change). No caller relied on the bypass (rs-graph-llm `graph-flow-action` tests use no admin role). + ## 2026-10-14 — per-classid field mask default on branch `ccr-0455e606-wmtsor` (unmerged) - **Contract:** `RbacBinding::with_class_field_defaults(Vec<(u16, WideFieldMask)>)`. A role the authority declared no field mask for gets the class default instead of every field, so the union over a user's roles can no longer open a sensitive field through a mask-less role. Policy data keyed by classid; it never changes a declared mask and is dropped outside the plug. HubSPO-rs D-IAM-3 (POC). Tests: `rbac_plug::a_class_default_replaces_the_open_default_and_nothing_else`, `authorize::plug_tests::a_class_default_keeps_sensitive_fields_out_of_the_union`; both red with the lookup removed. diff --git a/crates/lance-graph-contract/src/action.rs b/crates/lance-graph-contract/src/action.rs index efda139b6..23035ba1e 100644 --- a/crates/lance-graph-contract/src/action.rs +++ b/crates/lance-graph-contract/src/action.rs @@ -253,7 +253,8 @@ impl ActionInvocation { /// - `def` does not identify THIS invocation (`object_class`/`predicate` /// mismatch) → `Failed` — authorization is NEVER applied against an /// unrelated definition's `required_role`. - /// - unauthorized actor (lacks [`ActionDef::required_role`], not admin) → `Failed` + /// - unauthorized actor (lacks [`ActionDef::required_role`]) → `Failed`. Admin + /// is an ordinary role here: it passes only where the action requires it. /// - state guard present and unsatisfied (`guard_field_value != Some(value)`) /// → `Cancelled` — the action is not eligible in the current state. /// - MUL `Flow` → `Committed` (dispatched; `emitted_at_millis` stamped) @@ -281,7 +282,7 @@ impl ActionInvocation { } // RBAC — authority before eligibility/impact. if let Some(role) = def.required_role { - let authorized = actor.is_admin() || actor.roles.iter().any(|r| r == role); + let authorized = actor.roles.iter().any(|r| r == role); if !authorized { self.state = ActionState::Failed; return self.state; @@ -318,7 +319,7 @@ impl ActionInvocation { /// state guard → MUL impact), but RBAC is resolved through the [`ClassRbac`] /// trait instead of an [`crate::auth::ActorContext`] value. /// - /// **No `is_admin` bypass**: unlike `commit`, this method does NOT grant + /// **No `is_admin` bypass**, the same as `commit`: this method does NOT grant /// unconditional access to admins. An actor claiming administrative privilege /// must hold a role whose grant explicitly permits /// `Operation::Act { action: def.predicate }` on `def.object_class`. This is @@ -731,6 +732,37 @@ mod tests { ); } + /// Admin is an ordinary role: it passes only where an action requires it + /// (decision 2026-10-14; `commit_via` already had no bypass). + #[test] + fn commit_admin_is_a_role_not_a_bypass() { + let def = &SALE_ORDER_ACTIONS[0]; // required_role = "sales_manager" + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + assert_eq!( + inv.commit( + def, + &actor_with(&["admin"]), + &GateDecision::Flow, + Some("draft"), + 1000 + ), + ActionState::Failed, + "admin without the required role is refused" + ); + let mut both = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(7), 3, 1, 1); + assert_eq!( + both.commit( + def, + &actor_with(&["admin", "sales_manager"]), + &GateDecision::Flow, + Some("draft"), + 1000 + ), + ActionState::Committed, + "the required role still admits, admin or not" + ); + } + #[test] fn commit_unauthorized_fails_before_impact() { let def = &SALE_ORDER_ACTIONS[0]; diff --git a/docs/OGAR_CONSUMER_API.md b/docs/OGAR_CONSUMER_API.md index e7ffc16a8..0a9037b12 100644 --- a/docs/OGAR_CONSUMER_API.md +++ b/docs/OGAR_CONSUMER_API.md @@ -117,8 +117,9 @@ Adjudication order (each step can short-circuit): - **`def`-match first**: `def.object_class`/`predicate` MUST identify this invocation, else `ActionState::Failed` — RBAC/guard/MUL are never applied against an unrelated definition's policy. -- **RBAC** (`auth::ActorContext`): actor must hold `def.required_role` (or be - admin) → else `Failed`. +- **RBAC** (`auth::ActorContext`): actor must hold `def.required_role` → else + `Failed`. Admin is an ordinary role, not a bypass: an admin without the + required role is refused like anyone else (`commit` and `commit_via` agree). - **State guard** (`def.guard`): if present, `guard_field_value` must equal `guard.value` (the caller supplies the target instance's current field value — the Core holds no object state) → else `Cancelled` (not eligible in this