diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index 0fdeb12..f9e3eed 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -101,10 +101,13 @@ use ogar_vocab::{ crm_ticket, currency_policy, diagnosis, + directory_group, + directory_user, document, email, examination, external_practice, + group_membership, hr_department, hr_employee, hr_employment_contract, @@ -113,6 +116,7 @@ use ogar_vocab::{ joint, knowledge_item, lab_value, + license_assignment, machine_memory_map, mars_application, mars_machine, @@ -300,6 +304,10 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { ("auth_zanzibar", auth_zanzibar()), ("auth_ory_keto", auth_ory_keto()), ("email", email()), + ("directory_user", directory_user()), + ("directory_group", directory_group()), + ("group_membership", group_membership()), + ("license_assignment", license_assignment()), // ── 0x0DXX — HR cluster (closes the final 4-of-11 odoo-rs #14 gap) ── ("hr_employee", hr_employee()), ("hr_department", hr_department()), diff --git a/crates/ogar-flow-m365/Cargo.toml b/crates/ogar-flow-m365/Cargo.toml index c1eb46e..8daad4e 100644 --- a/crates/ogar-flow-m365/Cargo.toml +++ b/crates/ogar-flow-m365/Cargo.toml @@ -18,12 +18,11 @@ fixture = [] [dependencies] ogar-loco = { path = "../ogar-loco" } ogar-auth = { path = "../ogar-auth" } -ogar-dir-core = { path = "../ogar-dir-core" } +# Zero-dep: the canonical concept ids the operations bind to. +ogar-vocab = { path = "../ogar-vocab" } # Same coordinate the eight sibling OGAR crates and ogar-loco itself use. lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } -[dev-dependencies] -ogar-vocab = { path = "../ogar-vocab" } [[example]] name = "john_doe_101" diff --git a/crates/ogar-flow-m365/README.md b/crates/ogar-flow-m365/README.md index eddb271..262dcce 100644 --- a/crates/ogar-flow-m365/README.md +++ b/crates/ogar-flow-m365/README.md @@ -107,41 +107,43 @@ start with one source and end in `Export-Report`. | Directory observations | **mocked**: `fixture::FixtureDirectory` (synthetic) | | Report authority | **mocked**: `fixture::FixtureAuthority`, a test double, not security | | Assertion verification | **mocked**: `fixture::FixtureVerifier`, driven through the real `establish` | -| Production authority via `ogar-rbac` (`OgarRbac::authorize_user`, field masks) | **deferred, gated** (below) | -| Observation adapter over `ogar-az` / Microsoft Graph | deferred (PR-FLOW-02) | -| Registering the vocabulary in a `VocabularyRegistry` under a body classid | **deferred, gated** (below) | +| Production authority via `ogar-rbac` (`OgarRbac::authorize_user`, field masks) | deferred (PR-FLOW-02); the classids it keys on now exist (below) | +| Observation adapter over `lance-graph-dir-sim` / `ogar-az` | deferred (PR-FLOW-02) | +| Registering the vocabulary in a `VocabularyRegistry` | implemented: `vocab::plug_into`, body concept `0x1718` | | CSV / other export formats, `-like` / `-match`, `ForEach-Object`, sorting | not implemented | -### The two gates - -Both are the same missing piece: **no directory-user, license-assignment, -group-membership or report concept is minted in `ogar-vocab`.** This crate -mints nothing. - -- **RBAC.** `OgarRbac::authorize_user(identity, class, op)` and its field - masks are keyed by classid. Without a minted class for a directory user (and - for license and membership facts), the production authority has nothing to - be asked about. `ReportAuthority` is the seam it will implement. -- **Registry.** `VocabularyRegistry::plug` selects a vocabulary by the body - node's concept id. The flow vocabulary is validated and run directly; it is - not plugged into a registry, because that needs a body concept id (the - `ogar-ro` `0x0306` and `ogar-dismech` `0x0333` precedent), which is an - operator decision. +### Classids Every operation records its binding (`adjacency::ConceptBinding`): -`DirSchema` (the `ogar-dir-core` MS Graph user schema), `Generic`, or -`Unminted("…")` naming what is missing. + +| operations | binding | +|---|---| +| `query_user`, `update_user`, `remove_user` | `Minted(0x0B06)` `directory_user` | +| `read_membership`, `add_group_member` | `Minted(0x0B08)` `group_membership` | +| `read_license_evidence`, `assign_license` | `Minted(0x0B09)` `license_assignment` | +| `filter_rows`, `select_fields`, `evaluate_expression` | `Generic` | +| `export_report` | `Unminted`: report execution belongs to `lance-graph-report` | + +`directory_user` (`0x0B06`), `directory_group` (`0x0B07`), `group_membership` +(`0x0B08`) and `license_assignment` (`0x0B09`) are minted in `ogar-vocab`'s +IAM domain, above `email`. They are what `OgarRbac` grants and field masks on +directory data key on, so `ReportAuthority` can be backed by it. + +The flow body's concept id, `0x1718`, is a palette slot in `ogar-loco`'s +`0x17XX` domain, above `blockly-rs` (`0x1717`). Like every palette slot it is +declared in this crate, not in `ogar-vocab`'s mirrored codebook. +`vocab::plug_into` registers the vocabulary under it. ## The litmus test `Get-MgUser` was added as a friendly label without changing anything in `ogar-loco`: no new opcode, no change to the call ABI, the interpreter, the conformance rules or `DOMAIN_FLOOR`. The only LOCO primitive missing for -production is outside LOCO: the concept ids above. +production was outside LOCO: the concept ids above. ## Falsifiers -`cargo test -p ogar-flow-m365` runs 29 tests over synthetic data, F1–F10: +`cargo test -p ogar-flow-m365` runs 31 tests over synthetic data, F1–F10: resolution and refusal (F1), alias equivalence (F2), no new concept and shared-core comparison reuse (F3), read-only enforcement including hand-built bodies (F4), actor isolation: no scope, missing capability, diff --git a/crates/ogar-flow-m365/src/adjacency.rs b/crates/ogar-flow-m365/src/adjacency.rs index 29556f6..1b4ca9a 100644 --- a/crates/ogar-flow-m365/src/adjacency.rs +++ b/crates/ogar-flow-m365/src/adjacency.rs @@ -16,8 +16,8 @@ //! String matching happens here, once, at the compile boundary. Execution //! sees only [`FnIndex`] bytes. -use ogar_dir_core::{SchemaFamily, SchemaId}; use ogar_loco::FnIndex; +use ogar_vocab::class_ids; use crate::vocab; @@ -105,19 +105,18 @@ pub enum Backend { /// The semantic target an operation is bound to. /// -/// No variant carries a classid, because no directory-user, license, -/// group-membership or report concept is minted in `ogar-vocab` today, and -/// this crate does not mint concepts. Binding these to classids is the gate -/// for the production RBAC integration (README § Deferred). +/// A directory operation is bound to the `ogar-vocab` concept its rows are +/// (`directory_user`, `group_membership`, `license_assignment`), which is +/// what RBAC grants and field masks on that data key on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ConceptBinding { /// A pipeline operator over any population (filter, projection). It has /// no concept of its own. Generic, - /// Bound to an existing typed directory schema in `ogar-dir-core`. - DirSchema(SchemaId), - /// The operation has no canonical OGAR concept yet. The string names the - /// missing piece so the gap is visible at the call site. + /// Bound to a concept minted in `ogar-vocab` (the hi-u16 concept id). + Minted(u16), + /// The operation has no OGAR concept. The string names why, so the gap is + /// visible at the call site. Unminted(&'static str), } @@ -153,12 +152,6 @@ impl Operation { } } -/// The Graph `user` schema `ogar-az` encodes against. -const MS_GRAPH_USER: SchemaId = SchemaId { - family: SchemaFamily::MsGraph, - version: 2, -}; - /// Every operation, indexed by [`OperationId`]. pub const OPERATIONS: &[Operation] = &[ // 0 @@ -170,7 +163,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Nothing, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 1 Operation { @@ -181,7 +174,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::LICENSE_ASSIGNMENT), }, // 2 Operation { @@ -192,7 +185,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Population, backend: Backend::Loco, - binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::GROUP_MEMBERSHIP), }, // 3 Operation { @@ -226,7 +219,7 @@ pub const OPERATIONS: &[Operation] = &[ output: FlowShape::Report, backend: Backend::Loco, binding: ConceptBinding::Unminted( - "report execution is lance-graph-report's; no OGAR concept", + "report execution belongs to lance-graph-report; no OGAR concept", ), }, // 6 — known, refused. @@ -238,7 +231,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 7 Operation { @@ -249,7 +242,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + binding: ConceptBinding::Minted(class_ids::DIRECTORY_USER), }, // 8 Operation { @@ -260,7 +253,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::GROUP_MEMBERSHIP), }, // 9 Operation { @@ -271,7 +264,7 @@ pub const OPERATIONS: &[Operation] = &[ input: FlowShape::Population, output: FlowShape::Nothing, backend: Backend::None, - binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + binding: ConceptBinding::Minted(class_ids::LICENSE_ASSIGNMENT), }, // 10 Operation { diff --git a/crates/ogar-flow-m365/src/tests.rs b/crates/ogar-flow-m365/src/tests.rs index c9fafa1..fd9817e 100644 --- a/crates/ogar-flow-m365/src/tests.rs +++ b/crates/ogar-flow-m365/src/tests.rs @@ -252,12 +252,21 @@ fn f3_no_classid_is_minted_and_no_label_is_a_concept() { "{}", op.name ); - // Bindings name an existing schema, the generic operator, or the - // missing concept, never a classid of their own. - assert!(matches!( - op.binding, - ConceptBinding::Generic | ConceptBinding::DirSchema(_) | ConceptBinding::Unminted(_) - )); + // A minted binding names a concept ogar-vocab actually mints, in the + // IAM domain; nothing here invents a classid of its own. + if let ConceptBinding::Minted(id) = op.binding { + assert!( + ogar_vocab::canonical_concept_name(id).is_some(), + "{}", + op.name + ); + assert_eq!( + ogar_vocab::canonical_concept_domain(id), + ogar_vocab::ConceptDomain::Auth, + "{}", + op.name + ); + } } for l in LABELS { let normal = l.text.to_ascii_lowercase().replace('-', "_"); @@ -823,3 +832,75 @@ fn a_run_that_leaves_values_on_the_stack_returns_no_report() { Err(RunError::Dialect(FlowError::NoReport)) ); } + +#[test] +fn f3_directory_operations_bind_to_the_minted_directory_concepts() { + use ogar_vocab::class_ids; + let binding = |name: &str| { + OPERATIONS + .iter() + .find(|o| o.name == name) + .map(|o| o.binding) + .unwrap() + }; + for (op, id, concept) in [ + ("query_user", class_ids::DIRECTORY_USER, "directory_user"), + ( + "read_license_evidence", + class_ids::LICENSE_ASSIGNMENT, + "license_assignment", + ), + ( + "read_membership", + class_ids::GROUP_MEMBERSHIP, + "group_membership", + ), + ("update_user", class_ids::DIRECTORY_USER, "directory_user"), + ( + "assign_license", + class_ids::LICENSE_ASSIGNMENT, + "license_assignment", + ), + ( + "add_group_member", + class_ids::GROUP_MEMBERSHIP, + "group_membership", + ), + ] { + assert_eq!(binding(op), ConceptBinding::Minted(id), "{op}"); + assert_eq!(ogar_vocab::canonical_concept_name(id), Some(concept)); + } + // Pipeline operators have no concept, and report execution has none here. + assert_eq!(binding("filter_rows"), ConceptBinding::Generic); + assert_eq!(binding("select_fields"), ConceptBinding::Generic); + assert!(matches!( + binding("export_report"), + ConceptBinding::Unminted(_) + )); +} + +#[test] +fn f9_the_flow_vocabulary_plugs_into_a_registry_under_its_body_classid() { + let mut hub = ogar_loco::VocabularyRegistry::new(); + vocab::plug_into(&mut hub).unwrap(); + let t = hub + .resolve_classid(vocab::flow_body_render_classid(0x0001)) + .expect("0x1718 routes to the flow vocabulary"); + assert_eq!(t, vocab::checked().table()); + assert_eq!(t.name(vocab::GET_USER), Some("get_user")); + assert_eq!( + vocab::plug_into(&mut hub), + Err(ogar_loco::RegistryError::ConceptTaken { + concept_id: vocab::FLOW_BODY_CONCEPT_ID + }) + ); + // A palette slot: in LOCO's domain, and absent from the global codebook. + assert_eq!( + ogar_vocab::canonical_concept_domain(vocab::FLOW_BODY_CONCEPT_ID), + ogar_vocab::ConceptDomain::Blocks + ); + assert_eq!( + ogar_vocab::canonical_concept_name(vocab::FLOW_BODY_CONCEPT_ID), + None + ); +} diff --git a/crates/ogar-flow-m365/src/vocab.rs b/crates/ogar-flow-m365/src/vocab.rs index df65b7a..8331b80 100644 --- a/crates/ogar-flow-m365/src/vocab.rs +++ b/crates/ogar-flow-m365/src/vocab.rs @@ -21,11 +21,36 @@ //! second operator table. use lance_graph_contract::class_view::FieldMask; -use ogar_loco::{FnIndex, LaneShape, ValueCodebook, Vocabulary}; +use ogar_loco::{FnIndex, LaneShape, RegistryError, ValueCodebook, Vocabulary, VocabularyRegistry}; /// Every flow body's lane carving. pub const FLOW_SHAPE: LaneShape = LaneShape::Quads; +/// `0x1718` — the concept id of a flow body node: the classid a +/// [`VocabularyRegistry`] routes to [`FlowVocabulary`]. +/// +/// A palette slot in `ogar-loco`'s `0x17XX` domain, seated above +/// `blockly-rs` (`0x1717`) as `ogar-loco` reserves `0x1717`+ for consumer +/// palettes. Like `0x1717`, it lives in this crate, not in `ogar-vocab`'s +/// globally mirrored codebook (a palette is a frontend's private reading). +pub const FLOW_BODY_CONCEPT_ID: u16 = 0x1718; + +/// The full render classid of a flow body under `app_prefix`. +#[must_use] +pub const fn flow_body_render_classid(app_prefix: u16) -> u32 { + ((FLOW_BODY_CONCEPT_ID as u32) << 16) | (app_prefix as u32) +} + +/// Register [`FlowVocabulary`] under [`FLOW_BODY_CONCEPT_ID`]. +/// +/// # Errors +/// +/// [`RegistryError::ConceptTaken`] when the registry already has a vocabulary +/// for `0x1718`. +pub fn plug_into(registry: &mut VocabularyRegistry) -> Result<(), RegistryError> { + registry.plug(FLOW_BODY_CONCEPT_ID, &checked()) +} + /// `get_user`: look up one directory user. pub const GET_USER: FnIndex = FnIndex(0x90); /// `get_license_evidence`: attach observed license facts. diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 4186ffa..fb873a0 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1414,6 +1414,16 @@ const CODEBOOK: &[(&str, u16)] = &[ // directory. Mailboxes, folders, flags and threads are columns of an // email row, not concepts of their own. Consumed by spear (`SpearPort`). ("email", 0x0B05), + // Directory objects as observed (AD / Entra / Exchange), above `email`. + // `directory_user` / `directory_group` are the users and groups of + // `ogar-dir-sim`'s two populations; `group_membership` is one + // user→group edge; `license_assignment` is one observed license SKU on + // one user. They are what directory reads (ogar-flow-m365) bind to and + // what RBAC grants and field masks on directory data key on. + ("directory_user", 0x0B06), + ("directory_group", 0x0B07), + ("group_membership", 0x0B08), + ("license_assignment", 0x0B09), // ── 0x0CXX — Automation domain (the HIRO IT-automation stack) ── // One domain spanning the MARS structural CMDB (`ogit.MARS:` — // Application/Resource/Software/Machine, the A→R→S→M dependsOn backbone) @@ -2335,6 +2345,17 @@ pub mod class_ids { /// `email` (`0x0B05`) — one email message. OGIT `ogit:Email`. In the IAM /// domain, above the AuthStore profiles. pub const EMAIL: u16 = 0x0B05; + /// `directory_user` (`0x0B06`) — one directory user as observed + /// (AD / Entra), keyed by its object GUID. + pub const DIRECTORY_USER: u16 = 0x0B06; + /// `directory_group` (`0x0B07`) — one directory group (security or + /// distribution), keyed by its object GUID. + pub const DIRECTORY_GROUP: u16 = 0x0B07; + /// `group_membership` (`0x0B08`) — one user→group membership edge. + pub const GROUP_MEMBERSHIP: u16 = 0x0B08; + /// `license_assignment` (`0x0B09`) — one observed license SKU assigned + /// to one directory user. + pub const LICENSE_ASSIGNMENT: u16 = 0x0B09; // ── 0x0DXX — HR domain (employment / org / contracts) ── @@ -2557,6 +2578,10 @@ pub mod class_ids { ("auth_zanzibar", AUTH_ZANZIBAR), ("auth_ory_keto", AUTH_ORY_KETO), ("email", EMAIL), + ("directory_user", DIRECTORY_USER), + ("directory_group", DIRECTORY_GROUP), + ("group_membership", GROUP_MEMBERSHIP), + ("license_assignment", LICENSE_ASSIGNMENT), // 0x0DXX — HR (employment / org / contracts; closes the final // 4-of-11 cross-axis gap from odoo-rs PR #14) ("hr_employee", HR_EMPLOYEE), @@ -3508,6 +3533,10 @@ pub fn all_promoted_classes() -> Vec { auth_zanzibar(), auth_ory_keto(), email(), + directory_user(), + directory_group(), + group_membership(), + license_assignment(), // 0x0DXX — HR arm hr_employee(), hr_department(), @@ -5330,6 +5359,48 @@ pub fn email() -> Class { c } +// ───────────────────────────────────────────────────────────────────── +// Directory objects (`0x0B06`–`0x0B09`, IAM domain). Identity only; the +// columns live in the consumer's ClassView (lance-graph-dir-sim's +// populations, ogar-flow-m365's report fields). +// ───────────────────────────────────────────────────────────────────── + +/// `directory_user` (`0x0B06`) — one directory user as observed (AD / Entra). +#[must_use] +pub fn directory_user() -> Class { + let mut c = Class::new("DirectoryUser"); + c.language = Language::Unknown; + c.canonical_concept = Some("directory_user".to_string()); + c +} + +/// `directory_group` (`0x0B07`) — one directory group (security or distribution). +#[must_use] +pub fn directory_group() -> Class { + let mut c = Class::new("DirectoryGroup"); + c.language = Language::Unknown; + c.canonical_concept = Some("directory_group".to_string()); + c +} + +/// `group_membership` (`0x0B08`) — one user→group membership edge. +#[must_use] +pub fn group_membership() -> Class { + let mut c = Class::new("GroupMembership"); + c.language = Language::Unknown; + c.canonical_concept = Some("group_membership".to_string()); + c +} + +/// `license_assignment` (`0x0B09`) — one observed license SKU on one directory user. +#[must_use] +pub fn license_assignment() -> Class { + let mut c = Class::new("LicenseAssignment"); + c.language = Language::Unknown; + c.canonical_concept = Some("license_assignment".to_string()); + c +} + // ───────────────────────────────────────────────────────────────────── // 0xC6XX — MMIO hardware-platform builders (C64 = founding tenant). // Kind-level containers only: registers/opcodes are content rows. @@ -6441,6 +6512,10 @@ mod tests { ("auth_zanzibar", 0x0B03), ("auth_ory_keto", 0x0B04), ("email", 0x0B05), + ("directory_user", 0x0B06), + ("directory_group", 0x0B07), + ("group_membership", 0x0B08), + ("license_assignment", 0x0B09), ] { assert_eq!( canonical_concept_id(concept), @@ -6449,8 +6524,9 @@ mod tests { ); assert_eq!(canonical_concept_domain(id), ConceptDomain::Auth); } - // The four preminted profiles plus `email` are the Auth block today. - assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 5); + // The four preminted profiles, `email`, and the four directory + // concepts are the Auth block today. + assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 9); } #[test] @@ -6602,7 +6678,7 @@ mod tests { assert_eq!(concepts_in_domain(ConceptDomain::Commerce).count(), 11); assert_eq!(concepts_in_domain(ConceptDomain::ProjectMgmt).count(), 26); assert_eq!(concepts_in_domain(ConceptDomain::Anatomy).count(), 4); - assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 5); + assert_eq!(concepts_in_domain(ConceptDomain::Auth).count(), 9); assert_eq!(concepts_in_domain(ConceptDomain::Automation).count(), 9); // Every yielded Automation id really is in-domain (0x0CXX). let automation: Vec<&str> = concepts_in_domain(ConceptDomain::Automation)