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
4 changes: 4 additions & 0 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
38 changes: 35 additions & 3 deletions crates/lance-graph-contract/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Comment thread
AdaWorldAPI marked this conversation as resolved.
if !authorized {
self.state = ActionState::Failed;
return self.state;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
5 changes: 3 additions & 2 deletions docs/OGAR_CONSUMER_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading