diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index f32b797e6..6e224bba2 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,6 +1,6 @@ -## 2026-10-08 — rbac nested scope on branch `ccr-0455e606-wmtsor` (unmerged) +## 2026-10-08 — rbac nested scope, lance-graph #1401 MERGED (`ecbc396e`) -- Contract delta: `contract::rbac::{ScopePath, SCOPE_PATH_DEPTH}`; `ScopeSpec` gains `path` and `admits`; `intersect` folds paths (narrower wins, branches deny). `lance-graph-rbac::auth::AuthProvider::SurrealDb` (`auth_surrealdb` `0x0B05`) plus its mirror row. Entry: `entries/2026-10-08-rbac-nested-scope-path.md`. +- Contract delta: `contract::rbac::{ScopePath, SCOPE_PATH_DEPTH}`; `ScopeSpec` gains `path` and `admits`; `intersect` folds paths (narrower wins, branches deny). (#1401 also added an auth provider profile and its mirror row; both removed in the follow-up PR.) Entry: `entries/2026-10-08-rbac-nested-scope-path.md`. ## 2026-10-07 — lance-graph #1398 MERGED (`89694cc`) diff --git a/.claude/board/entries/2026-10-08-rbac-nested-scope-path.md b/.claude/board/entries/2026-10-08-rbac-nested-scope-path.md index e20c7897b..15dac3455 100644 --- a/.claude/board/entries/2026-10-08-rbac-nested-scope-path.md +++ b/.claude/board/entries/2026-10-08-rbac-nested-scope-path.md @@ -1,20 +1,17 @@ -# 2026-10-08 — rbac: nested scope (`ScopePath`) and the `auth_surrealdb` provider +# 2026-10-08 — rbac: nested scope (`ScopePath`) -**Status:** TEST-PINNED (`lance-graph-contract` rbac tests; `lance-graph-rbac` auth tests). +**Status:** TEST-PINNED (`lance-graph-contract` rbac tests). ## MEASURED -- `ScopeSpec` carried one flat tenant id, so axis 3 could not say "an owner at namespace `a` acts on every database inside `a`". SurrealDB's IAM is exactly that model: `Level::{Root, Namespace, Database, Record}`, and `is_allowed_check` grants View when `resource.level().sublevel_of(actor.level())`, Edit for `Owner` likewise, and Edit for `Editor` only on 12 resource kinds (`surrealdb/core/src/iam/mod.rs`). -- SurrealDB tokens carry the subject in `ID`, roles in `RL`, the namespace in `NS`, then `DB` and `AC` (`iam/token.rs`). +- `ScopeSpec` carried one flat tenant id, so axis 3 could not say "an owner at namespace `a` acts on every database inside `a`": a scope that contains every scope nested below it. ## DECISION - `contract::rbac::ScopePath`: up to `SCOPE_PATH_DEPTH = 4` interned `u64` segments, outermost first; `contains` is prefix. `ScopeSpec` gains `path` (default `ScopePath::ROOT`, so every existing scope is unchanged) and `admits(&ScopePath)` (address containment, not policy). `intersect` keeps the narrower of two nested paths; two branches intersect to `ScopeSpec::DENY`. -- `lance-graph-rbac::auth::AuthProvider::SurrealDb` = `auth_surrealdb` (`0x0B05`), grammar `ID` / `RL` / `NS`; contract mirror row added. Paired with the OGAR mint (merge together). -- A record-level SurrealDB actor holds no roles but may View; that is mapped as data (an explicit role granted View) in the OGAR adapter, so the kernel keeps "no roles ⇒ deny". -- **SCOPE:** axis 3 of the keystone. **BASIS:** operator chose "extend ScopeSpec" over `predicate_key` or harvest-only. +- **SCOPE:** axis 3 of the keystone. **BASIS:** operator chose "extend ScopeSpec" over `predicate_key`. ## OPEN -- Grants match on codebook concepts; OGAR has none for database catalog objects (namespace, table, field, index, …), which SurrealDB's Editor rule depends on. -- `authorize_scoped` still never calls `roles_reaching`; the role hierarchy (Owner ⊇ Editor ⊇ Viewer) is expressed as explicit grants per role. +- A scope bound to a membership (actor, role, position) is not expressible: `ClassRbac::row_scope(role, class)` has no actor, and `RoleId` is `&'static str`. +- `authorize_scoped` still never calls `roles_reaching`; a role hierarchy is expressed as explicit grants per role. diff --git a/crates/lance-graph-contract/src/ogar_codebook.rs b/crates/lance-graph-contract/src/ogar_codebook.rs index 87e6aec31..b4c1c3d05 100644 --- a/crates/lance-graph-contract/src/ogar_codebook.rs +++ b/crates/lance-graph-contract/src/ogar_codebook.rs @@ -696,7 +696,6 @@ pub const CODEBOOK: &[(&str, u16)] = &[ ("auth_zitadel", 0x0B02), ("auth_zanzibar", 0x0B03), ("auth_ory_keto", 0x0B04), - ("auth_surrealdb", 0x0B05), // ── 0x0DXX — HR domain (employment / org / contracts; OGAR PR #127) ── // Closes the final 4-of-11 cross-axis identity gap surfaced by odoo-rs // PR #14: hr.employee / hr.department / hr.job / hr.contract. @@ -959,7 +958,6 @@ mod tests { assert_eq!(canonical_concept_id("vital_sign"), Some(0x0907)); assert_eq!(canonical_concept_id("auth_store"), Some(0x0B01)); assert_eq!(canonical_concept_id("auth_ory_keto"), Some(0x0B04)); - assert_eq!(canonical_concept_id("auth_surrealdb"), Some(0x0B05)); assert_eq!(canonical_concept_id("mars_application"), Some(0x0C01)); assert_eq!(canonical_concept_id("knowledge_item"), Some(0x0C05)); assert_eq!(canonical_concept_id("mars_node_template"), Some(0x0C06)); diff --git a/crates/lance-graph-contract/src/rbac.rs b/crates/lance-graph-contract/src/rbac.rs index 043160969..5511fb780 100644 --- a/crates/lance-graph-contract/src/rbac.rs +++ b/crates/lance-graph-contract/src/rbac.rs @@ -31,9 +31,9 @@ use crate::class_view::{FieldMask, WideFieldMask}; use crate::property::PrefetchDepth; -/// The most segments a [`ScopePath`] holds. Four covers a tenant/org above -/// SurrealDB's own three (namespace / database / access) and Zitadel's -/// org / project. +/// The most segments a [`ScopePath`] holds. Four covers a tenant above a +/// three-level resource hierarchy (for example namespace / database / record) +/// and Zitadel's org / project. pub const SCOPE_PATH_DEPTH: usize = 4; /// A position in a **nested** scope hierarchy — root ⊃ namespace ⊃ database ⊃ diff --git a/crates/lance-graph-rbac/src/auth.rs b/crates/lance-graph-rbac/src/auth.rs index db6d165cf..899e27a32 100644 --- a/crates/lance-graph-rbac/src/auth.rs +++ b/crates/lance-graph-rbac/src/auth.rs @@ -19,7 +19,7 @@ //! ``` //! //! The [`AuthProvider`] variants ARE the preminted `0x0B` family -//! (`auth_store` 0x0B01 base + `auth_zitadel`/`auth_zanzibar`/`auth_ory_keto`/`auth_surrealdb` +//! (`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 @@ -51,9 +51,6 @@ pub enum AuthProvider { Zanzibar, /// `auth_ory_keto` (0x0B04) — Ory Keto. OryKeto, - /// `auth_surrealdb` (0x0B05) — SurrealDB IAM (system users and record - /// access; level-scoped Viewer / Editor / Owner roles). - SurrealDb, } impl AuthProvider { @@ -65,7 +62,6 @@ impl AuthProvider { Self::Zitadel => "auth_zitadel", Self::Zanzibar => "auth_zanzibar", Self::OryKeto => "auth_ory_keto", - Self::SurrealDb => "auth_surrealdb", } } @@ -83,15 +79,9 @@ impl AuthProvider { /// 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, - Self::SurrealDb, - ] - .into_iter() - .find(|p| p.class_id() == id) + [Self::Store, Self::Zitadel, Self::Zanzibar, Self::OryKeto] + .into_iter() + .find(|p| p.class_id() == id) } /// As a full 32-bit `ClassId` under the core render lens (concept in the @@ -130,15 +120,6 @@ impl AuthProvider { roles_claim: "relation", tenant_claim: "namespace", }, - // SurrealDB: the subject is `ID` (a system user's name or a record - // id), roles are `RL`, and the namespace `NS` is the outermost scope. - // `DB` and `AC` narrow it further; they enter as a scope path, not as - // the tenant. - Self::SurrealDb => ClaimGrammar { - subject_claim: "ID", - roles_claim: "RL", - tenant_claim: "NS", - }, } } @@ -225,7 +206,6 @@ mod tests { assert_eq!(AuthProvider::Zitadel.class_id(), 0x0B02); assert_eq!(AuthProvider::Zanzibar.class_id(), 0x0B03); assert_eq!(AuthProvider::OryKeto.class_id(), 0x0B04); - assert_eq!(AuthProvider::SurrealDb.class_id(), 0x0B05); // Full classid under the core lens: concept in the CANON high u16 // (post-flip form), custom prefix 0x0000 — auth is cross-app. assert_eq!(AuthProvider::Store.classid(), 0x0B01_0000); @@ -235,7 +215,6 @@ mod tests { AuthProvider::Zitadel, AuthProvider::Zanzibar, AuthProvider::OryKeto, - AuthProvider::SurrealDb, ] { assert_eq!(AuthProvider::from_class_id(p.class_id()), Some(p)); }