diff --git a/.claude/board/CONSUMER_SCAN_TODO.md b/.claude/board/CONSUMER_SCAN_TODO.md new file mode 100644 index 00000000..36219034 --- /dev/null +++ b/.claude/board/CONSUMER_SCAN_TODO.md @@ -0,0 +1,110 @@ +# Consumer Scan TODO — cross-repo latent-issue sweep (2026-06-23) + +> Two latent issue-classes surfaced this session. Both are *anticipatable* +> across every consumer, not one-offs. This is the scan checklist: run each +> check against each client, tick the box, file an issue on any hit. +> +> Status keys: `[ ]` = not scanned · `[x]` = scanned + clean · `[!]` = hit (file issue) · `[fixed]` = scanned + repaired this arc. + +--- + +## Issue Class A — OGAR codebook-mirror drift + +**Symptom:** `error[E0080]` at `lance-graph-ogar/src/lib.rs` COUNT_FUSE, or a +runtime `assert_codebook_parity` panic — fires in **every** build that vendors +the OGAR git dep when `ogar_vocab::class_ids::ALL` and a local mirror disagree +on concept count or domain. + +**Root cause:** a hand-maintained copy of the codebook that is NOT bound to +`ogar_vocab` by `pub use`. The zero-dep contract mirror cannot re-export (it has +no dep on ogar-vocab by design), so it is the ONE surface that drifts. + +**The rule (E-CODEBOOK-MINT-IS-A-CROSS-REPO-ARC):** an OGAR concept mint is a +cross-repo arc. DoD = OGAR `ogar-vocab` entry **+** `lance-graph-contract::ogar_codebook` +mirror rows + `ConceptDomain` variant + `canonical_concept_domain` arm **+** +`lance-graph-ogar::parity::domains_agree` arm — all in the same arc. + +### Scan check +For each client: does it carry a codebook/class-id table that is a **copy** +rather than a `pub use ogar_vocab::class_ids::*` re-export? If yes → drift risk. + +``` +# per repo: +rg -n "ConceptDomain|canonical_concept_domain|class_ids::ALL|0x0[0-9A-F]{3}" --type rust +# a re-export ("pub use ogar_vocab::class_ids::*") is SAFE; a literal id table is the risk. +``` + +### Per-client status +- [fixed] **lance-graph-contract** `ogar_codebook::CODEBOOK` — the one hand-maintained mirror. Synced to 43 (added 0x0B auth family). This is the only client that can structurally drift; guard it hardest. +- [x] **openproject-nexgen-rs** `op-canon/src/class_ids.rs` — `pub use ogar_vocab::class_ids::*` re-export → cannot drift. CLEAN. +- [x] **MedCare-rs** — SWEPT 2026-06-23 (workspace-wide id-table grep): no literal `0xDDCC`/`ConceptDomain` table in any `medcare-*` crate. Vendors `lance-graph-ogar` + `ogar-vocab` (git main); the Health set is inherited via the upstream gate, not a hand-list. CLEAN. +- [x] **smb-office-rs** — SWEPT 2026-06-23: no local codebook copy; consumes via `lance-graph-contract`. CLEAN. +- [x] **woa-rs** — SWEPT 2026-06-23: no literal id table; `WoaPort` pulls classid via PortSpec. CLEAN. +- [x] **q2 / ladybug-rs / crewai-rust / n8n-rs / rs-graph-llm** — SWEPT 2026-06-23: none carries a literal OGAR id table (most don't consume the codebook at all). CLEAN. + +> **Sweep result (2026-06-23):** a workspace-wide grep for literal codebook id-tables +> (`const … CODEBOOK`, `=> ConceptDomain::…`, `pub const … : u16 = 0xDD…`) returns +> exactly TWO authoritative tables: `OGAR/ogar-vocab` (the source) and +> `lance-graph-contract::ogar_codebook` (the mirror, now fused at 43). Every other +> consumer re-exports or pulls via PortSpec. **Class-A drift surface = the one mirror.** + +### Guard to add (prevents recurrence) +- [ ] Any client that maintains its own concept list MUST either (a) `pub use ogar_vocab::class_ids::*`, or (b) add a `const _` COUNT_FUSE against `ogar_vocab::class_ids::ALL.len()`. Hand-lists with neither are the bug. + +--- + +## Issue Class B — PaaS deploy crash (Railway / Heroku / Cloud Run / Fly) + +**Symptom (B1 — unreachable):** container starts, binds a fixed port (e.g. +`0.0.0.0:3000`), platform routes its public edge to `$PORT` (often 8080) → the +app is up but the proxy can't reach it (the `shuttle.proxy…:45472 > :3000` +non-resolve medcare hit). + +**Symptom (B2 — crash-loop):** the app writes to a CWD-relative dir +(`./audit`, `./data`, `./cache`) that is read-only on the container image → +`PermissionDenied` at boot, fail-closed crash-loop (the medcare +`MEDCARE_AUDIT_DIR` crash). + +### Scan checks +``` +# B1 — fixed-port bind without $PORT fallback: +rg -n "TcpListener::bind|SocketAddr|\.listen|bind\(" --type rust src +# HIT if the bind addr is a config/literal with NO `std::env::var("PORT")` branch. + +# B2 — CWD-relative writable path in a sink/store/cache initializer: +rg -n '"\./|from\("\.|PathBuf::from\("[^/]' --type rust src +# HIT if a write target defaults to a relative path instead of a writable data root. +``` + +### Per-client status (web-app `main`/`server`/`serve` found) +- [fixed] **MedCare-rs** `medcare-server/src/main.rs` — `$PORT` bind branch added; audit dir now derives `/audit` (writable), not `./audit`. Both classes repaired. +- [ ] **woa-rs** `src/main.rs` — axum; verify `$PORT` bind + Tresor/PDF/sled write dirs use a writable data root (Stefan's Railway deploy is production). +- [ ] **openproject-nexgen-rs** `op-server/src/main.rs` — verify `$PORT` + any RLS/audit write dir. +- [ ] **q2** `crates/quarto-hub/src/server.rs` — verify `$PORT` + hub doc-store path. +- [ ] **rs-graph-llm** `insurance-claims-service` / `medical-document-service` / `recommendation-service` / `notebook` mains — verify `$PORT` + any session/Lance store path. +- [ ] **n8n-rs** `n8n-server/src/main.rs` — verify `$PORT` + sled/background-work dir. +- [ ] **crewai-rust** `src/bin/server.rs` — verify `$PORT` + any cache dir. +- [ ] **ladybug-rs** `src/bin/server.rs` — verify `$PORT` (note: uses CogRedis, separate concern). +- [ ] **spider** `spider_worker/src/main.rs` — verify `$PORT` if exposed. +- [ ] **lance-graph** `lance-graph-planner/src/serve.rs`, `cognitive-shader-driver/src/{serve.rs,bin/serve.rs}` — these are lab/serve surfaces; verify `$PORT` before any are deployed. + +### Canonical pattern (copy from medcare) +```rust +// B1: bind $PORT when set (all interfaces), else fall back to config. +let addr: SocketAddr = match std::env::var("PORT") { + Ok(p) if !p.trim().is_empty() => format!("0.0.0.0:{}", p.trim()).parse()?, + _ => settings.listen.parse()?, +}; +// B2: derive writable paths from the data root the platform mounts, +// never a CWD-relative "./audit" / "./data". +``` + +--- + +## How to run this sweep +1. One pass per repo with the rg checks above (5 min each). +2. Tick `[x]` clean / `[!]` hit. For each `[!]`, file an issue in that repo's + board (`ISSUES.md` / `Altlasten.md` / `braid`) pointing at this doc. +3. Class-A hits also append to OGAR `EPIPHANIES` (mint-arc rule) if a new + un-guarded mirror is discovered. +4. Re-run after the next OGAR codebook mint — Class A is recurring by nature. diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 68d25f53..0a0ad93c 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,92 @@ +## 2026-06-23 — E-AUTH-CLASS-WIRED-TO-RBAC — the OGIT-imported 0x0B AuthStore family is now the membrane front-door of authorize() + +**Status:** FINDING (2026-06-23). The OGIT `NTO/Auth/Configuration` entity (arago's +`auth_store`, 1:1 with OGAR `0x0B01`) is wired into `lance-graph-rbac` as the +authorization membrane (keystone §7 / I-K7: the inner `authorize()` kernel never +touches a token). `lance-graph-rbac/src/auth.rs`: + +- `AuthProvider` enum = the preminted `0x0B` family (`Store`/`Zitadel`/`Zanzibar`/ + `OryKeto`); each variant's classid is resolved through the **zero-dep contract + mirror** (`contract::ogar_codebook::canonical_concept_id`) — one source, no + hardcoded `0x0B0N`, no `ogar-vocab` dep (BBB-safe). +- `ClaimGrammar` per provider (subject/roles/tenant claim keys) — Zitadel's + project-roles URN, Zanzibar's user/relation/namespace tuple grammar, the + plain-OIDC base — the §7 "each profile carries its claim grammar as data". +- `AuthProvider::resolve(sub, roles, tenant) -> ResolvedIdentity` — the §7 mapping + (`sub → actor`, `role-key → roles`, `org → tenant`/scope). `ResolvedIdentity` is + the ONLY thing that crosses inward; it feeds `authorize(rbac, &id.actor, class, op)`. +- 4 tests incl. `resolved_identity_feeds_authorize` (membrane → kernel end-to-end) + and `provider_class_ids_resolve_through_the_contract_mirror` (pins the 0x0B family + to the codebook). + +Token *extraction* (JWT/JSON parse via `grammar()`) stays at the consumer membrane — +no JWT/serde dep leaks into the rbac tier. The consumer maps IdP role strings → its +own role set. Cross-ref: OGAR `CLASSID-RBAC-KEYSTONE-SPEC.md` §7, E-RBAC-AUTHORIZE-PROBE-GREEN. + +## 2026-06-23 — E-RBAC-AUTHORIZE-PROBE-GREEN — classid-keyed `authorize()` reproduces the shipped membrane gate bit-for-bit; keystone §5 promoted CONJECTURE→FINDING (for the in-repo reference) + +**Status:** FINDING (probe green, 2026-06-23). The OGAR `CLASSID-RBAC-KEYSTONE-SPEC.md` +§10 names `PROBE-OGAR-RBAC-AUTHORIZE` as the gate that must pass before any consumer +collapses onto classid-keyed authorization: the classid-keyed kernel must reproduce a +reference system's decision **bit-for-bit**. Built against the in-repo reference (the +shipped `lance_graph_rbac::policy::Policy::evaluate` — the "reconcile the shipped +MembraneGate path with the keystone" framing of `ISS-RBAC-AUTHORIZE-BY-CLASSID`): + +- `lance-graph-rbac/src/authorize.rs` — `ClassRbac` trait (§4), `authorize(rbac, actor, + class: ClassId, op)` (§5 positive ∧ op-gate kernel), `ClassGrants` (`PermissionSpec` + **re-keyed by `ClassId`**, §11). The kernel mirrors the shipped deny reasons exactly + ("unknown role" / "insufficient read depth" / "predicate not writable" / "action not + allowed") so the comparison is bit-for-bit, not just allow/deny. +- `probe_ogar_rbac_authorize` — 15-tuple corpus across all three SMB roles, all three op + kinds, the allow path, every distinct deny reason, the depth boundary, and the + unknown-actor path → all equal to `Policy::evaluate`. GREEN. +- `probe_is_falsifiable_under_wrong_keying` — proves the gate is not vacuous: a wrong + classid flips an Allow, so the corpus genuinely tests the keying + kernel, not a + delegation. + +**Honest fence (what is NOT yet certified):** the shipped reference is positive +role→permission only — no row-scope predicate, no field projection in the decision. So +this gate certifies the §5 *positive ∧ op-gate* half and the §11 classid re-keying. +The §5 stage-2 row-scope predicate and the projecting `Allow { scope, mask }` return +remain keystone work; the stronger references (Odoo `ir.model.access ∧ ir.rule`, +OpenFGA) exercise scope and are the follow-on probes. **Necessary, not yet sufficient** +for the full keystone — but it is the step-4 reconciliation, and it unblocks the +medcare #169 consumer-collapse (`authorize(actor, HealthcarePort::class_id("Patient"))`) +against the positive-grant half. Trait promotion to `lance-graph-contract` (§11) and the +scope-bearing references are the next deliverables. + +## 2026-06-23 — E-CODEBOOK-MINT-IS-A-CROSS-REPO-ARC — an OGAR concept mint is NOT done until the lance-graph-contract mirror lands in the SAME arc + +**Status:** FINDING (cross-repo cascade, 2026-06-23). Minting the `0x0B` +AuthStore family in OGAR (`ogar-vocab` PR #110, merged to OGAR `main`) added 4 +concepts to `ogar_vocab::class_ids::ALL` (39 → 43). The `lance-graph-contract` +zero-dep mirror (`ogar_codebook::CODEBOOK`) was NOT updated in the same arc, so +the **compile-time `COUNT_FUSE`** in `lance-graph-ogar` (`assert!(mirror::CODEBOOK.len() +== ogar_vocab::class_ids::ALL.len())`) fired `error[E0080]` in **every** lance-graph +consumer that vendors the OGAR git dep — medcare CI went red on a `cargo build`, +not just a test. The fuse did exactly its job; the gap was process. + +The lesson, promoted to a rule: + +1. **The codebook has TWO authoritative homes that move in lockstep.** OGAR + `ogar-vocab` (the source) and `lance-graph-contract::ogar_codebook` (the + zero-dep wire mirror). The `COUNT_FUSE` (compile-time) + `assert_codebook_parity` + (runtime, in `lance-graph-ogar`) bind them. A mint touches BOTH or it breaks + the build of everything downstream. +2. **A mint is a cross-repo arc, not a single-repo PR.** The Definition-of-Done + for "mint concept X in OGAR" includes: (a) the OGAR `ogar-vocab` entry + + `ConceptDomain` variant + `canonical_concept_domain` arm; (b) the paired + `lance-graph-contract` mirror CODEBOOK rows + `ConceptDomain::X` variant + + `0xDD => X` arm; (c) the `lance-graph-ogar` `domains_agree` match arm (else the + runtime parity test panics); (d) the consumer coverage gates that inherit the + concept set (medcare's RLS fail-closed gate inherits the Health set this way — + a new Health concept becomes a fail-closed boot error, by design). +3. **Fix landed here:** mirror CODEBOOK +4 auth rows (auth_store 0x0B01, + auth_zitadel 0x0B02, auth_zanzibar 0x0B03, auth_ory_keto 0x0B04), + `ConceptDomain::Auth`, `0x0B => Auth`, and the `domains_agree` `(O::Auth, C::Auth)` + arm — restoring 43 == 43. Confirmed by the OGIT Configuration ⊨ auth_store + convergence (arago's Jan-2026 bridge entity). See OGAR `docs/CLASSID-RBAC-KEYSTONE-SPEC.md` + §7 + OGAR `EPIPHANIES` for the mint provenance. ## 2026-06-23 — E-DOLCE-ODOO-SILENT-SUFFIX-DRIFT — two hydrator-side suffix rules silently failed their own comments; cross-validation against `od_ontology::alignment` caught it (odoo-rs PR #15) **Status:** FINDING (cross-validation result from a sibling repo). Two diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index e6477db0..790046c0 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -1,5 +1,9 @@ # Issues Log — Open + Resolved (double-entry, append-only) +## 2026-06-23 — ISS-OGAR-AUTH-MIRROR-DRIFT — `0x0B` AuthStore mint broke the contract mirror's COUNT_FUSE in every consumer + +**Status:** RESOLVED 2026-06-23 (this commit). OGAR `ogar-vocab` PR #110 minted the `0x0B` AuthStore family (4 concepts: auth_store 0x0B01, auth_zitadel 0x0B02, auth_zanzibar 0x0B03, auth_ory_keto 0x0B04) and merged to OGAR `main`, taking `ogar_vocab::class_ids::ALL` from 39 → 43. The paired `lance-graph-contract::ogar_codebook::CODEBOOK` mirror was NOT updated in the same arc, so the compile-time `COUNT_FUSE` in `lance-graph-ogar` (`assert!(mirror::CODEBOOK.len() == ogar_vocab::class_ids::ALL.len())`) fired `error[E0080]` (`vendor/lance-graph/crates/lance-graph-ogar/src/lib.rs:113`) in **every** consumer vendoring the OGAR git dep — medcare CI went red on `cargo build`. **Resolution:** added the 4 auth rows + `ConceptDomain::Auth` + `0x0B => Auth` to the mirror, and the `(O::Auth, C::Auth)` arm to `lance-graph-ogar::parity::domains_agree` (else the runtime `assert_codebook_parity` test panics). 43 == 43 restored; `cargo test -p lance-graph-contract` green. **Process fix (see EPIPHANIES E-CODEBOOK-MINT-IS-A-CROSS-REPO-ARC):** an OGAR concept mint is a cross-repo arc — the OGAR entry + the contract mirror + the `domains_agree` arm land together, never split across sessions. **Merge note (2026-06-23):** main landed #595 (auth sync) + #597 (PRODUCT + ACCOUNTING_ACCOUNT, OGAR #111) first; on merge this branch took main's superset `ogar_codebook.rs` (45 concepts incl. the `AppPrefix` render layer), so the auth mirror rows here are subsumed — the `domains_agree` Auth arm + this finding stand. + ## 2026-06-22 — ISS-CONTRACT-APP-PREFIX-MIRROR — `contract::ogar_codebook` lacks the OGAR#97 `APP_PREFIX` / `render_classid_for` mirror, so membrane consumers must hand-stamp the hi-u16 render prefix **Status:** RESOLVED 2026-06-22 (`claude/contract-app-prefix-mirror`) · Owner: lance-graph-contract · Surfaced by: `.claude/knowledge/ogar-consumer-preflight.md` (the consumer spellbook). diff --git a/crates/lance-graph-rbac/src/auth.rs b/crates/lance-graph-rbac/src/auth.rs new file mode 100644 index 00000000..0ef57ec2 --- /dev/null +++ b/crates/lance-graph-rbac/src/auth.rs @@ -0,0 +1,291 @@ +//! `auth` — the OGIT-imported AuthStore class family (`0x0B`) wired to the +//! authorization kernel (OGAR keystone §7). +//! +//! # The membrane, not the kernel +//! +//! The keystone draws a hard line (I-K7): **the inner [`authorize`] kernel never +//! touches a token.** A token is parsed once at the membrane; the chosen +//! `auth_store` provider profile resolves its claims to canonical classids/roles; +//! and only those *resolved keys* go inward. This module is that membrane step — +//! the OGIT `NTO/Auth/Configuration` entity (arago's `auth_store`, 1:1 with OGAR's +//! `0x0B01`) made executable: +//! +//! ```text +//! raw token ──parse──▶ RawClaims ──AuthProvider::resolve──▶ ResolvedIdentity +//! (membrane) (per-IdP grammar) (actor + roles + tenant) +//! │ +//! ▼ +//! authorize(rbac, &id.actor, class, op) +//! ``` +//! +//! The [`AuthProvider`] variants ARE the preminted `0x0B` family +//! (`auth_store` 0x0B01 base + `auth_zitadel`/`auth_zanzibar`/`auth_ory_keto` +//! provider profiles). Selecting a provider = picking its codebook classid; the +//! classid is resolved through the zero-dep contract mirror +//! ([`lance_graph_contract::ogar_codebook::canonical_concept_id`]), so this crate +//! pulls the identity from ONE source (BBB-safe: no `ogar-vocab` dependency). +//! +//! # The §7 mapping +//! +//! Each provider carries its own *claim grammar* as data: which claim key holds +//! the subject, the role list, and the org/tenant. `resolve` applies it — +//! `sub → actor`, `role-key → roles`, `org → tenant` (the scope axis) — and +//! returns owned [`ResolvedIdentity`] strings. Mapping the resolved IdP role +//! strings to the app's own role set is the *consumer's* job (a small fixed +//! IdP-role → app-role table); see [`ResolvedIdentity`] and the tests for the +//! handoff into [`authorize`]. + +use crate::authorize::ClassId; + +/// The preminted AuthStore class family (`0x0B`). Each variant is one codebook +/// concept; the classid is resolved through the contract mirror so there is no +/// hardcoded `0x0B0N` and no `ogar-vocab` dependency. `Store` is the base +/// (provider-agnostic); the others are per-IdP profiles that is-a `Store`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuthProvider { + /// `auth_store` (0x0B01) — the base. Provider-agnostic claim resolution. + Store, + /// `auth_zitadel` (0x0B02) — Zitadel claim grammar (org-project-roles URN). + Zitadel, + /// `auth_zanzibar` (0x0B03) — Zanzibar / OpenFGA tuple grammar. + Zanzibar, + /// `auth_ory_keto` (0x0B04) — Ory Keto. + OryKeto, +} + +impl AuthProvider { + /// The canonical concept name (the codebook key). + #[must_use] + pub const fn concept(self) -> &'static str { + match self { + Self::Store => "auth_store", + Self::Zitadel => "auth_zitadel", + Self::Zanzibar => "auth_zanzibar", + Self::OryKeto => "auth_ory_keto", + } + } + + /// The codebook classid (the low-`u16`), resolved through the zero-dep + /// contract mirror — the single source of truth, no hardcoded `0x0B0N`. + /// Panics only if the contract mirror and this enum drift, which the + /// `provider_class_ids_resolve_through_the_contract_mirror` test forbids. + #[must_use] + pub fn class_id(self) -> u16 { + lance_graph_contract::ogar_codebook::canonical_concept_id(self.concept()) + .expect("AuthProvider concept must exist in the contract codebook mirror") + } + + /// Reverse: a codebook classid (low `u16`) back to its provider, if it is in + /// the `0x0B` AuthStore family. `None` for any non-auth id. + #[must_use] + pub fn from_class_id(id: u16) -> Option { + [Self::Store, Self::Zitadel, Self::Zanzibar, Self::OryKeto] + .into_iter() + .find(|p| p.class_id() == id) + } + + /// As a full 32-bit `ClassId` (hi-`u16` core prefix `0x0000`, lo-`u16` + /// concept) — the form [`authorize`](crate::authorize::authorize) and the + /// `NodeGuid` classid take. Auth concepts are core (cross-app), so the + /// render prefix is `0x0000`. + #[must_use] + pub fn classid(self) -> ClassId { + u32::from(self.class_id()) + } + + /// The claim-key grammar for this provider — which claim names carry the + /// subject, the role list, and the org/tenant. The per-IdP grammar the + /// keystone §7 says each profile "carries as data". `Store` uses the plain + /// OIDC defaults; the named providers override the ones that differ. + #[must_use] + pub const fn grammar(self) -> ClaimGrammar { + match self { + // Plain OIDC defaults. + Self::Store | Self::OryKeto => ClaimGrammar { + subject_claim: "sub", + roles_claim: "roles", + tenant_claim: "org", + }, + // Zitadel: roles live under the project-roles URN; org is the URN org id. + Self::Zitadel => ClaimGrammar { + subject_claim: "sub", + roles_claim: "urn:zitadel:iam:org:project:roles", + tenant_claim: "urn:zitadel:iam:org:id", + }, + // Zanzibar/OpenFGA: the subject is the tuple's user; relations are roles. + Self::Zanzibar => ClaimGrammar { + subject_claim: "user", + roles_claim: "relation", + tenant_claim: "namespace", + }, + } + } + + /// Apply the §7 mapping: `sub → actor`, `role-key → roles`, `org → tenant`. + /// `subject` is the already-extracted subject value; `role_values` the + /// already-extracted role list; `tenant` the org/tenant. (Extraction from a + /// concrete token uses [`grammar`](Self::grammar) at the membrane — kept out + /// of this crate so no JWT/JSON dependency leaks into the contract tier.) + #[must_use] + pub fn resolve( + self, + subject: impl Into, + role_values: impl IntoIterator, + tenant: Option, + ) -> ResolvedIdentity { + ResolvedIdentity { + provider: self, + actor: subject.into(), + roles: role_values.into_iter().collect(), + tenant, + } + } +} + +/// The claim-key grammar a provider profile carries as data (keystone §7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClaimGrammar { + /// Claim holding the subject (→ actor). + pub subject_claim: &'static str, + /// Claim holding the role list (→ roles). + pub roles_claim: &'static str, + /// Claim holding the org / tenant (→ scope axis). + pub tenant_claim: &'static str, +} + +/// The resolved identity — the ONLY thing that crosses the membrane inward +/// (no token, per I-K7). Owned strings: the actor (from `sub`), the IdP role +/// strings (mapped to the app's role set by the consumer), and the tenant +/// (scope axis). The provider it was resolved through is retained for audit / +/// provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedIdentity { + /// Which `0x0B` profile resolved this identity. + pub provider: AuthProvider, + /// The actor — the OIDC `sub`, resolved to a membership key. + pub actor: String, + /// The IdP role strings. The consumer maps these to its own role set (a + /// fixed IdP-role → app-role table) before calling + /// [`authorize`](crate::authorize::authorize). + pub roles: Vec, + /// The org / tenant — the scope axis (§5 stage 2). `None` = unscoped. + pub tenant: Option, +} + +impl ResolvedIdentity { + /// Does the resolved identity carry `role` (raw IdP string)? Convenience for + /// the consumer's IdP-role → app-role mapping. + #[must_use] + pub fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } + + /// The auth-class classid this identity was resolved through — for the audit + /// witness (which `0x0B` profile authorized the actor). + #[must_use] + pub fn auth_classid(&self) -> ClassId { + self.provider.classid() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::authorize::{authorize, ClassGrants}; + use crate::permission::PermissionSpec; + use crate::policy::Operation; + + #[test] + fn provider_class_ids_resolve_through_the_contract_mirror() { + // The 0x0B family resolves through the zero-dep contract mirror — one + // source, no hardcoded ids. Pins the OGIT-imported auth class to the + // codebook. + assert_eq!(AuthProvider::Store.class_id(), 0x0B01); + assert_eq!(AuthProvider::Zitadel.class_id(), 0x0B02); + assert_eq!(AuthProvider::Zanzibar.class_id(), 0x0B03); + assert_eq!(AuthProvider::OryKeto.class_id(), 0x0B04); + // Full classid is core-prefixed (hi u16 = 0x0000 — auth is cross-app). + assert_eq!(AuthProvider::Store.classid(), 0x0000_0B01); + // Round-trips. + for p in [ + AuthProvider::Store, + AuthProvider::Zitadel, + AuthProvider::Zanzibar, + AuthProvider::OryKeto, + ] { + assert_eq!(AuthProvider::from_class_id(p.class_id()), Some(p)); + } + // A non-auth id is not in the family. + assert_eq!(AuthProvider::from_class_id(0x0901), None); // patient + } + + #[test] + fn provider_grammars_match_keystone_section_7() { + // Zitadel's project-roles URN + org-id URN (the §7 worked example). + let z = AuthProvider::Zitadel.grammar(); + assert_eq!(z.roles_claim, "urn:zitadel:iam:org:project:roles"); + assert_eq!(z.tenant_claim, "urn:zitadel:iam:org:id"); + // Zanzibar's tuple grammar (user / relation / namespace). + let zn = AuthProvider::Zanzibar.grammar(); + assert_eq!(zn.subject_claim, "user"); + assert_eq!(zn.roles_claim, "relation"); + // Store is the plain-OIDC base. + assert_eq!(AuthProvider::Store.grammar().subject_claim, "sub"); + } + + #[test] + fn resolve_maps_sub_roles_and_org() { + let id = AuthProvider::Zitadel.resolve( + "user-42", + ["physician".to_string(), "billing".to_string()], + Some("clinic-7".to_string()), + ); + assert_eq!(id.actor, "user-42"); + assert!(id.has_role("physician")); + assert!(!id.has_role("admin")); + assert_eq!(id.tenant.as_deref(), Some("clinic-7")); + assert_eq!(id.auth_classid(), 0x0000_0B02); + } + + #[test] + fn resolved_identity_feeds_authorize() { + // The end-to-end seam: an identity resolved at the membrane feeds the + // inner authorize() kernel. The consumer maps the IdP role string + // ("accountant") to the app's known &'static role name; here the + // mapping is identity, modelling an IdP whose role names match the app. + let grants = ClassGrants::new() + .with_grant( + "accountant", + 0x0000_C002, // probe-local Invoice classid + PermissionSpec::full("Invoice", &["status"], &["approve"]), + ) + .with_actor("user-42", vec!["accountant"]); + + let id = AuthProvider::Store.resolve( + "user-42", + ["accountant".to_string()], + Some("clinic-7".to_string()), + ); + + // Membrane resolved → kernel authorizes on the resolved actor. + let decision = authorize( + &grants, + &id.actor, + 0x0000_C002, + Operation::Act { action: "approve" }, + ); + assert!(decision.is_allowed()); + + // A write to a predicate outside the grant's writable set is denied + // (the grant allows writing "status", not "due_date") — kernel unchanged. + let denied = authorize( + &grants, + &id.actor, + 0x0000_C002, + Operation::Write { + predicate: "due_date", + }, + ); + assert!(denied.is_denied()); + } +} diff --git a/crates/lance-graph-rbac/src/authorize.rs b/crates/lance-graph-rbac/src/authorize.rs new file mode 100644 index 00000000..32dbd69d --- /dev/null +++ b/crates/lance-graph-rbac/src/authorize.rs @@ -0,0 +1,362 @@ +//! `authorize` — the classid-keyed RBAC kernel (OGAR keystone §5) and its +//! falsification gate, `PROBE-OGAR-RBAC-AUTHORIZE` (keystone §10). +//! +//! # What this is +//! +//! The shipped membrane path is [`crate::policy::Policy::evaluate`] — a +//! **string-keyed** check (`role_name`, `entity_type`, `Operation`). The OGAR +//! `CLASSID-RBAC-KEYSTONE-SPEC.md` §5 specifies the canonical successor: +//! `authorize(rbac, actor, class: ClassId, op)` — **classid-keyed**, where the +//! entity is named by its codebook `ClassId` (the `NodeGuid.classid`), not a +//! string. The keystone §11 build order ends at step (4): a probe that proves +//! the classid-keyed kernel reproduces a reference system's decision +//! **bit-for-bit** before any consumer collapses onto it (step 5). Until that +//! probe is green the keystone is **CONJECTURE**. +//! +//! This module is steps (1)+(3)+(4) made concrete against the in-repo reference +//! (the shipped `Policy` — the "reconcile the shipped MembraneGate path with the +//! keystone" framing of `ISS-RBAC-AUTHORIZE-BY-CLASSID`): +//! +//! - [`ClassRbac`] — the §4 grant-resolution trait, classid-keyed. +//! - [`authorize`] — the §5 two-stage kernel (positive ∧ op-gate), collapsed to +//! the shipped [`AccessDecision`] so the parity comparison is exact. +//! - [`ClassGrants`] — `PermissionSpec` **re-keyed by `ClassId`** (§11 "re-key +//! `PermissionSpec` to `ClassId`"); the independent representation the probe +//! tests. +//! - `tests::probe_ogar_rbac_authorize` — the gate. For a fixed corpus of +//! `(actor, class, op)` it asserts `authorize(...) == Policy::evaluate(...)`, +//! **deny-reason included**. A wrong keying or a wrong kernel branch fails it. +//! +//! # Scope of this probe (honest fence) +//! +//! The reference here is the **shipped in-repo gate**, which is positive +//! role→permission only (no row-scope predicate, no field projection in the +//! decision). So this probe certifies the §5 *positive ∧ op-gate* half and the +//! classid re-keying. The §5 stage-2 *row-scope* predicate and the projecting +//! `Allow { scope, mask }` return remain keystone work; the keystone's stronger +//! reference options (Odoo `ir.model.access ∧ ir.rule`, OpenFGA) exercise scope +//! and are the follow-on probes. This gate is necessary, not yet sufficient for +//! the full keystone — but it is the step-4 reconciliation the shipped path +//! needs, and it moves "classid keying reproduces the membrane" from CONJECTURE +//! to FINDING. + +use crate::access::AccessDecision; +use crate::permission::PermissionSpec; +use crate::policy::Operation; + +/// The codebook class identity an authorization targets — the `NodeGuid.classid` +/// (or its low-`u16` codebook id widened). Opaque to the kernel: `authorize` +/// only compares and looks it up, never decodes it (per the keystone, the kernel +/// "never touches a token" — only resolved keys go inward). +pub type ClassId = u32; + +/// An actor identity. In the full keystone this is the OIDC `sub` resolved to a +/// membership-set; here it is the opaque key the [`ClassRbac`] impl maps to +/// roles. +pub type ActorId<'a> = &'a str; + +/// A role identity (a minted role classid in the full keystone; the role *name* +/// here, to reconcile against the shipped string-keyed `Policy`). +pub type RoleId = &'static str; + +/// The §4 grant-resolution surface, **classid-keyed**. Both the membrane gate +/// and the cognitive loop resolve access through this one trait; the impl owns +/// the membership→role folding and the (role, class) grant table. Kept +/// rbac-crate-local for the probe; the keystone §11 promotes the trait to +/// `lance-graph-contract` once the gate is green (tracked as follow-up). +pub trait ClassRbac { + /// Roles the actor holds, already folded through + /// membership → member_role → role (the §4 `actor_roles`). Empty ⇒ the actor + /// is unknown to the policy. + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId]; + + /// Does `role` carry a grant on `class` that permits `op`? The positive + /// `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; +} + +/// The §5 kernel — positive intersection ∧ op-gate, collapsed to the shipped +/// [`AccessDecision`]. An actor is allowed iff it holds at least one role whose +/// grant on `class` permits `op`. Deny reasons mirror [`Policy::evaluate`] +/// exactly so the parity gate can compare bit-for-bit: +/// - no roles at all ⇒ `Deny { "unknown role" }` +/// - roles present, none permit ⇒ the op-specific reason. +/// +/// [`Policy::evaluate`]: crate::policy::Policy::evaluate +#[must_use] +pub fn authorize( + rbac: &impl ClassRbac, + actor: ActorId<'_>, + class: ClassId, + op: Operation<'_>, +) -> AccessDecision { + let roles = rbac.actor_roles(actor); + if roles.is_empty() { + // Mirrors the shipped gate: an actor with no resolvable role is + // indistinguishable from an unknown role-name. + return AccessDecision::Deny { + reason: "unknown role", + }; + } + if roles.iter().any(|&r| rbac.grant_permits(r, class, &op)) { + return AccessDecision::Allow; + } + // Positive set non-empty but no grant permits — the op-specific reason, + // identical to `Policy::evaluate`'s per-arm deny. + AccessDecision::Deny { + reason: match op { + Operation::Read { .. } => "insufficient read depth", + Operation::Write { .. } => "predicate not writable", + Operation::Act { .. } => "action not allowed", + }, + } +} + +/// `PermissionSpec` **re-keyed by `ClassId`** (keystone §11) plus the +/// actor→role membership folding. The independent, classid-keyed representation +/// the probe certifies against the shipped string-keyed `Policy`. +#[derive(Clone, Debug, Default)] +pub struct ClassGrants { + /// `(role, class) → grant`. The shipped `Role` keys `PermissionSpec` by + /// `entity_type: &str`; this keys the same grant primitive by `ClassId`. + grants: Vec<(RoleId, ClassId, PermissionSpec)>, + /// `actor → roles`. One actor may hold several roles (the §5 union); the + /// probe assigns each actor exactly one named role to mirror the + /// single-role-name shipped gate. + memberships: Vec<(&'static str, Vec)>, +} + +impl ClassGrants { + /// Empty grant table. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Add a `(role, class) → grant` row (the re-keyed `PermissionSpec`). + #[must_use] + pub fn with_grant(mut self, role: RoleId, class: ClassId, grant: PermissionSpec) -> Self { + self.grants.push((role, class, grant)); + self + } + + /// Assign an actor a set of roles (membership fold). + #[must_use] + pub fn with_actor(mut self, actor: &'static str, roles: Vec) -> Self { + self.memberships.push((actor, roles)); + self + } + + fn grant_for(&self, role: RoleId, class: ClassId) -> Option<&PermissionSpec> { + self.grants + .iter() + .find(|(r, c, _)| *r == role && *c == class) + .map(|(_, _, g)| g) + } +} + +impl ClassRbac for ClassGrants { + fn actor_roles(&self, actor: ActorId<'_>) -> &[RoleId] { + self.memberships + .iter() + .find(|(a, _)| *a == actor) + .map(|(_, roles)| roles.as_slice()) + .unwrap_or(&[]) + } + + fn grant_permits(&self, role: RoleId, class: ClassId, op: &Operation<'_>) -> bool { + let Some(g) = self.grant_for(role, class) else { + return false; + }; + match op { + Operation::Read { depth } => g.can_read_at(*depth), + Operation::Write { predicate } => g.can_write(predicate), + Operation::Act { action } => g.can_act(action), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::{smb_policy, Policy}; + use lance_graph_contract::property::PrefetchDepth; + + // ── Probe-local classid allocation. The point of the probe is the *keying* + // (string `entity_type` → `ClassId`), not which specific codebook slot; the + // SMB entity types are app-local and not promoted into the OGAR codebook, so + // these are probe-local ids. A real consumer substitutes the codebook id. ── + const CID_CUSTOMER: ClassId = 0x0000_C001; + const CID_INVOICE: ClassId = 0x0000_C002; + const CID_TAXDECL: ClassId = 0x0000_C003; + + fn class_of(entity_type: &str) -> ClassId { + match entity_type { + "Customer" => CID_CUSTOMER, + "Invoice" => CID_INVOICE, + "TaxDeclaration" => CID_TAXDECL, + other => panic!("probe corpus references unmapped entity {other}"), + } + } + + /// Build the classid-keyed grant table BY RE-KEYING the shipped policy: walk + /// each role's `PermissionSpec`s and store them under `class_of(entity_type)` + /// instead of the entity string. This guarantees the two representations + /// carry the *same grant data*, so the probe isolates what we actually want + /// to certify: that the classid **keying** + the [`authorize`] **kernel** + /// reproduce the shipped `Policy::evaluate` structure (multi-role union, + /// empty→"unknown role", op→reason). A bug in either fails the corpus. + fn class_grants_from(policy: &Policy) -> ClassGrants { + let mut g = ClassGrants::new(); + for role in &policy.roles { + for perm in &role.permissions { + g = g.with_grant(role.name, class_of(perm.entity_type), perm.clone()); + } + // Each role becomes an actor of the same name holding exactly that + // one role — mirroring the single-role-name shipped gate. + g = g.with_actor(role.name, vec![role.name]); + } + g + } + + /// `PROBE-OGAR-RBAC-AUTHORIZE` (keystone §10). + /// + /// Asserts the classid-keyed [`authorize`] reproduces the shipped + /// string-keyed [`Policy::evaluate`] **bit-for-bit** (deny-reason included) + /// over a fixed corpus spanning all three SMB roles, all three op kinds, the + /// allow path, every distinct deny reason, the depth boundary, and the + /// unknown-actor path. Green ⇒ the §5 positive ∧ op-gate kernel + the §11 + /// classid re-keying are FINDING (no longer CONJECTURE) for the shipped + /// reference. + #[test] + fn probe_ogar_rbac_authorize() { + let policy = smb_policy(); + let grants = class_grants_from(&policy); + + // (actor / role-name, entity_type, op) — chosen to hit every branch. + let corpus: &[(&str, &str, Operation)] = &[ + // accountant: Detail on Customer (allow), Full on Customer (deny depth) + ( + "accountant", + "Customer", + Operation::Read { + depth: PrefetchDepth::Detail, + }, + ), + ( + "accountant", + "Customer", + Operation::Read { + depth: PrefetchDepth::Full, + }, + ), + // accountant: write/act on Invoice (allow), unwritable predicate (deny) + ( + "accountant", + "Invoice", + Operation::Write { + predicate: "status", + }, + ), + ( + "accountant", + "Invoice", + Operation::Act { action: "approve" }, + ), + ( + "accountant", + "Invoice", + Operation::Write { + predicate: "due_date", + }, + ), + ("accountant", "Invoice", Operation::Act { action: "delete" }), + // accountant: no grant on Customer write/act → op-specific deny + ( + "accountant", + "Customer", + Operation::Write { + predicate: "customer_name", + }, + ), + // auditor: Full read everywhere (allow), but write/act deny + ( + "auditor", + "Invoice", + Operation::Read { + depth: PrefetchDepth::Full, + }, + ), + ( + "auditor", + "Invoice", + Operation::Write { + predicate: "status", + }, + ), + ("auditor", "Invoice", Operation::Act { action: "approve" }), + // admin: full power + ("admin", "Customer", Operation::Act { action: "delete" }), + ( + "admin", + "TaxDeclaration", + Operation::Act { action: "submit" }, + ), + ( + "admin", + "Customer", + Operation::Write { + predicate: "customer_name", + }, + ), + // unknown actor → "unknown role" + ( + "ghost", + "Customer", + Operation::Read { + depth: PrefetchDepth::Identity, + }, + ), + ]; + + for (actor, entity, op) in corpus { + let shipped = policy.evaluate(actor, entity, op.clone()); + let keyed = authorize(&grants, actor, class_of(entity), op.clone()); + assert_eq!( + keyed, shipped, + "classid-keyed authorize diverged from shipped Policy::evaluate \ + for actor={actor:?} entity={entity:?} op={op:?}: \ + keyed={keyed:?} shipped={shipped:?}", + ); + } + } + + /// Falsification self-check: the gate is only meaningful if a *wrong* keying + /// actually fails the comparison. Mapping every entity to one wrong class + /// must make at least one corpus tuple diverge — proving the probe is not + /// vacuous (it would pass trivially if `authorize` ignored the class). + #[test] + fn probe_is_falsifiable_under_wrong_keying() { + let policy = smb_policy(); + let grants = class_grants_from(&policy); + // Send an allow tuple to the WRONG class: accountant approve Invoice, + // but ask under the Customer classid (accountant has no act grant there). + let shipped = policy.evaluate( + "accountant", + "Invoice", + Operation::Act { action: "approve" }, + ); + let miskeyed = authorize( + &grants, + "accountant", + CID_CUSTOMER, // wrong class + Operation::Act { action: "approve" }, + ); + assert_eq!(shipped, AccessDecision::Allow); + assert_ne!( + miskeyed, shipped, + "a wrong classid must change the decision — else the probe is vacuous", + ); + } +} diff --git a/crates/lance-graph-rbac/src/lib.rs b/crates/lance-graph-rbac/src/lib.rs index 90c6bbbd..f9d6b606 100644 --- a/crates/lance-graph-rbac/src/lib.rs +++ b/crates/lance-graph-rbac/src/lib.rs @@ -8,6 +8,8 @@ //! Depends only on `lance-graph-contract`. pub mod access; +pub mod auth; +pub mod authorize; pub mod permission; pub mod policy; pub mod role;