Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/board/LATEST_STATE.md
Original file line number Diff line number Diff line change
@@ -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`)

Expand Down
15 changes: 6 additions & 9 deletions .claude/board/entries/2026-10-08-rbac-nested-scope-path.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions crates/lance-graph-contract/src/ogar_codebook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions crates/lance-graph-contract/src/rbac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ⊃
Expand Down
29 changes: 4 additions & 25 deletions crates/lance-graph-rbac/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -65,7 +62,6 @@ impl AuthProvider {
Self::Zitadel => "auth_zitadel",
Self::Zanzibar => "auth_zanzibar",
Self::OryKeto => "auth_ory_keto",
Self::SurrealDb => "auth_surrealdb",
}
}

Expand All @@ -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> {
[
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
Expand Down Expand Up @@ -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",
},
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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));
}
Expand Down
Loading