diff --git a/.claude/board/INTEGRATION_PLANS.md b/.claude/board/INTEGRATION_PLANS.md index b8214d68..c2edf0ec 100644 --- a/.claude/board/INTEGRATION_PLANS.md +++ b/.claude/board/INTEGRATION_PLANS.md @@ -1,3 +1,17 @@ +## 2026-06-23 — integration-actionhandler-rbac-orchestration-v1 (PLAN; shipped) + +Plan: `.claude/plans/integration-actionhandler-rbac-orchestration-v1.md`. The +ActionHandler⟷RBAC⟷orchestration spine, 5+3-hardened then built one-Sonnet-agent- +per-file (commented drafts → Opus uncomment+reconcile+central compile). Six files: +F1 `contract::rbac` §4 trait (`roles_reaching`/`row_scope`/`field_mask` defaults + +`ScopeSpec`), F2 `rbac::authorize_scoped`+`ScopedDecision` (§5 two-stage), F3 +`ActionInvocation::commit_via` (ClassRbac convergence of the inline gate), F4 +`lance-graph-ogar::OgarRbac` (Q5 as a local newtype — orphan-safe + +§6 evaporation seam), F5 `graph-flow-kanban::run_cycle` (end-to-end spine), F6 +`graph-flow-action::dispatch_via` (executor-side convergence). DEFERRED (OGAR repo): +MARS class mint + `gen_statem→ActionDef` lift (the three `ogar-from-elixir` todo!()s). +contract 735 / rbac 23 / ogar 3 / kanban 12 / action 11 green; clippy+fmt clean. + ## 2026-06-21 — capstone-out-leg-wiring-v1 (PLAN) Plan: `.claude/plans/capstone-out-leg-wiring-v1.md`. The file-level execution diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 12f284dd..4fcd675a 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -10,6 +10,10 @@ --- +## 2026-06-23 — IN PR (`claude/medcare-bridge-lance-graph-wmx76z`) — ActionHandler⟷RBAC⟷orchestration spine + +`contract::rbac`: `ScopeSpec` (axis-3 Copy token) + `ClassRbac` §4 default methods (`roles_reaching`/`row_scope`/`field_mask`; backward-compat, probe green). `contract::class_view::FieldMask::union`. `contract::action::ActionInvocation::commit_via` (no-admin-bypass convergence of the inline gate). `lance-graph-rbac::{authorize_scoped, ScopedDecision}` (§5 two-stage). `lance-graph-ogar::{OgarRbac, GrantSource}` (Q5 local newtype, §6 evaporation seam). rs-graph-llm: `graph-flow-kanban::{run_cycle, CycleOutcome}` + `graph-flow-action::dispatch_via`. Plan: integration-actionhandler-rbac-orchestration-v1. + ## 2026-06-23 — IN PR (`claude/medcare-bridge-lance-graph-wmx76z`) — `contract::rbac` — `ClassRbac` trait + `Operation` promoted to contract (keystone §11 trait-placement) The `ClassRbac` grant-resolution trait (§4) + the `Operation` it ranges over were promoted from `lance-graph-rbac` into the zero-dep contract so `lance-graph-ogar`'s `OgarClassView` (deps contract, NOT rbac) can implement the keystone's `impl ClassRbac for OgarClassView` (Q5) — the missing wire in the `contract ↔ rbac ↔ ogar ↔ callcenter` chain. **NEW** `lance_graph_contract::rbac`: `ClassId` / `ActorId` / `RoleId` / `Operation<'a>` (reads `contract::property::PrefetchDepth`, no rbac dep) / `trait ClassRbac { actor_roles, grant_permits }`. `lance-graph-rbac` **re-exports** them (`policy::Operation`, `authorize::{ClassRbac, ClassId, ActorId, RoleId}` unchanged) — `authorize()` + `ClassGrants` + `Policy` + `AccessDecision` + the `0x0B` auth membrane stay in rbac. Zero breakage: `lance-graph-callcenter` builds against the re-exports (38s); the sibling `smb-realtime` / `medcare-realtime` gates consume `AccessDecision` (unmoved) untouched. **Verified:** contract::rbac 2 tests (incl. a contract-only `impl ClassRbac` proving ogar can satisfy it) + 723 contract tests; rbac 21 tests; callcenter builds; clippy `-D warnings` + fmt clean. Follow-on (not forced here): converge `rbac::auth::ResolvedIdentity` onto the existing `contract::auth::ActorContext`; the `OgarClassView` impl needs the §6 `project_role.granted` tenant. Refs: EPIPHANIES `E-CLASSRBAC-PROMOTED-TO-CONTRACT`, OGAR `CLASSID-RBAC-KEYSTONE-SPEC.md` §11/Q5. diff --git a/.claude/plans/integration-actionhandler-rbac-orchestration-v1.md b/.claude/plans/integration-actionhandler-rbac-orchestration-v1.md new file mode 100644 index 00000000..a7ff9214 --- /dev/null +++ b/.claude/plans/integration-actionhandler-rbac-orchestration-v1.md @@ -0,0 +1,257 @@ +# Integration Plan — ActionHandler ⟷ RBAC ⟷ Orchestration spine (v1) + +> **Created:** 2026-06-23. **Status:** HARDENING (5+3 in progress). +> **Branch:** `claude/medcare-bridge-lance-graph-wmx76z`. **Target PR:** one, against `main`. +> **Workflow:** each file below is implemented by ONE Sonnet agent writing its new +> code **commented-out** (inert — the crate still compiles). The Opus orchestrator +> then reviews every draft, **uncomments + reconciles**, compiles/tests **once** +> centrally (cargo-hygiene: shared checkout, no per-agent worktrees, no per-agent +> builds), and commits the activated code to the PR. + +## Why (the audit gaps this closes) + +From the chain audit (`ActionHandler transcode ⟷ MARS ⟷ RBAC ⟷ orchestration`), +the lance-graph-side load-bearing gaps: + +1. **RBAC §4 trait is partial** — `contract::rbac::ClassRbac` has only + `actor_roles` + `grant_permits`; the keystone §4 `roles_reaching` / + `row_scope` / `field_mask` (axes 2/3/4) are absent. +2. **`authorize()` is positive∧op-gate only** — no §5 stage-2 row-scope + predicate, no projecting `Allow{scope, mask}`. +3. **commit ↔ authorize disconnected** — `ActionInvocation::commit` does an + *inline* `actor.roles.contains(required_role)`; it never consults `ClassRbac`. + Two RBAC surfaces, unconverged. +4. **No `impl ClassRbac for OgarClassView`** — keystone Q5's intended + active-record impl is missing (only the reference `ClassGrants` exists). +5. **Orchestration not wired** — `OgarActionProvider` (DO surface) + + `graph-flow-action` (executor) + `graph-flow-kanban` (lifecycle) compose only + *in principle*; nothing runs `classid → effective_actions → ActionInvocation → + Kanban cycle → authorized dispatch` end to end. + +**Out of scope for THIS PR (deferred follow-up, OGAR repo):** minting the MARS +class family in `ogar_vocab::class_ids`, and implementing the `gen_statem → +ActionDef` behavioral lift (`ogar-from-elixir`'s three `todo!()`s). Those are a +parser implementation in a different repo under strict canon/shell discipline; +they gate the *MARS DO surface* but not the RBAC/orchestration spine, which this +PR completes generically (provider-agnostic) so MARS plugs in unchanged later. + +## Non-negotiables (carried from the workspaces) + +- **Backward compatibility:** extending `ClassRbac` MUST use **default methods** + so the existing `ClassGrants` impl + the `PROBE-OGAR-RBAC-AUTHORIZE` gate keep + compiling and passing **unchanged**. No breaking the green probe. +- **Contract stays zero-dep.** New types in `contract::rbac` use only contract + types (`PrefetchDepth`, `FieldMask` if reused, no heap-heavy deps in hot types). +- **kgV invariant** (`I-ACTIONHANDLER-IS-KGV-NOT-CHOKEPOINT`): the orchestration + integrator stays generic over a *provided* action manifest + a *provided* + `ClassRbac` — it must NOT import a thinking style or wrap the SoA columns. +- **commit's existing semantics are sticky/tested** — the convergence is + *additive* (a new `commit_*` entry point), never a silent change to `commit`. + +## Files (one Sonnet agent each — code COMMENTED) + +### F1 — `crates/lance-graph-contract/src/rbac.rs` (extend the §4 trait) +- Add types: `RoleSet` (a small owned set of `RoleId` — `Vec` newtype or + `&[RoleId]`), `ScopeSpec` (compiled row-scope predicate handle: an opaque + `{ tenant: Option, predicate_key: u32 }`-shape carrier — NOT a runtime + domain; §3 axis-3), and reuse the existing `OpMask`/`ClassGrant`. +- Extend `trait ClassRbac` with **DEFAULT** methods: + - `fn roles_reaching(&self, class: ClassId) -> RoleSet` (default: empty — the + role-hierarchy fold is impl-provided; default keeps it positive-only). + - `fn row_scope(&self, role: RoleId, class: ClassId) -> Option` + (default `None` — global, no row restriction). + - `fn field_mask(&self, role: RoleId, class: ClassId) -> u64` (default + `u64::MAX` — all fields; axis-4). (u64 mask to avoid pulling canonical_node's + FieldMask if it widens the dep; agent confirms the lightest correct type.) +- Tests: defaults preserve today's behaviour; a typed impl exercises all four. + +### F2 — `crates/lance-graph-rbac/src/authorize.rs` (§5 two-stage) +- Add `ScopedDecision { decision: AccessDecision, scope: Option, + field_mask: u64 }` and `authorize_scoped(rbac, actor, class, op) -> + ScopedDecision`: stage-1 = the existing positive∧op-gate (REUSE `authorize`); + stage-2 = AND the granting roles' `row_scope` (restrictive default-deny) and + union their `field_mask`. `authorize()` stays as the collapsed compat path + (unchanged signature, unchanged probe). +- Tests: a scoped impl yields scope+mask on Allow; deny short-circuits scope. + +### F3 — `crates/lance-graph-contract/src/action.rs` (commit ↔ authorize) +- Add `ActionInvocation::commit_via(&mut self, def, rbac, actor_id, + impact, guard_field_value, now) -> ActionState`: identical lifecycle to + `commit`, but the **RBAC step resolves through `rbac.grant_permits(role, class, + &Operation::Act{action: predicate})`** for each `rbac.actor_roles(actor_id)` + instead of the inline `ActorContext.roles.contains`. `commit` is untouched + (documented as the coarse ActorContext gate; `commit_via` is the ClassRbac + convergence). Reuse `def.required_role` semantics: if `required_role` is set, + the actor must hold a role whose grant permits the act on `def.object_class`. +- Tests: `commit_via` accepts an authorized actor, rejects an ungranted one, + parity with `commit` on the ActorContext-equivalent case. + +### F4 — `crates/lance-graph-ogar/src/rbac_impl.rs` (Q5 — `impl ClassRbac for OgarClassView`) +- New module `pub mod rbac_impl;` + `impl ClassRbac for OgarClassView` resolving: + `actor_roles` (from a provided membership table — keep the impl table-backed, + same shape as `ClassGrants`, since `project_role.granted` isn't in OGAR Core + yet — documented as the bridge until §6 lands), `grant_permits` (via + `contract::rbac::grants_permit` over a per-role `&[ClassGrant]`), §4 defaults. +- Verify in isolation (contract-only scratch like the actions module — the lib + builds against fresh OGAR main now, so an in-crate test is fine). + +### F5 — `crates/graph-flow-kanban/src/orchestrate.rs` (rs-graph-llm — the end-to-end spine) +- New module: `run_cycle(actions: &[ActionDef], rbac: &impl ClassRbac, actor_id, + classid, predicate, gate, guard_value, now) -> CycleOutcome` that: + resolve the `ActionDef` by `(classid, predicate)` from the provided manifest → + build a `KanbanPlanEnvelope` → advance Planning→CognitiveWork on `Flow` → + build an `ActionInvocation` → `commit_via(rbac, …)` at the CognitiveWork→commit + boundary → map the `ActionState` onto a Kanban terminal (Committed→Commit, + Pending→Plan, Cancelled/Failed→Prune) → return `{ outcome, envelope }`. + Generic over the manifest + rbac (kgV: no provider/thinking import). +- Tests: authorized act drives the cycle to `Commit`; unauthorized → `Prune`; + MUL `Hold` → `Plan` (re-deliberate). + +## Sequencing / dependencies +- F1 is the type root (F2, F3, F4, F5 reference its §4 surface / commit_via). +- F3 depends on F1 (uses `ClassRbac`). F5 depends on F1+F3 (uses `commit_via`). +- F2 depends on F1. F4 depends on F1. +- Because all drafts are **commented**, agents may run fully in parallel; the + Opus review reconciles cross-file type names during uncomment. + +## HARDENING OUTCOME — corrected specs (v2, AUTHORITATIVE for the fleet) + +> Supersedes the F1–F5 blocks above where they conflict. From 5+3 review +> (integration-lead, preflight-drift, convergence-architect, dto-soa-savant, +> core-first-architect + 3 reviewers pending). Every impl agent obeys THIS. + +**F1 — `contract/src/rbac.rs`:** Reuse `contract::class_view::FieldMask` (NOT a +raw `u64` — it already exists, is zero-dep, and rbac's `PermissionSpec.projection` +already uses it). **Keep** the `ScopeSpec` newtype as the axis-3 row-scope token: +`pub struct ScopeSpec { tenant: Option, predicate_key: u32, deny: bool }` +(`Copy` POD, no interpreting methods; `tenant: None` = global, `deny: true` = the +empty scope) — a dedicated token rather than a bare `Option` because the +restrictive-AND fold needs a sound *conflict* value (two distinct tenants → empty, +not "self wins") and a reserved `predicate_key`. Drop the `RoleSet` newtype — +`roles_reaching(&self, class) -> &[RoleId]` (default `&[]`). All three are +**DEFAULT** methods (default `field_mask` = `FieldMask::FULL`, `row_scope` = +`None`, `roles_reaching` = `&[]`) so `ClassGrants` + the green +`PROBE-OGAR-RBAC-AUTHORIZE` compile/pass unchanged. + +**F2 — `rbac/src/authorize.rs`:** `ScopedDecision { decision: AccessDecision, +scope: Option, field_mask: FieldMask }` + `authorize_scoped(...)`. Stage-1 +REUSES `authorize()`. `AccessDecision` has **THREE** variants — `Allow`, +`Deny{reason}`, `Escalate{reason}` — the stage-2 match MUST be exhaustive and +short-circuit scope on ANY non-`Allow`. The stage-2 fold intersects only +*concrete* `Some` row-scopes (a `None`/global role never narrows the fold; it +leaves the `None` sentinel intact), and `ScopeSpec::intersect` returns the empty +scope (`deny`) on a two-tenant conflict. `authorize()` stays byte-unchanged. + +**F3 — `contract/src/action.rs`:** Add `commit_via(&mut self, def, +rbac: &R, actor_id: ActorId<'_>, impact, guard_field_value, now) -> ActionState`. +RBAC step: if `def.required_role.is_some()`, require `rbac.actor_roles(actor_id)` +to contain ≥1 role where `rbac.grant_permits(role, def.object_class, +&Operation::Act{action: def.predicate})`; if `required_role.is_none()` → proceed +(parity with `commit`). **PINNED semantics (integration-lead R1):** `commit_via` +has **NO `is_admin()` bypass** — admin must be a granted role (more auditable; +documented divergence from `commit`). `commit` is **untouched**. (OQ-CSV deferred: +convergence-architect's "make `commit` a forwarder over an `ActorRoleRbac` +adapter" — elegant but the adapter would need `def`; revisit post-PR.) + +**F4 — `lance-graph-ogar/src/rbac_impl.rs`:** **DO NOT** `impl ClassRbac for +OgarClassView` — that is an **orphan-rule violation** (both trait + type foreign +to the crate) AND a Core-state-leak. Instead a **local newtype** with an +**injected grant source** (the §6 evaporation seam): +```rust +pub trait GrantSource { + fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId]; + fn grants_of(&self, role: RoleId) -> &[ClassGrant]; +} +pub struct OgarRbac { source: S } // owns NO grant data +impl ClassRbac for OgarRbac { /* actor_roles/grant_permits via source; §4 defaults */ } +``` +Body reads only from `source`, so when §6 `project_role.granted` lands the source +flips from a fixture to the tenant resolver with **zero body change** (the +"evaporation test"). File the §6 tenant work as a core-gap ticket in the PR body. +Verify in-crate (`lance-graph-ogar` builds against fresh OGAR main — confirmed). + +**F5 — `/home/user/rs-graph-llm/graph-flow-kanban/src/orchestrate.rs`** (rs-graph-llm +workspace, cwd `/home/user/rs-graph-llm`, NOT lance-graph). **CONSUME** the +EXISTING `KanbanPlanEnvelope` (`::new`/`.advance`/`.try_transition` — do NOT +re-author it). Net-new: `run_cycle(actions: &[ActionDef], rbac: &impl ClassRbac, +actor_id, classid, predicate, gate, guard_value, now) -> CycleOutcome { outcome, +envelope }`. Resolve `ActionDef` by `(classid, predicate)` → drive the envelope +Planning→CognitiveWork on `Flow` → call **F6 `dispatch_via`** (not `commit_via` +directly — compose the executor, don't fork it) → map `ActionState` onto a Kanban +terminal. Stay generic (kgV: no thinking-style / no provider / no SoA column). One +test must feed a **provider-shaped** manifest (so the `def.object_class` match is +real, per truth-architect/integration-lead R3). + +**F6 — `/home/user/rs-graph-llm/graph-flow-action/src/lib.rs`** (NEW, added by +hardening — M1): add `dispatch_via(handler, rbac, +actor_id, gate, action, inv, guard_value, now) -> HandlerOutcome` mirroring +`dispatch` but routing the cold-floor through `inv.commit_via(action, rbac, +actor_id, gate, guard_value, now)` instead of `commit`. Closes the "executor still +on the old RBAC surface" half of gap #3. `dispatch` stays untouched. + +### Canonical signatures (FINAL — 5+3 synthesized; every agent obeys verbatim) + +```rust +// REUSE (never redefine): ClassId=u32, ActorId<'a>=&'a str, RoleId=&'static str, +// Operation<'a>, OpMask, ClassGrant, grants_permit, ClassRbac (contract::rbac); +// contract::class_view::FieldMask (FieldMask(pub u64); ::FULL, ::EMPTY, .has, .intersect, .inherit); +// AccessDecision { Allow, Deny{reason:&'static str}, Escalate{reason:&'static str} } — FROZEN; +// KanbanPlanEnvelope, KanbanColumn, ExecTarget (graph-flow-kanban + contract::kanban); +// GateDecision (contract::mul), ActionDef, ActionInvocation, NodeGuid. + +// F1 contract/src/rbac.rs — extend trait with DEFAULT methods (zero existing impl edited) + ScopeSpec +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ScopeSpec { pub tenant: Option, pub predicate_key: u32, pub deny: bool } // Copy POD, NO interpreting methods; tenant None=global, deny true=empty scope; predicate_key reserved (0 = tenant-only). intersect: distinct tenants => DENY (never self-wins); deny absorbing. +// added to `trait ClassRbac` (ALL with default bodies): +// fn roles_reaching(&self, _class: ClassId) -> &[RoleId] { &[] } // axis-2 hook, default empty (CONJECTURE: not impl'd this PR) +// fn row_scope(&self, _role: RoleId, _class: ClassId) -> Option { None } // axis-3, default global +// fn field_mask(&self, _role: RoleId, _class: ClassId) -> FieldMask { FieldMask::FULL } // axis-4, reuse FieldMask + +// F2 lance-graph-rbac/src/authorize.rs +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScopedDecision { pub decision: AccessDecision, pub scope: Option, pub field_mask: FieldMask } +pub fn authorize_scoped(rbac:&impl ClassRbac, actor:ActorId<'_>, class:ClassId, op:Operation<'_>) -> ScopedDecision; +// stage1 = authorize(...) UNCHANGED. non-Allow (Deny OR Escalate) => {decision, None, FieldMask::FULL}. +// stage2 subset = actor_roles(actor).iter().filter(|r| grant_permits(r,class,&op)) [SAME predicate, NOT roles_reaching] +// scope = fold row_scope by restrictive-AND; field_mask = fold field_mask by union (.inherit/.intersect-union). +// AccessDecision FROZEN. authorize() byte-unchanged. probe stays green. + +// F3 contract/src/action.rs — `commit` UNTOUCHED; add: +impl ActionInvocation { pub fn commit_via(&mut self, def:&ActionDef, rbac:&R, + actor_id:ActorId<'_>, impact:&GateDecision, guard_field_value:Option<&str>, now_millis:u64) -> ActionState; } +// order: def-match -> RBAC -> guard -> MUL (mirror commit). NO is_admin bypass. +// RBAC: if let Some(_)=def.required_role { let ok = rbac.actor_roles(actor_id).iter() +// .any(|&r| rbac.grant_permits(r, def.object_class, &Operation::Act{action: def.predicate})); +// if !ok { self.state=Failed; return self.state; } } // None required_role => proceed (parity) +// resolve `ok` to a bool BEFORE mutating self.state. No unwrap/panic. + +// F4 lance-graph-ogar/src/rbac_impl.rs — LOCAL newtype (NOT impl for OgarClassView; orphan E0117) +pub trait GrantSource { fn roles_of(&self, actor:ActorId<'_>) -> &[RoleId]; fn grants_of(&self, role:RoleId) -> &[ClassGrant]; } +pub struct OgarRbac { pub source: S } // owns NO grant data; source is the §6 evaporation seam +// impl ClassRbac for OgarRbac: actor_roles->source.roles_of; grant_permits->grants_permit(source.grants_of(role),class,op); §4 defaults. +// ADD `pub mod rbac_impl;` to lance-graph-ogar/src/lib.rs. + +// F5 rs-graph-llm/graph-flow-kanban/src/orchestrate.rs (cwd /home/user/rs-graph-llm; Cargo.toml UNCHANGED, contract-only) +#[derive(Debug, Clone)] pub struct CycleOutcome { pub outcome: KanbanColumn, pub envelope: KanbanPlanEnvelope } +#[allow(clippy::too_many_arguments)] +pub fn run_cycle(actions:&[ActionDef], rbac:&impl ClassRbac, actor_id:ActorId<'_>, classid:u32, + predicate:&'static str, gate:&GateDecision, object_instance:NodeGuid, guard_value:Option<&str>, now_millis:u64) -> CycleOutcome; +// def = actions.iter().find(|a| a.object_class==classid && a.predicate==predicate) -> Option; None => outcome Prune (no unwrap). +// env=KanbanPlanEnvelope::new(classid as MailboxId, def.exec); env.advance(gate) x2 (Planning->CognitiveWork->Evaluation); +// inv=ActionInvocation::pending(classid,predicate,object_instance,env.cycle,0,0); st=inv.commit_via(def,rbac,actor_id,gate,guard_value,now_millis); +// terminal = match st { Committed=>Commit, Pending=>Plan, Cancelled|Failed=>Prune }; env.try_transition(terminal); CycleOutcome{terminal, env}. +// ADD `pub mod orchestrate;` to graph-flow-kanban/src/lib.rs. + +// F6 rs-graph-llm/graph-flow-action/src/lib.rs — `dispatch` UNTOUCHED; add (executor-side convergence): +pub fn dispatch_via(handler:&H, rbac:&R, actor_id:ActorId<'_>, gate:&GateDecision, + action:&ActionDef, inv:&mut ActionInvocation, guard_field_value:Option<&str>, now_millis:u64) -> HandlerOutcome; +// mirror `dispatch` but cold-floor via inv.commit_via(action, rbac, actor_id, gate, guard_field_value, now_millis). +// needs `use lance_graph_contract::rbac::{ClassRbac, ActorId};` — contract-only, no new dep. +``` + +## Activation (Opus, after all agents) +1. Read each commented draft; reconcile type names + signatures across F1–F5. +2. Uncomment, `cargo fmt` + `cargo clippy` + `cargo test` per crate (contract, + rbac, ogar via manifest, graph-flow-kanban) — once, centrally. +3. Board-hygiene: prepend INTEGRATION_PLANS.md + a PR_ARC entry in the SAME commit. +4. Open ONE PR; note the deferred OGAR MARS follow-up. diff --git a/crates/lance-graph-contract/src/action.rs b/crates/lance-graph-contract/src/action.rs index c9000b2e..5de83326 100644 --- a/crates/lance-graph-contract/src/action.rs +++ b/crates/lance-graph-contract/src/action.rs @@ -309,6 +309,273 @@ impl ActionInvocation { } } +use crate::rbac::{ActorId, ClassRbac, Operation}; + +impl ActionInvocation { + /// ClassRbac convergence of [`ActionInvocation::commit`]'s inline RBAC gate. + /// + /// Identical lifecycle order to `commit` (sticky-terminal → def-match → RBAC → + /// 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 + /// 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 + /// deliberate: the `ClassRbac` surface is the authoritative grant registry; + /// out-of-band bypass is not modelled here. + pub fn commit_via( + &mut self, + def: &ActionDef, + rbac: &R, + actor_id: ActorId<'_>, + impact: &GateDecision, + guard_field_value: Option<&str>, + now_millis: u64, + ) -> ActionState { + // Sticky terminal — already adjudicated, not re-adjudicated. + if self.state.is_terminal() { + return self.state; + } + // Def-match: the def MUST identify THIS invocation before any policy check. + // Applying an unrelated def's required_role would authorize via the wrong policy. + if def.object_class != self.object_class || def.predicate != self.predicate { + self.state = ActionState::Failed; + return self.state; + } + // RBAC — resolve `ok` to bool BEFORE mutating `self.state` (borrow hygiene). + if let Some(required_role) = def.required_role { + let ok = rbac.actor_roles(actor_id).iter().any(|&r| { + r == required_role + && rbac.grant_permits( + r, + def.object_class, + &Operation::Act { + action: def.predicate, + }, + ) + }); + if !ok { + self.state = ActionState::Failed; + return self.state; + } + } + // State guard — fire only when `field == value` (P2). + // An unsatisfied or unknown guarded state refuses the fire (Cancelled, not Failed). + if let Some(g) = def.guard { + if guard_field_value != Some(g.value) { + self.state = ActionState::Cancelled; + return self.state; + } + } + // MUL impact assessment — mirrors `commit` exactly. + self.state = match impact { + GateDecision::Flow => { + self.emitted_at_millis = Some(now_millis); + ActionState::Committed + } + GateDecision::Hold { .. } => ActionState::Pending, + GateDecision::Block { .. } => ActionState::Cancelled, + }; + self.state + } +} + +#[cfg(test)] +mod commit_via_tests { + use super::*; + + // Minimal ClassRbac test double: a fixed grant table. + struct TestRbac { + // triples of (role, object_class, action_predicate) that are permitted + grants: Vec<(&'static str, u32, &'static str)>, + // pairs of actor_id -> roles + actor_roles_map: Vec<(&'static str, Vec<&'static str>)>, + } + + impl ClassRbac for TestRbac { + fn actor_roles<'a>(&'a self, actor_id: ActorId<'_>) -> &'a [&'static str] { + for (id, roles) in &self.actor_roles_map { + if *id == actor_id { + return roles.as_slice(); + } + } + &[] + } + fn grant_permits(&self, role: &'static str, object_class: u32, op: &Operation) -> bool { + match op { + Operation::Act { action } => self + .grants + .iter() + .any(|&(r, c, a)| r == role && c == object_class && a == *action), + _ => false, + } + } + } + + fn inst(identity: u32) -> NodeGuid { + NodeGuid::new(0x0A1E_0001, 0, 0, 0, 0, identity) + } + + const DEF_WITH_ROLE: ActionDef = ActionDef { + predicate: "action_confirm", + object_class: 0x0A1E_0001, + exec: ExecTarget::SurrealQl, + guard: Some(StateGuard { + field: "state", + value: "draft", + }), + required_role: Some("sales_manager"), + overrides: None, + }; + + const DEF_NO_ROLE: ActionDef = ActionDef { + predicate: "action_cancel", + object_class: 0x0A1E_0001, + exec: ExecTarget::SurrealQl, + guard: None, + required_role: None, + overrides: None, + }; + + fn rbac_granting(role: &'static str) -> TestRbac { + TestRbac { + grants: vec![(role, 0x0A1E_0001, "action_confirm")], + actor_roles_map: vec![("u1", vec![role])], + } + } + + fn rbac_denying() -> TestRbac { + TestRbac { + grants: vec![("sales_manager", 0x0A1E_0001, "action_confirm")], + actor_roles_map: vec![("u1", vec!["viewer"])], + } + } + + fn actor(id: &'static str) -> ActorId<'static> { + id + } + + /// Authorized actor (holds a role whose grant permits Act) + guard satisfied + Flow + /// → Committed, emitted_at_millis stamped. Terminal sticky on re-call. + #[test] + fn commit_via_authorized_flow_commits() { + let rbac = rbac_granting("sales_manager"); + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(1), 5, 1, 1); + let state = inv.commit_via( + &DEF_WITH_ROLE, + &rbac, + actor("u1"), + &GateDecision::Flow, + Some("draft"), + 2000, + ); + assert_eq!(state, ActionState::Committed); + assert_eq!(inv.emitted_at_millis, Some(2000)); + // sticky: re-adjudication is a no-op even with Block + let state2 = inv.commit_via( + &DEF_WITH_ROLE, + &rbac, + actor("u1"), + &GateDecision::Block { + reason: "x".to_string(), + }, + Some("draft"), + 3000, + ); + assert_eq!(state2, ActionState::Committed); + assert_eq!( + inv.emitted_at_millis, + Some(2000), + "stamp must not be overwritten on sticky re-call" + ); + } + + /// Ungranted actor (role present but grant does not permit Act) → Failed before guard/MUL. + #[test] + fn commit_via_ungranted_actor_fails() { + let rbac = rbac_denying(); + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(2), 5, 2, 2); + let state = inv.commit_via( + &DEF_WITH_ROLE, + &rbac, + actor("u1"), + &GateDecision::Flow, + Some("draft"), + 2000, + ); + assert_eq!(state, ActionState::Failed); + assert_eq!(inv.emitted_at_millis, None, "failed action must not emit"); + } + + /// required_role: None → proceeds regardless of rbac content (parity with commit). + /// No role check means even an actor with no roles can proceed to MUL/guard. + #[test] + fn commit_via_no_required_role_proceeds_regardless_of_rbac() { + let rbac = rbac_denying(); // rbac grants nothing useful for this actor + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_cancel", inst(3), 5, 3, 3); + // DEF_NO_ROLE has no guard and no required_role → reaches MUL directly + let state = inv.commit_via( + &DEF_NO_ROLE, + &rbac, + actor("u1"), + &GateDecision::Flow, + None, + 1000, + ); + assert_eq!( + state, + ActionState::Committed, + "no required_role means rbac is not consulted" + ); + } + + /// Admin-as-role: an actor explicitly granted "admin" role whose grant permits Act passes. + /// Bare `is_admin` does NOT bypass — there is no `ActorContext` here, no backdoor. + #[test] + fn commit_via_admin_must_be_granted_role_not_bypass() { + // Actor "admin_user" is explicitly granted "admin" role with the required permission. + let rbac = TestRbac { + grants: vec![("admin", 0x0A1E_0001, "action_confirm")], + actor_roles_map: vec![("admin_user", vec!["admin"])], + }; + let admin_def = ActionDef { + required_role: Some("admin"), + ..DEF_WITH_ROLE + }; + let mut inv = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(4), 5, 4, 4); + let state = inv.commit_via( + &admin_def, + &rbac, + actor("admin_user"), + &GateDecision::Flow, + Some("draft"), + 1000, + ); + assert_eq!(state, ActionState::Committed, "admin-as-granted-role works"); + + // Actor whose id is literally "admin" but holds a role with no grant → fails (no bypass). + let rbac_no_grants = TestRbac { + grants: vec![], + actor_roles_map: vec![("admin", vec!["admin"])], + }; + let mut inv2 = ActionInvocation::pending(0x0A1E_0001, "action_confirm", inst(5), 5, 5, 5); + let state2 = inv2.commit_via( + &DEF_WITH_ROLE, + &rbac_no_grants, + actor("admin"), + &GateDecision::Flow, + Some("draft"), + 1000, + ); + assert_eq!( + state2, + ActionState::Failed, + "no grant → fails; bare is_admin does not bypass" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/lance-graph-contract/src/class_view.rs b/crates/lance-graph-contract/src/class_view.rs index b4d0e753..cc7880d8 100644 --- a/crates/lance-graph-contract/src/class_view.rs +++ b/crates/lance-graph-contract/src/class_view.rs @@ -134,6 +134,15 @@ impl FieldMask { Self(self.0 & other.0) } + /// Bitwise union — the field positions present in EITHER mask. The fold an + /// RBAC kernel uses to combine the projections a user's several granting + /// roles each permit (a user sees the union of the columns any of their + /// roles may see). + #[inline] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + /// Do the two masks share NO field position? RBAC uses this to assert /// two roles project **distinct** views of the same class — e.g. a /// research projection must be disjoint from the identifier fields diff --git a/crates/lance-graph-contract/src/rbac.rs b/crates/lance-graph-contract/src/rbac.rs index 17ae580c..fc31214b 100644 --- a/crates/lance-graph-contract/src/rbac.rs +++ b/crates/lance-graph-contract/src/rbac.rs @@ -28,8 +28,73 @@ //! admit/deny an external commit; `ClassRbac` is the *grant resolution* a gate //! consults. They compose: a gate calls `authorize(rbac, actor, class, op)`. +use crate::class_view::FieldMask; use crate::property::PrefetchDepth; +/// §3/§4 compiled scope-and-projection token — the `(tenant, predicate_key)` pair +/// that constrains a role's row-visibility on a class. `predicate_key = 0` means +/// tenant-only scope (the common case). Intentionally opaque: NO `evaluate` / +/// interpret methods — it is a compiled address token, not a runtime policy +/// engine; the kernel resolves it against the store, never interprets it inline. +/// `Copy` POD (no heap). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ScopeSpec { + /// The tenant discriminant. `None` = global (cross-tenant) scope. + pub tenant: Option, + /// Reserved predicate key (0 = tenant-only; non-zero reserved for future + /// predicate-scoped row filters — do NOT interpret in this PR). + pub predicate_key: u32, + /// The **empty** scope — `true` ⇒ no row is visible. This is the sound + /// representation of an irreconcilable [`intersect`](ScopeSpec::intersect): + /// when two granting roles bind DIFFERENT tenants, no row can satisfy both, + /// so the AND-fold is empty — NOT "one tenant arbitrarily wins" (which would + /// silently *widen* visibility to a tenant the other role never granted). + /// `deny` is absorbing: `deny ∩ anything = deny`. Default `false` (a fresh + /// scope denies nothing). + pub deny: bool, +} + +impl ScopeSpec { + /// The empty scope — denies every row. The absorbing element of + /// [`intersect`](ScopeSpec::intersect). + pub const DENY: ScopeSpec = ScopeSpec { + tenant: None, + predicate_key: 0, + deny: true, + }; + + /// Restrictive intersection of two scopes — the AND-fold `authorize_scoped` + /// uses when a user holds several granting roles (each row must satisfy + /// EVERY granting role's scope). `None` (global) ∩ x = x (the specific one + /// restricts). Two **distinct** tenants is a genuine conflict: no row lives + /// in both, so the result is the empty scope ([`ScopeSpec::DENY`]) — never + /// "self wins" (that would widen visibility to `self`'s tenant, which the + /// other granting role never authorized). `deny` is absorbing. `predicate_key`s + /// are OR-combined (reserved; both keys apply once a consumer interprets them). + #[inline] + #[must_use] + pub const fn intersect(self, other: Self) -> Self { + if self.deny || other.deny { + return ScopeSpec::DENY; + } + let predicate_key = self.predicate_key | other.predicate_key; + match (self.tenant, other.tenant) { + (None, t) | (t, None) => Self { + tenant: t, + predicate_key, + deny: false, + }, + (Some(a), Some(b)) if a == b => Self { + tenant: Some(a), + predicate_key, + deny: false, + }, + // Distinct tenants: empty intersection — no row satisfies both. + (Some(_), Some(_)) => ScopeSpec::DENY, + } + } +} + /// The codebook class identity an authorization targets — the /// [`NodeGuid`](crate::NodeGuid) `classid` (or its low-`u16` codebook id widened). /// Opaque to the kernel: it is compared and looked up, never decoded (the kernel @@ -84,6 +149,32 @@ pub trait ClassRbac { /// `R⁺` op-mask gate (§5 stage 1). No grant, or a grant that does not permit /// the op, ⇒ `false` (restrictive default-deny). fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool; + + /// Axis-2 role-hierarchy fold hook — the roles that *reach* `class` through a + /// hierarchy above the direct actor roles. Default empty: the kernel consults + /// only [`actor_roles`](ClassRbac::actor_roles) when this returns `&[]`. + /// **CONJECTURE (not implemented this PR):** a non-empty return will be folded + /// in by a future keystone phase; today's `authorize()` / `authorize_scoped()` + /// do NOT call this method (they use `actor_roles ∧ grant_permits`). + fn roles_reaching(&self, _class: ClassId) -> &[RoleId] { + &[] + } + + /// Axis-3 compiled scope — the row-visibility constraint for `role` on + /// `class`. `None` (global, see all rows) by default. A non-`None` value is a + /// pre-compiled [`ScopeSpec`] token; the kernel resolves it against the store + /// as an address, never interprets it inline. + fn row_scope(&self, _role: RoleId, _class: ClassId) -> Option { + None + } + + /// Axis-4 field projection — the column mask permitted for `role` on `class`. + /// [`FieldMask::FULL`] (all fields) by default; a narrower mask gives + /// column-level RBAC. The kernel intersects this with the query's own + /// projection before emitting rows. + fn field_mask(&self, _role: RoleId, _class: ClassId) -> FieldMask { + FieldMask::FULL + } } // ───────────────────────────────────────────────────────────────────────────── @@ -313,4 +404,82 @@ mod tests { assert!(rbac.grant_permits("physician", PATIENT, &read)); assert!(rbac.grant_permits("cashier", PATIENT, &read)); } + + // ── F1: axis-2/3/4 default-body + override tests ── + + /// A minimal 2-method impl still compiles after adding default methods — + /// E0046 cannot fire on default bodies — and the defaults are positive-preserving. + #[test] + fn defaults_preserve_two_method_impls() { + let rbac = OneRole; + assert_eq!(rbac.roles_reaching(0x0901), &[] as &[RoleId]); // axis-2 empty + assert!(rbac.row_scope("reader", 0x0901).is_none()); // axis-3 global + assert_eq!(rbac.field_mask("reader", 0x0901), FieldMask::FULL); // axis-4 all fields + } + + /// A 5-method impl overriding all three new axis methods — proves the hooks + /// are individually overridable. + struct FullRbac; + impl ClassRbac for FullRbac { + fn actor_roles(&self, _actor: ActorId<'_>) -> &[RoleId] { + const R: &[RoleId] = &["admin"]; + R + } + fn grant_permits(&self, _role: RoleId, _class: ClassId, _op: &Operation<'_>) -> bool { + true + } + fn roles_reaching(&self, _class: ClassId) -> &[RoleId] { + const R: &[RoleId] = &["super-admin"]; + R + } + fn row_scope(&self, _role: RoleId, _class: ClassId) -> Option { + Some(ScopeSpec { + tenant: Some(42), + predicate_key: 0, + deny: false, + }) + } + fn field_mask(&self, _role: RoleId, _class: ClassId) -> FieldMask { + FieldMask::EMPTY + } + } + + #[test] + fn override_all_three_axis_methods() { + let rbac = FullRbac; + assert_eq!(rbac.roles_reaching(0x0901), &["super-admin"]); + let scope = rbac.row_scope("admin", 0x0901).expect("should be Some"); + assert_eq!(scope.tenant, Some(42)); + assert_eq!(scope.predicate_key, 0); + assert_eq!(rbac.field_mask("admin", 0x0901), FieldMask::EMPTY); + } + + #[test] + fn scope_intersect_is_restrictive() { + let global = ScopeSpec::default(); // tenant None, denies nothing + let t1 = ScopeSpec { + tenant: Some(1), + predicate_key: 0, + deny: false, + }; + let t2 = ScopeSpec { + tenant: Some(2), + predicate_key: 0, + deny: false, + }; + // global ∩ specific = specific (the specific one restricts) + assert_eq!(global.intersect(t1).tenant, Some(1)); + assert!(!global.intersect(t1).deny); + assert_eq!(t1.intersect(global).tenant, Some(1)); + // same tenant ∩ itself = itself (still visible) + assert_eq!(t1.intersect(t1).tenant, Some(1)); + assert!(!t1.intersect(t1).deny); + // distinct tenants: EMPTY intersection (deny), never "self wins" — + // self-wins would widen visibility to tenant 1 that t2 never granted. + assert!(t1.intersect(t2).deny); + assert_eq!(t1.intersect(t2), ScopeSpec::DENY); + // deny is absorbing + assert!(ScopeSpec::DENY.intersect(t1).deny); + assert!(t1.intersect(ScopeSpec::DENY).deny); + } } diff --git a/crates/lance-graph-ogar/src/lib.rs b/crates/lance-graph-ogar/src/lib.rs index 87be866b..c7cf3b44 100644 --- a/crates/lance-graph-ogar/src/lib.rs +++ b/crates/lance-graph-ogar/src/lib.rs @@ -86,6 +86,7 @@ pub mod bridges; // into the class (the Türsteher). The action-axis sibling of OgarClassView. ── pub mod actions; pub use actions::OgarActionProvider; +pub mod rbac_impl; // Per-port bridge aliases (`MedcareBridge` / `OpenProjectBridge` / // `RedmineBridge` / `OdooBridge` / `SmbBridge` / `WoaBridge`) are diff --git a/crates/lance-graph-ogar/src/rbac_impl.rs b/crates/lance-graph-ogar/src/rbac_impl.rs new file mode 100644 index 00000000..4f5c5728 --- /dev/null +++ b/crates/lance-graph-ogar/src/rbac_impl.rs @@ -0,0 +1,133 @@ +//! `rbac_impl` — the OGAR active-record RBAC realization (keystone Q5), as a +//! **local newtype**. +//! +//! The keystone names `impl ClassRbac for OgarClassView` as Q5's active-record +//! RBAC. That exact form is an **orphan-rule violation** here: `OgarClassView` is +//! a foreign type (re-exported from the OGAR git crate) and `ClassRbac` is a +//! foreign trait (`lance_graph_contract::rbac`) — a third crate cannot +//! `impl ForeignTrait for ForeignType` (E0117). So the realization is a local +//! newtype [`OgarRbac`] that carries an **injected** [`GrantSource`]. +//! +//! # The §6 evaporation seam +//! +//! [`OgarRbac`] owns **no grant data** — every answer is read from its +//! `GrantSource`. Today the source is a fixture (or a consumer-supplied table) +//! because the OGAR Core does not yet carry the `project_role.granted` value-tenant +//! (keystone §6 — a tracked core-gap). When §6 lands, the `GrantSource` +//! implementation flips from a fixture to a resolver over `project_role.granted` +//! + the membership `EdgeBlock`, and **`OgarRbac`'s body does not change** (the +//! "evaporation test"). That is what makes this an honest bridge rather than a +//! consumer-side re-implementation of the Core. + +use lance_graph_contract::rbac::{ + grants_permit, ActorId, ClassGrant, ClassId, ClassRbac, Operation, RoleId, +}; + +/// The injected grant source — the §6 evaporation seam. A fixture implements it +/// today; the §6 `project_role.granted` + membership-`EdgeBlock` resolver +/// implements it later, with no change to [`OgarRbac`]. +pub trait GrantSource { + /// Roles the `actor` holds (membership → role fold). + fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId]; + /// The typed `granted` value-tenant of `role` — its `(target_classid, op_mask)` set. + fn grants_of(&self, role: RoleId) -> &[ClassGrant]; +} + +/// The OGAR active-record [`ClassRbac`] (keystone Q5) as a local newtype over an +/// injected [`GrantSource`]. Carries no grant state of its own. +pub struct OgarRbac { + /// The injected grant source (fixture today; §6 tenant resolver later). + pub source: S, +} + +impl OgarRbac { + /// Wrap a [`GrantSource`] as a [`ClassRbac`]. + pub const fn new(source: S) -> Self { + Self { source } + } +} + +impl ClassRbac for OgarRbac { + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] { + self.source.roles_of(actor) + } + + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + grants_permit(self.source.grants_of(role), class, op) + } + // §4 axis-2/3/4 (roles_reaching / row_scope / field_mask) inherit the + // contract defaults — column-level RBAC over the OGAR class surface is the + // §6 follow-on, not this bridge. +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_contract::property::PrefetchDepth; + use lance_graph_contract::rbac::OpMask; + + /// A Vec-backed fixture `GrantSource` — the stand-in the §6 tenant resolver replaces. + struct FixtureGrants { + memberships: Vec<(&'static str, Vec)>, + grants: Vec<(RoleId, Vec)>, + } + impl GrantSource for FixtureGrants { + fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId] { + self.memberships + .iter() + .find(|(a, _)| *a == actor) + .map_or(&[], |(_, r)| r.as_slice()) + } + fn grants_of(&self, role: RoleId) -> &[ClassGrant] { + self.grants + .iter() + .find(|(r, _)| *r == role) + .map_or(&[], |(_, g)| g.as_slice()) + } + } + + const ENCOUNTER: ClassId = 0x0000_0901; + + fn fixture() -> OgarRbac { + OgarRbac::new(FixtureGrants { + memberships: vec![("dr-house", vec!["physician"]), ("betty", vec!["cashier"])], + grants: vec![ + ( + "physician", + vec![ClassGrant::new(0x0901, OpMask::READ.union(OpMask::ACT))], + ), + ("cashier", vec![ClassGrant::new(0x0901, OpMask::READ)]), + ], + }) + } + + #[test] + fn actor_roles_resolve_via_source() { + let r = fixture(); + assert_eq!(r.actor_roles("dr-house"), &["physician"]); + assert_eq!(r.actor_roles("betty"), &["cashier"]); + assert_eq!(r.actor_roles("nobody"), &[] as &[RoleId]); + } + + #[test] + fn physician_acts_cashier_cannot() { + let r = fixture(); + let act = Operation::Act { action: "approve" }; + assert!(r.grant_permits("physician", ENCOUNTER, &act)); + assert!(!r.grant_permits("cashier", ENCOUNTER, &act)); + // both may read + let read = Operation::Read { + depth: PrefetchDepth::Identity, + }; + assert!(r.grant_permits("physician", ENCOUNTER, &read)); + assert!(r.grant_permits("cashier", ENCOUNTER, &read)); + } + + /// Evaporation proof: `OgarRbac` satisfies `ClassRbac` for ANY `GrantSource` + /// — the body is generic over the source, so the §6 resolver drops in unchanged. + fn _is_class_rbac(_: &impl ClassRbac) {} + #[test] + fn ogar_rbac_is_class_rbac_over_any_source() { + _is_class_rbac(&fixture()); + } +} diff --git a/crates/lance-graph-rbac/src/authorize.rs b/crates/lance-graph-rbac/src/authorize.rs index ca71c972..fcf9f5a6 100644 --- a/crates/lance-graph-rbac/src/authorize.rs +++ b/crates/lance-graph-rbac/src/authorize.rs @@ -43,6 +43,8 @@ use crate::access::AccessDecision; use crate::permission::PermissionSpec; use crate::policy::Operation; +use lance_graph_contract::class_view::FieldMask; +use lance_graph_contract::rbac::ScopeSpec; // `ClassId` / `ActorId` / `RoleId` / `ClassRbac` were promoted to // `lance_graph_contract::rbac` (keystone §11) so `lance-graph-ogar`'s @@ -153,6 +155,163 @@ impl ClassRbac for ClassGrants { } } +/// The §5 two-stage authorization result — the positive∧op-gate decision PLUS +/// the row-scope (axis-3) and field-projection (axis-4) a granted read carries. +/// `Allow` carries `scope`+`field_mask`; a non-`Allow` carries `None`/`FULL` +/// (scope is irrelevant when access is refused). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScopedDecision { + /// The stage-1 positive∧op-gate verdict (unchanged from [`authorize`]). + pub decision: AccessDecision, + /// Axis-3 row-scope — the restrictive-AND of every granting role's + /// [`ScopeSpec`]. `None` ⇒ global (no row restriction). + pub scope: Option, + /// Axis-4 field projection — the union of every granting role's + /// [`FieldMask`]. `FieldMask::FULL` on a refused decision. + pub field_mask: FieldMask, +} + +/// §5 two-stage authorize: stage-1 is the unchanged positive∧op-gate +/// ([`authorize`]); stage-2 folds the **granting subset** (the roles that +/// actually permit `op` — the SAME predicate stage-1 uses, NOT `roles_reaching`) +/// into a restrictive-AND row-scope and a union field-mask. +/// +/// A non-`Allow` stage-1 short-circuits (no scope/mask computed). `AccessDecision` +/// is unchanged — the projection lives only here, in [`ScopedDecision`]. +#[must_use] +pub fn authorize_scoped( + rbac: &impl ClassRbac, + actor: ActorId<'_>, + class: ClassId, + op: Operation<'_>, +) -> ScopedDecision { + let decision = authorize(rbac, actor, class, op.clone()); + // Deny OR Escalate (any non-Allow) → no projection. + if !matches!(decision, AccessDecision::Allow) { + return ScopedDecision { + decision, + scope: None, + field_mask: FieldMask::FULL, + }; + } + // Stage 2 — fold over the granting subset (actor_roles ∧ grant_permits). + let mut scope: Option = None; + let mut mask = FieldMask::EMPTY; + for &r in rbac.actor_roles(actor) { + if rbac.grant_permits(r, class, &op) { + // restrictive-AND of row-scopes. A role with NO scope is global — + // it must NOT narrow the fold, so we only intersect *concrete* `Some` + // scopes and leave `None` (the global sentinel) untouched. Folding a + // `None` in as `ScopeSpec::default()` would replace the "no restriction" + // sentinel with a materialized empty-tenant scope and force every + // consumer down the `Some` branch even when nothing restricts. + if let Some(rs) = rbac.row_scope(r, class) { + scope = Some(match scope { + None => rs, + Some(acc) => acc.intersect(rs), + }); + } + // union of field projections (a user sees any column any role permits). + mask = mask.union(rbac.field_mask(r, class)); + } + } + ScopedDecision { + decision, + scope, + field_mask: mask, + } +} + +#[cfg(test)] +mod scoped_tests { + use super::*; + use lance_graph_contract::rbac::{ClassGrant, OpMask}; + + // Two roles, BOTH granting Act on the class, with DIFFERENT row_scope + + // DIFFERENT field_mask — so the fold's restrictive-AND scope + union mask are + // both exercised (the test FAILS if scope is OR'd or mask is intersected). + struct DualGrantRbac; + const CLS: ClassId = 0x0000_0901; + impl ClassRbac for DualGrantRbac { + fn actor_roles(&self, _actor: ActorId<'_>) -> &[RoleId] { + const R: &[RoleId] = &["role_a", "role_b"]; + R + } + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + (role == "role_a" || role == "role_b") + && class == CLS + && matches!(op, Operation::Act { .. }) + } + fn row_scope(&self, role: RoleId, _class: ClassId) -> Option { + match role { + "role_a" => Some(ScopeSpec { + tenant: Some(7), + predicate_key: 0, + deny: false, + }), + "role_b" => Some(ScopeSpec { + tenant: None, + predicate_key: 2, + deny: false, + }), + _ => None, + } + } + fn field_mask(&self, role: RoleId, _class: ClassId) -> FieldMask { + match role { + "role_a" => FieldMask::from_positions(&[0, 1]), + "role_b" => FieldMask::from_positions(&[1, 2]), + _ => FieldMask::EMPTY, + } + } + } + + #[test] + fn scoped_allow_ands_scope_and_unions_mask() { + let _ = ClassGrant::new(0, OpMask::ACT); // touch the imports + let d = authorize_scoped(&DualGrantRbac, "u", CLS, Operation::Act { action: "x" }); + assert_eq!(d.decision, AccessDecision::Allow); + // restrictive-AND of {tenant 7, pk 0} ∩ {tenant None, pk 2} + let expected_scope = ScopeSpec { + tenant: Some(7), + predicate_key: 0, + deny: false, + } + .intersect(ScopeSpec { + tenant: None, + predicate_key: 2, + deny: false, + }); + assert_eq!(d.scope, Some(expected_scope)); + assert_eq!(d.scope.unwrap().tenant, Some(7)); + assert_eq!(d.scope.unwrap().predicate_key, 2); + // union of {0,1} ∪ {1,2} = {0,1,2} + assert_eq!( + d.field_mask, + FieldMask::from_positions(&[0, 1]).union(FieldMask::from_positions(&[1, 2])) + ); + assert!(d.field_mask.has(0) && d.field_mask.has(1) && d.field_mask.has(2)); + } + + // Zero roles → Deny short-circuits with no scope, FULL mask. + struct NoRoles; + impl ClassRbac for NoRoles { + fn actor_roles(&self, _a: ActorId<'_>) -> &[RoleId] { + &[] + } + fn grant_permits(&self, _r: RoleId, _c: ClassId, _o: &Operation<'_>) -> bool { + false + } + } + #[test] + fn scoped_deny_yields_no_scope_full_mask() { + let d = authorize_scoped(&NoRoles, "ghost", CLS, Operation::Act { action: "x" }); + assert!(matches!(d.decision, AccessDecision::Deny { .. })); + assert_eq!(d.scope, None); + assert_eq!(d.field_mask, FieldMask::FULL); + } +} + #[cfg(test)] mod tests { use super::*;