diff --git a/Cargo.toml b/Cargo.toml index af6a805..1c7949e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/ogar-from-docv1", "crates/ogar-render-typst", "crates/ogar-loco", + "crates/ogar-flow-m365", "crates/ogar-r2il", "crates/ogar-rbac", "crates/ogar-ro", diff --git a/crates/ogar-flow-m365/Cargo.toml b/crates/ogar-flow-m365/Cargo.toml new file mode 100644 index 0000000..c1eb46e --- /dev/null +++ b/crates/ogar-flow-m365/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ogar-flow-m365" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "PowerShell-shaped command labels (Get-MgUser, Where-Object, Select-Object, Export-Report, …) as an ogar-loco Vocabulary + Dialect: a typed label→operation adjacency table, a lowering of an admitted read-only pipeline subset into ogar-loco calls, and a host seam that runs them over directory observations under a verified OGAR identity and an injected report authority. No PowerShell runtime, no Graph network access, no new opcodes in ogar-loco." + +[features] +default = [] +# Synthetic, deterministic "John Doe 101" directory + a fixture authority. +# NOT security: the fixture authority is a test double for the production +# OGAR RBAC binding (see README § Deferred). +fixture = [] + +[dependencies] +ogar-loco = { path = "../ogar-loco" } +ogar-auth = { path = "../ogar-auth" } +ogar-dir-core = { path = "../ogar-dir-core" } +# 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" +required-features = ["fixture"] diff --git a/crates/ogar-flow-m365/README.md b/crates/ogar-flow-m365/README.md new file mode 100644 index 0000000..eddb271 --- /dev/null +++ b/crates/ogar-flow-m365/README.md @@ -0,0 +1,171 @@ +# ogar-flow-m365 + +PowerShell-shaped command labels for administrators, executed as typed +`ogar-loco` calls. This is the first slice of the flow arc: one read-only +reporting pipeline over a synthetic directory. + +```text + "Get-MgUser … | Get-IamLicenseEvidence | Select-Object … | Export-Report" + │ frontend::compile (strings stop here) + ▼ + label ─► adjacency::Operation ─► call byte ─► ogar_loco::Program (one Quads body) + │ + VerifiedLogin ─► ReportAuthority ─► ReportScope │ ogar_loco::Interpreter::run + │ ▼ + └──► FlowDialect ─► DirectoryObservations + │ + ▼ + Report ─► to_json +``` + +## John Doe 101 + +```sh +cargo run -p ogar-flow-m365 --features fixture --example john_doe_101 +``` + +```powershell +Get-MgUser -UserId "john.doe@example.test" | + Get-IamLicenseEvidence | + Select-Object DisplayName, Department, Licenses | + Export-Report -Format JSON +``` + +This is a declarative input, not PowerShell. It compiles to one +`ogar-loco` function body of four calls (`0x90 0x91 0x94 0x95`), which the +unchanged `ogar_loco::Interpreter` runs under the flow dialect. Output, as +produced by the example: + +```json +{"format":"ogar-flow-m365/report.v1","requested":"john.doe@example.test", + "scope":{"tenant":32343,"policy_version":1,"fields":["ObjectId","UserPrincipalName","DisplayName","Department","AccountEnabled","Licenses","Memberships","MailboxAvailable"]}, + "source":{"id":"fixture:john-doe-101","observed_version":7}, + "rows":[{"DisplayName":"John Doe","Department":"Operations","Licenses":["M365 E3","Teams Phone"]}], + "trace":[{"label":"Get-MgUser","operation":"query_user","fn":"0x90"}, + {"label":"Get-IamLicenseEvidence","operation":"read_license_evidence","fn":"0x91"}, + {"label":"Select-Object","operation":"select_fields","fn":"0x94"}, + {"label":"Export-Report","operation":"export_report","fn":"0x95"}]} +``` + +(Line breaks added; the real output is one line with fixed key order.) + +## The adjacency table + +A label is metadata; the operation it names is authoritative. Aliases are +several labels naming one operation row. + +| labels | operation | effect | capability | call byte | +|---|---|---|---|---| +| `Get-MgUser`, `Get-AzureADUser` | `query_user` | read | `ReadIdentity` | `0x90` | +| `Get-IamLicenseEvidence` | `read_license_evidence` | read | `ReadLicenses` | `0x91` | +| `Get-IamMembership` | `read_membership` | read | `ReadMemberships` | `0x92` | +| `Where-Object`, `where`, `?` | `filter_rows` | read | field permission | `0x93` | +| `Select-Object`, `select` | `select_fields` | read | field permission | `0x94` | +| `Export-Report` | `export_report` | export | `Export` | `0x95` | +| `Set-MgUser`, `Update-MgUser`, `Remove-MgUser`, `New-MgGroupMember`, `Set-MgUserLicense` | mutations | mutation | — | none: refused | +| `Invoke-Expression`, `iex` | `evaluate_expression` | execution | — | none: refused | + +Any other label is unknown and refused. + +`Where-Object` admits ` -eq|-ne ` on scalar fields and +` -contains ` on list fields. Its comparison byte is the +shared-core `ogar-loco` index (`EQ`, `NEQ`, `LIST_CONTAINS`), not a second +operator table. `Select-Object` packs its fields as a 24-bit +`lance_graph_contract::class_view::FieldMask` into the call's three value +bytes. + +Refused syntax: script blocks, variables, subexpressions, statement +separators, redirection, escapes, splatting, call operators, unknown +parameters (`-RunAs`, `-Credential`, …), and any pipeline that does not +start with one source and end in `Export-Report`. + +## Trust order + +1. The actor is the canonical user of an `ogar_auth::federation::VerifiedLogin`. + `run_flow` accepts nothing else, so the login boundary is checked by the + compiler. +2. A `ReportAuthority` maps that user to a `ReportScope`: one tenant, a + capability set, a field mask, a policy version. No scope means + `FlowError::NoScope` and the host is never queried. +3. Every domain call checks its capability before it reads. `get_user` asks + the host only for permitted fields and drops any extra field a host + returns. Filtering or selecting an unpermitted field is refused (a filter + on it would leak it through the row count). +4. Host queries carry the scope's tenant, never one named in the pipeline. A + user in another tenant looks exactly like a user that does not exist. + +## Implemented, mocked, deferred + +| piece | status | +|---|---| +| Label → operation adjacency, aliases, refusal of unknown and mutating labels | implemented | +| Flow `Vocabulary` (6 domain bytes), passes `ogar-loco` conformance | implemented | +| Lowering of the admitted subset to one `ogar-loco` body | implemented | +| Execution through `ogar_loco::Interpreter` with a flow `Dialect` | implemented | +| Verified-login entry (`VerifiedLogin`) | implemented, real `ogar-auth` type | +| Deterministic JSON report with scope, source, version and trace | implemented | +| 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) | +| 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. + +Every operation records its binding (`adjacency::ConceptBinding`): +`DirSchema` (the `ogar-dir-core` MS Graph user schema), `Generic`, or +`Unminted("…")` naming what is missing. + +## 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. + +## Falsifiers + +`cargo test -p ogar-flow-m365` runs 29 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, +foreign tenant, actor-choosing parameters, unverified assertions (F5), field +projection including a host that overshares (F6), determinism (F7), parity +with a direct typed reference (F8), `ogar-loco` conformance (F9), and refused +syntax (F10). Each guard was disabled once to confirm a test fails without +it. + +## Dependency impact + +`ogar-loco` is unchanged (it still pulls only `lance-graph-contract`). This +crate adds `ogar-auth` for the verified-login type, which brings its +cryptography stack (`ogar-encryption`, the ndarray `encryption` crate, hmac, +des, md-5, …): 97 unique crates in the normal-dependency tree. That is the +cost of taking `VerifiedLogin` rather than a bare actor string. A host that +already links `ogar-auth` pays nothing extra. + +## Next seam: `report-rs` + +A `report-rs` command palette would hold saved pipelines as text, call +`compile` once to show preview and validation errors, and call `run_flow` +with the session's `VerifiedLogin`, a production `ReportAuthority` and an +observation host. `Report` already carries what a provenance panel needs: +source id, observed version, scope, policy version, and the per-stage trace. +Report folds and large populations belong to `lance-graph-report`, behind +`DirectoryObservations`, not in this dialect. diff --git a/crates/ogar-flow-m365/examples/john_doe_101.rs b/crates/ogar-flow-m365/examples/john_doe_101.rs new file mode 100644 index 0000000..09d0419 --- /dev/null +++ b/crates/ogar-flow-m365/examples/john_doe_101.rs @@ -0,0 +1,28 @@ +//! John Doe 101: read a user's identity and licenses, project three fields, +//! and export a report — through the real `ogar-loco` interpreter, over the +//! synthetic fixture directory. +//! +//! ```sh +//! cargo run -p ogar-flow-m365 --features fixture --example john_doe_101 +//! ``` + +use ogar_flow_m365::fixture::{FixtureAuthority, FixtureDirectory, login}; +use ogar_flow_m365::{compile, run_flow}; + +const PIPELINE: &str = r#"Get-MgUser -UserId "john.doe@example.test" | + Get-IamLicenseEvidence | + Select-Object DisplayName, Department, Licenses | + Export-Report -Format JSON"#; + +fn main() { + let flow = compile(PIPELINE).expect("the John Doe pipeline is in the admitted subset"); + let login = login("fixture-signed:reader").expect("the fixture reader logs in"); + let report = run_flow( + &login, + &flow, + &FixtureDirectory, + &FixtureAuthority::standard(), + ) + .expect("the reader is scoped for this report"); + println!("{}", report.to_json()); +} diff --git a/crates/ogar-flow-m365/src/adjacency.rs b/crates/ogar-flow-m365/src/adjacency.rs new file mode 100644 index 0000000..29556f6 --- /dev/null +++ b/crates/ogar-flow-m365/src/adjacency.rs @@ -0,0 +1,446 @@ +//! The **command adjacency table**: friendly labels beside typed operations. +//! +//! A label is metadata. The [`Operation`] it points at is authoritative: its +//! effect, the capability it needs, its input/output shape, the backend that +//! can execute it, and the call byte it lowers to. Two labels that point at +//! one operation are aliases and cannot mean different things, because there +//! is only one row for them to point at. +//! +//! ```text +//! "Get-MgUser" ─┐ +//! ├─► LabelId ─► OperationId ─► Operation { fn_index, effect, capability, … } +//! "Get-AzureADUser" ┘ │ +//! ogar-loco Call (fn_index : value…) +//! ``` +//! +//! 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 crate::vocab; + +/// Index into [`LABELS`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct LabelId(pub u16); + +/// Index into [`OPERATIONS`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OperationId(pub u8); + +/// What running an operation does to the world. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Effect { + /// Reads observed facts; changes nothing. + Read, + /// Produces an output artifact from facts already read. + Export, + /// Changes directory state. Never executable in this crate. + Mutation, + /// Evaluates caller-supplied code. Never executable in this crate. + Execution, +} + +/// A capability bit the report authority must grant before an operation runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum Capability { + /// Read directory identity facts. + ReadIdentity = 1 << 0, + /// Read observed license facts. + ReadLicenses = 1 << 1, + /// Read observed group memberships. + ReadMemberships = 1 << 2, + /// Produce a report artifact. + Export = 1 << 3, +} + +/// A set of [`Capability`] bits. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct CapabilitySet(pub u8); + +impl CapabilitySet { + /// No capabilities. + pub const NONE: Self = Self(0); + + /// The set holding exactly `caps`. + #[must_use] + pub const fn of(caps: &[Capability]) -> Self { + let mut bits = 0u8; + let mut i = 0; + while i < caps.len() { + bits |= caps[i] as u8; + i += 1; + } + Self(bits) + } + + /// Whether `cap` is in the set. + #[must_use] + pub const fn has(self, cap: Capability) -> bool { + self.0 & (cap as u8) != 0 + } +} + +/// What flows into and out of an operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FlowShape { + /// Nothing: the operation is a source. + Nothing, + /// A population of directory rows. + Population, + /// A finished report: the operation is a sink. + Report, +} + +/// Which engine can execute an operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Backend { + /// Lowered to `ogar-loco` calls and run by its `Interpreter`. + Loco, + /// No backend: the label is known, the operation is refused. + None, +} + +/// 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). +#[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. + Unminted(&'static str), +} + +/// One typed operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Operation { + /// Canonical operation name (snake_case). Never a PowerShell label. + pub name: &'static str, + /// The call byte this operation lowers to; `None` = not executable. + pub fn_index: Option, + /// What running it does. + pub effect: Effect, + /// The capability it needs; `None` = gated by field permissions only. + pub capability: Option, + /// What it consumes. + pub input: FlowShape, + /// What it produces. + pub output: FlowShape, + /// Who can execute it. + pub backend: Backend, + /// Its semantic target. + pub binding: ConceptBinding, +} + +impl Operation { + /// Whether this operation can run at all: it has a call byte, a backend, + /// and its effect is read or export. + #[must_use] + pub const fn is_available(&self) -> bool { + self.fn_index.is_some() + && matches!(self.backend, Backend::Loco) + && matches!(self.effect, Effect::Read | Effect::Export) + } +} + +/// 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 + Operation { + name: "query_user", + fn_index: Some(vocab::GET_USER), + effect: Effect::Read, + capability: Some(Capability::ReadIdentity), + input: FlowShape::Nothing, + output: FlowShape::Population, + backend: Backend::Loco, + binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + }, + // 1 + Operation { + name: "read_license_evidence", + fn_index: Some(vocab::GET_LICENSE_EVIDENCE), + effect: Effect::Read, + capability: Some(Capability::ReadLicenses), + input: FlowShape::Population, + output: FlowShape::Population, + backend: Backend::Loco, + binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + }, + // 2 + Operation { + name: "read_membership", + fn_index: Some(vocab::GET_MEMBERSHIP), + effect: Effect::Read, + capability: Some(Capability::ReadMemberships), + input: FlowShape::Population, + output: FlowShape::Population, + backend: Backend::Loco, + binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + }, + // 3 + Operation { + name: "filter_rows", + fn_index: Some(vocab::WHERE), + effect: Effect::Read, + capability: None, + input: FlowShape::Population, + output: FlowShape::Population, + backend: Backend::Loco, + binding: ConceptBinding::Generic, + }, + // 4 + Operation { + name: "select_fields", + fn_index: Some(vocab::SELECT), + effect: Effect::Read, + capability: None, + input: FlowShape::Population, + output: FlowShape::Population, + backend: Backend::Loco, + binding: ConceptBinding::Generic, + }, + // 5 + Operation { + name: "export_report", + fn_index: Some(vocab::EXPORT), + effect: Effect::Export, + capability: Some(Capability::Export), + input: FlowShape::Population, + output: FlowShape::Report, + backend: Backend::Loco, + binding: ConceptBinding::Unminted( + "report execution is lance-graph-report's; no OGAR concept", + ), + }, + // 6 — known, refused. + Operation { + name: "update_user", + fn_index: None, + effect: Effect::Mutation, + capability: None, + input: FlowShape::Population, + output: FlowShape::Nothing, + backend: Backend::None, + binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + }, + // 7 + Operation { + name: "remove_user", + fn_index: None, + effect: Effect::Mutation, + capability: None, + input: FlowShape::Population, + output: FlowShape::Nothing, + backend: Backend::None, + binding: ConceptBinding::DirSchema(MS_GRAPH_USER), + }, + // 8 + Operation { + name: "add_group_member", + fn_index: None, + effect: Effect::Mutation, + capability: None, + input: FlowShape::Population, + output: FlowShape::Nothing, + backend: Backend::None, + binding: ConceptBinding::Unminted("no directory group-membership concept in ogar-vocab"), + }, + // 9 + Operation { + name: "assign_license", + fn_index: None, + effect: Effect::Mutation, + capability: None, + input: FlowShape::Population, + output: FlowShape::Nothing, + backend: Backend::None, + binding: ConceptBinding::Unminted("no license-assignment concept in ogar-vocab"), + }, + // 10 + Operation { + name: "evaluate_expression", + fn_index: None, + effect: Effect::Execution, + capability: None, + input: FlowShape::Nothing, + output: FlowShape::Nothing, + backend: Backend::None, + binding: ConceptBinding::Generic, + }, +]; + +/// One friendly label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Label { + /// The label as an administrator types it. Matched case-insensitively. + pub text: &'static str, + /// The operation it names. + pub operation: OperationId, +} + +/// Every known label. Several labels may name one operation (aliases). +pub const LABELS: &[Label] = &[ + Label { + text: "Get-MgUser", + operation: OperationId(0), + }, + Label { + text: "Get-AzureADUser", + operation: OperationId(0), + }, + Label { + text: "Get-IamLicenseEvidence", + operation: OperationId(1), + }, + Label { + text: "Get-IamMembership", + operation: OperationId(2), + }, + Label { + text: "Where-Object", + operation: OperationId(3), + }, + Label { + text: "where", + operation: OperationId(3), + }, + Label { + text: "?", + operation: OperationId(3), + }, + Label { + text: "Select-Object", + operation: OperationId(4), + }, + Label { + text: "select", + operation: OperationId(4), + }, + Label { + text: "Export-Report", + operation: OperationId(5), + }, + Label { + text: "Set-MgUser", + operation: OperationId(6), + }, + Label { + text: "Update-MgUser", + operation: OperationId(6), + }, + Label { + text: "Remove-MgUser", + operation: OperationId(7), + }, + Label { + text: "New-MgGroupMember", + operation: OperationId(8), + }, + Label { + text: "Set-MgUserLicense", + operation: OperationId(9), + }, + Label { + text: "Invoke-Expression", + operation: OperationId(10), + }, + Label { + text: "iex", + operation: OperationId(10), + }, +]; + +/// Why a label did not resolve to an executable operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// No label with this text exists. + Unknown(String), + /// The label is known, but its operation cannot run here. + Unavailable { + /// The label as typed. + label: String, + /// The operation it names. + operation: &'static str, + /// Why it cannot run. + effect: Effect, + }, +} + +impl core::fmt::Display for ResolveError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Unknown(l) => write!(f, "unknown command `{l}`"), + Self::Unavailable { + label, + operation, + effect, + } => write!( + f, + "`{label}` names `{operation}` ({effect:?}), which this flow host does not execute" + ), + } + } +} + +impl core::error::Error for ResolveError {} + +/// The operation behind `id`. +#[must_use] +pub fn operation(id: OperationId) -> &'static Operation { + &OPERATIONS[usize::from(id.0)] +} + +/// Find a label by text, case-insensitively. Known-but-unavailable labels +/// resolve here too; [`resolve`] is what refuses them. +#[must_use] +pub fn lookup(text: &str) -> Option<(LabelId, &'static Label)> { + LABELS + .iter() + .enumerate() + .find(|(_, l)| l.text.eq_ignore_ascii_case(text)) + .map(|(i, l)| (LabelId(i as u16), l)) +} + +/// Resolve a label to an executable operation. +/// +/// # Errors +/// +/// [`ResolveError::Unknown`] for an unknown label, and +/// [`ResolveError::Unavailable`] for a known label whose operation mutates, +/// executes code, or has no backend. +pub fn resolve(text: &str) -> Result<(OperationId, &'static Operation), ResolveError> { + let (_, label) = lookup(text).ok_or_else(|| ResolveError::Unknown(text.to_owned()))?; + let op = operation(label.operation); + if op.is_available() { + Ok((label.operation, op)) + } else { + Err(ResolveError::Unavailable { + label: text.to_owned(), + operation: op.name, + effect: op.effect, + }) + } +} + +/// The operation whose call byte is `f`, if any. +#[must_use] +pub fn operation_for_fn(f: FnIndex) -> Option<&'static Operation> { + OPERATIONS.iter().find(|op| op.fn_index == Some(f)) +} diff --git a/crates/ogar-flow-m365/src/dialect.rs b/crates/ogar-flow-m365/src/dialect.rs new file mode 100644 index 0000000..2042837 --- /dev/null +++ b/crates/ogar-flow-m365/src/dialect.rs @@ -0,0 +1,355 @@ +//! The flow dialect: what each flow call byte does when the `ogar-loco` +//! [`Interpreter`](ogar_loco::Interpreter) reaches it. +//! +//! The interpreter owns the program counter and control flow; this dialect +//! owns every value. It refuses every shared-core byte it is handed (flow +//! bodies use none), and it checks the [`ReportScope`] before each domain +//! call, so an operation the actor may not perform fails before any fact is +//! read. + +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::FieldMask; +use ogar_loco::{Dialect, FnIndex}; + +use crate::adjacency::{Capability, operation_for_fn}; +use crate::host::{DirectoryObservations, FieldValue, ReportScope, SourceStamp}; +use crate::report::{Report, ReportRow}; +use crate::vocab::{self, Comparison, ExportFormat, ReportField}; + +/// One row in flight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Row { + key: String, + fields: BTreeMap, +} + +/// The dialect's stack value: a population of rows. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Population { + rows: Vec, +} + +impl Population { + /// How many rows the population holds. + #[must_use] + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether the population is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } + + /// Every field any row currently holds — what this population has + /// materialized, whatever is later selected or exported. + #[must_use] + pub fn loaded_fields(&self) -> FieldMask { + self.rows + .iter() + .flat_map(|r| r.fields.keys()) + .fold(FieldMask::EMPTY, |m, k| m.with(k.position())) + } +} + +/// Why a flow call failed. Every variant stops the run before export, so a +/// failed run produces no report and no partial output. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FlowError { + /// The actor has no report scope at all. + NoScope, + /// The scope does not grant the capability this operation needs. + CapabilityDenied { + /// The operation. + operation: &'static str, + /// The capability it needs. + capability: Capability, + }, + /// A field the scope does not permit was filtered on or selected. + FieldDenied { + /// The field. + field: ReportField, + }, + /// A field was filtered on or selected before any stage loaded it. + FieldNotLoaded { + /// The field. + field: ReportField, + }, + /// A call byte this dialect does not execute (any shared-core byte, or a + /// domain byte outside the flow vocabulary). + UnsupportedCall { + /// The byte. + call: FnIndex, + }, + /// A value byte that does not decode under its codebook. + MalformedCall { + /// The call. + call: FnIndex, + }, + /// The stack did not hold a population where one was required. + StackShape { + /// The call. + call: FnIndex, + }, + /// The run ended without a report, or with values left on the stack. + NoReport, +} + +impl core::fmt::Display for FlowError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{self:?}") + } +} + +impl core::error::Error for FlowError {} + +/// The flow dialect over one host, one scope, one compiled argument table. +pub struct FlowDialect<'a, H: DirectoryObservations> { + host: &'a H, + scope: &'a ReportScope, + args: &'a [String], + requested: &'a str, + trace: &'a [crate::frontend::TraceEntry], + source: SourceStamp, + report: Option, +} + +impl<'a, H: DirectoryObservations> FlowDialect<'a, H> { + /// Bind the dialect for one run. + pub fn new( + host: &'a H, + scope: &'a ReportScope, + flow: &'a crate::frontend::CompiledFlow, + ) -> Self { + Self { + host, + scope, + args: &flow.args, + requested: &flow.requested, + trace: &flow.trace, + source: host.source(), + report: None, + } + } + + /// The report, once `export` has run. + #[must_use] + pub fn report(&self) -> Option<&Report> { + self.report.as_ref() + } + + /// Take the report out of the dialect. + pub fn into_report(self) -> Option { + self.report + } + + fn require(&self, f: FnIndex) -> Result<(), FlowError> { + let op = operation_for_fn(f).ok_or(FlowError::UnsupportedCall { call: f })?; + match op.capability { + Some(cap) if !self.scope.capabilities.has(cap) => Err(FlowError::CapabilityDenied { + operation: op.name, + capability: cap, + }), + _ => Ok(()), + } + } + + fn permit(&self, field: ReportField) -> Result<(), FlowError> { + if self.scope.fields.has(field.position()) { + Ok(()) + } else { + Err(FlowError::FieldDenied { field }) + } + } + + fn arg(&self, f: FnIndex, b: u8) -> Result<&'a str, FlowError> { + self.args + .get(usize::from(b)) + .map(String::as_str) + .ok_or(FlowError::MalformedCall { call: f }) + } + + fn get_user(&self, f: FnIndex, v: [u8; 3]) -> Result { + let user_ref = self.arg(f, v[0])?; + // Ask the host only for permitted fields; the read is projected at + // the source. + let fields = ReportField::USER_FIELDS.intersect(self.scope.fields); + let rows = self + .host + .find_user(self.scope.tenant, user_ref, fields) + .map(|obs| Row { + key: obs.object_id, + // Defence in depth: a host that returns more than it was + // asked for does not get the extra fields into the run. + fields: obs + .fields + .into_iter() + .filter(|(k, _)| fields.has(k.position())) + .collect(), + }) + .into_iter() + .collect(); + Ok(Population { rows }) + } + + fn attach( + &self, + mut pop: Population, + field: ReportField, + read: impl Fn(&str) -> Vec, + ) -> Result { + self.permit(field)?; + for row in &mut pop.rows { + let mut facts = read(&row.key); + facts.sort(); + row.fields.insert(field, FieldValue::List(facts)); + } + Ok(pop) + } + + fn filter(&self, f: FnIndex, v: [u8; 3], mut pop: Population) -> Result { + let field = ReportField::from_position(v[0]).ok_or(FlowError::MalformedCall { call: f })?; + let cmp = Comparison::from_byte(v[1]).ok_or(FlowError::MalformedCall { call: f })?; + let want = self.arg(f, v[2])?; + self.permit(field)?; + let mut kept = Vec::with_capacity(pop.rows.len()); + for row in pop.rows { + let value = row + .fields + .get(&field) + .ok_or(FlowError::FieldNotLoaded { field })?; + let hit = match (cmp, value) { + (Comparison::Contains, FieldValue::List(xs)) => { + xs.iter().any(|x| x.eq_ignore_ascii_case(want)) + } + (Comparison::Eq | Comparison::Ne, FieldValue::Text(s)) => { + s.eq_ignore_ascii_case(want) == (cmp == Comparison::Eq) + } + (Comparison::Eq | Comparison::Ne, FieldValue::Bool(b)) => { + let wb = want.eq_ignore_ascii_case("true"); + (*b == wb) == (cmp == Comparison::Eq) + } + _ => return Err(FlowError::MalformedCall { call: f }), + }; + if hit { + kept.push(row); + } + } + pop.rows = kept; + Ok(pop) + } + + fn project(&self, v: [u8; 3], mut pop: Population) -> Result { + let mask = vocab::unpack_mask(v); + for p in 0..24u8 { + if mask.has(p) + && let Some(field) = ReportField::from_position(p) + { + self.permit(field)?; + } + } + for row in &mut pop.rows { + for p in 0..24u8 { + if mask.has(p) + && let Some(field) = ReportField::from_position(p) + && field != ReportField::ObjectId + && !row.fields.contains_key(&field) + { + return Err(FlowError::FieldNotLoaded { field }); + } + } + row.fields.retain(|k, _| mask.has(k.position())); + if mask.has(ReportField::ObjectId.position()) { + row.fields + .insert(ReportField::ObjectId, FieldValue::Text(row.key.clone())); + } + } + Ok(pop) + } + + fn export(&mut self, f: FnIndex, v: [u8; 3], pop: Population) -> Result<(), FlowError> { + ExportFormat::from_byte(v[0]).ok_or(FlowError::MalformedCall { call: f })?; + // No clipping here: every field enters a row through `get_user`'s + // projection or `attach`, and both check the scope, so a population + // never holds an unpermitted field to clip. + let permitted: FieldMask = self.scope.fields; + let rows = pop + .rows + .into_iter() + .map(|r| ReportRow { fields: r.fields }) + .collect(); + self.report = Some(Report { + requested: self.requested.to_owned(), + tenant: self.scope.tenant, + policy_version: self.scope.policy_version, + permitted_fields: ReportField::ALL + .into_iter() + .filter(|k| permitted.has(k.position())) + .collect(), + source: self.source.clone(), + rows, + trace: self.trace.to_vec(), + }); + Ok(()) + } +} + +fn pop_population(f: FnIndex, stack: &mut Vec) -> Result { + stack.pop().ok_or(FlowError::StackShape { call: f }) +} + +impl Dialect for FlowDialect<'_, H> { + type Value = Population; + type Error = FlowError; + + fn truthy(&self, value: &Population) -> bool { + !value.is_empty() + } + + fn repeat_count(&self, value: &Population) -> u32 { + u32::try_from(value.len()).unwrap_or(u32::MAX) + } + + fn call( + &mut self, + f: FnIndex, + values: [u8; 3], + stack: &mut Vec, + ) -> Result<(), FlowError> { + self.require(f)?; + match f { + vocab::GET_USER => { + let pop = self.get_user(f, values)?; + stack.push(pop); + } + vocab::GET_LICENSE_EVIDENCE => { + let pop = pop_population(f, stack)?; + let (host, tenant) = (self.host, self.scope.tenant); + stack.push(self.attach(pop, ReportField::Licenses, |k| host.licenses(tenant, k))?); + } + vocab::GET_MEMBERSHIP => { + let pop = pop_population(f, stack)?; + let (host, tenant) = (self.host, self.scope.tenant); + stack.push(self.attach(pop, ReportField::Memberships, |k| { + host.memberships(tenant, k) + })?); + } + vocab::WHERE => { + let pop = pop_population(f, stack)?; + stack.push(self.filter(f, values, pop)?); + } + vocab::SELECT => { + let pop = pop_population(f, stack)?; + stack.push(self.project(values, pop)?); + } + vocab::EXPORT => { + let pop = pop_population(f, stack)?; + self.export(f, values, pop)?; + } + _ => return Err(FlowError::UnsupportedCall { call: f }), + } + Ok(()) + } +} diff --git a/crates/ogar-flow-m365/src/fixture.rs b/crates/ogar-flow-m365/src/fixture.rs new file mode 100644 index 0000000..be04447 --- /dev/null +++ b/crates/ogar-flow-m365/src/fixture.rs @@ -0,0 +1,313 @@ +//! The synthetic "John Doe 101" fixture: a deterministic directory, a test +//! authority, and a test assertion verifier. +//! +//! **Nothing here is security.** [`FixtureAuthority`] stands in for the +//! production OGAR RBAC binding and [`FixtureVerifier`] for a real OIDC/SAML +//! assertion verifier. They exist so the flow can be exercised end to end +//! through the real `ogar-loco` interpreter and the real +//! `ogar_auth::federation::establish` boundary. Compiled only for tests and +//! under the `fixture` feature. + +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::FieldMask; +use lance_graph_contract::sla::TenantId; +use ogar_auth::federation::{ + AssertionVerifier, LoginError, TrustedIssuers, VerifiedClaims, VerifiedLogin, establish, +}; +use ogar_auth::identity::{ExternalIdentity, ExternalSubject, Issuer}; +use ogar_auth::user::{ + AuthBinding, AuthStrength, AuthenticatedUser, LocalUserStore, ProviderId, User, UserId, +}; + +use crate::adjacency::{Capability, CapabilitySet}; +use crate::host::{ + DirectoryObservations, FieldValue, ReportAuthority, ReportScope, SourceStamp, UserObservation, +}; +use crate::vocab::ReportField; + +/// The fixture's tenant. +pub const TEST_TENANT: TenantId = 0x7E57; +/// A second tenant, holding a user the first tenant must never see. +pub const OTHER_TENANT: TenantId = 0x0BAD; +/// The fixture's issuer. +pub const ISSUER: &str = "https://login.example.test/fixture"; +/// The fixture's identity provider. +pub const PROVIDER: ProviderId = ProviderId("fixture-idp"); + +/// One synthetic directory user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FixtureUser { + /// Tenant the user lives in. + pub tenant: TenantId, + /// Synthetic object id. + pub object_id: &'static str, + /// UPN. + pub upn: &'static str, + /// Display name. + pub display_name: &'static str, + /// Department. + pub department: &'static str, + /// Mail address. + pub mail: &'static str, + /// Whether the account is enabled. + pub enabled: bool, + /// Observed license SKUs. + pub licenses: &'static [&'static str], + /// Observed group memberships. + pub memberships: &'static [&'static str], + /// Whether a primary mailbox is observed. + pub mailbox: bool, +} + +/// The fixture directory. +pub const USERS: &[FixtureUser] = &[ + FixtureUser { + tenant: TEST_TENANT, + object_id: "00000000-0000-4000-8000-000000000101", + upn: "john.doe@example.test", + display_name: "John Doe", + department: "Operations", + mail: "john.doe@example.test", + enabled: true, + licenses: &["M365 E3", "Teams Phone"], + memberships: &["Reporting-Readers", "ServiceDesk"], + mailbox: true, + }, + FixtureUser { + tenant: TEST_TENANT, + object_id: "00000000-0000-4000-8000-000000000102", + upn: "jane.roe@example.test", + display_name: "Jane Roe", + department: "Finance", + mail: "jane.roe@example.test", + enabled: true, + licenses: &["M365 E5"], + memberships: &["Finance-Approvers"], + mailbox: true, + }, + FixtureUser { + tenant: OTHER_TENANT, + object_id: "00000000-0000-4000-8000-000000000201", + upn: "john.doe@other.test", + display_name: "John Doe (other tenant)", + department: "Secret", + mail: "john.doe@other.test", + enabled: true, + licenses: &["M365 E5"], + memberships: &["Board"], + mailbox: true, + }, +]; + +/// The fixture's observation snapshot. +#[derive(Debug, Clone, Copy, Default)] +pub struct FixtureDirectory; + +impl FixtureDirectory { + fn user(tenant: TenantId, user_ref: &str) -> Option<&'static FixtureUser> { + USERS.iter().find(|u| { + u.tenant == tenant + && (u.upn.eq_ignore_ascii_case(user_ref) + || u.object_id.eq_ignore_ascii_case(user_ref)) + }) + } + + fn by_key(tenant: TenantId, object_id: &str) -> Option<&'static FixtureUser> { + USERS + .iter() + .find(|u| u.tenant == tenant && u.object_id == object_id) + } + + /// Every field of `u`, unprojected. Used by hosts that deliberately + /// misbehave in tests. + #[must_use] + pub fn all_fields(u: &FixtureUser) -> Vec<(ReportField, FieldValue)> { + vec![ + ( + ReportField::UserPrincipalName, + FieldValue::Text(u.upn.into()), + ), + ( + ReportField::DisplayName, + FieldValue::Text(u.display_name.into()), + ), + ( + ReportField::Department, + FieldValue::Text(u.department.into()), + ), + (ReportField::Mail, FieldValue::Text(u.mail.into())), + (ReportField::AccountEnabled, FieldValue::Bool(u.enabled)), + (ReportField::MailboxAvailable, FieldValue::Bool(u.mailbox)), + ] + } +} + +impl DirectoryObservations for FixtureDirectory { + fn source(&self) -> SourceStamp { + SourceStamp { + source_id: "fixture:john-doe-101".into(), + observed_version: 7, + } + } + + fn find_user( + &self, + tenant: TenantId, + user_ref: &str, + fields: FieldMask, + ) -> Option { + let u = Self::user(tenant, user_ref)?; + Some(UserObservation { + object_id: u.object_id.into(), + fields: Self::all_fields(u) + .into_iter() + .filter(|(k, _)| fields.has(k.position())) + .collect(), + }) + } + + fn licenses(&self, tenant: TenantId, object_id: &str) -> Vec { + Self::by_key(tenant, object_id) + .map(|u| u.licenses.iter().map(|s| (*s).to_owned()).collect()) + .unwrap_or_default() + } + + fn memberships(&self, tenant: TenantId, object_id: &str) -> Vec { + Self::by_key(tenant, object_id) + .map(|u| u.memberships.iter().map(|s| (*s).to_owned()).collect()) + .unwrap_or_default() + } +} + +/// A test authority: scopes keyed by canonical `(UserId, tenant)`. +#[derive(Debug, Clone, Default)] +pub struct FixtureAuthority { + scopes: BTreeMap<(u64, TenantId), ReportScope>, +} + +impl FixtureAuthority { + /// Grant `scope` to the canonical user `id` in `tenant`. + #[must_use] + pub fn grant(mut self, id: UserId, tenant: TenantId, scope: ReportScope) -> Self { + self.scopes.insert((id.0, tenant), scope); + self + } + + /// The standard fixture: the report reader (`READER`) may read identity, + /// licenses and memberships and export, on every field except `Mail`. + /// The reader's scope is pinned to [`TEST_TENANT`]. + #[must_use] + pub fn standard() -> Self { + Self::default().grant(READER, TEST_TENANT, reader_scope()) + } +} + +/// The reader's scope in [`FixtureAuthority::standard`]. +#[must_use] +pub fn reader_scope() -> ReportScope { + ReportScope { + tenant: TEST_TENANT, + capabilities: CapabilitySet::of(&[ + Capability::ReadIdentity, + Capability::ReadLicenses, + Capability::ReadMemberships, + Capability::Export, + ]), + fields: FieldMask::from_positions(&[0, 1, 2, 3, 5, 6, 7, 8]), + policy_version: 1, + } +} + +impl ReportAuthority for FixtureAuthority { + fn scope_for(&self, actor: &AuthenticatedUser) -> Option { + // Keyed by the canonical user and tenant, never by the subject string. + self.scopes + .get(&(actor.user.id.0, actor.user.tenant)) + .cloned() + } +} + +/// Canonical id of the fixture's report reader. +pub const READER: UserId = UserId(1001); +/// Canonical id of a fixture user with no report scope. +pub const OUTSIDER: UserId = UserId(1002); + +/// A test assertion verifier: accepts assertions of the form +/// `"fixture-signed:"` from [`ISSUER`]. It stands in for real token +/// verification so the flow is driven through `establish`. +#[derive(Debug, Clone, Copy, Default)] +pub struct FixtureVerifier; + +/// Why a fixture assertion was rejected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BadAssertion; + +impl AssertionVerifier for FixtureVerifier { + type Input = str; + type Error = BadAssertion; + const ID: &'static str = "fixture"; + + fn verify( + &self, + _trusted: &TrustedIssuers, + input: &str, + ) -> Result { + let subject = input.strip_prefix("fixture-signed:").ok_or(BadAssertion)?; + Ok(VerifiedClaims { + identity: ExternalIdentity::new( + Issuer::new(ISSUER).map_err(|_| BadAssertion)?, + ExternalSubject::new(subject).map_err(|_| BadAssertion)?, + ), + strength: AuthStrength::MultiFactor, + email: None, + }) + } +} + +/// The fixture's user store and trusted issuers: `READER` (subject `reader`) +/// and `OUTSIDER` (subject `outsider`), both in [`TEST_TENANT`]. +#[must_use] +pub fn identity_store() -> (LocalUserStore, TrustedIssuers) { + let mut store = LocalUserStore::new(); + for (id, subject) in [(READER, "reader"), (OUTSIDER, "outsider")] { + store.insert(User { + id, + subject: subject.into(), + tenant: TEST_TENANT, + roles: Vec::new(), + memberships: Vec::new(), + bindings: vec![AuthBinding::new(PROVIDER, subject)], + key_refs: Vec::new(), + }); + store + .bind_external( + id, + ExternalIdentity::new( + Issuer::new(ISSUER).expect("fixture issuer is valid"), + ExternalSubject::new(subject).expect("fixture subject is non-empty"), + ), + ) + .expect("fixture binding is unique"); + } + let mut trusted = TrustedIssuers::new(); + trusted + .insert( + Issuer::new(ISSUER).expect("fixture issuer is valid"), + PROVIDER, + ) + .expect("one fixture issuer"); + (store, trusted) +} + +/// Log in through `ogar_auth::federation::establish` with a fixture +/// assertion. +/// +/// # Errors +/// +/// Whatever `establish` reports: a malformed assertion, an untrusted issuer, +/// or a subject bound to no user. +pub fn login(assertion: &str) -> Result> { + let (store, trusted) = identity_store(); + establish(&FixtureVerifier, &trusted, assertion, &store) +} diff --git a/crates/ogar-flow-m365/src/frontend.rs b/crates/ogar-flow-m365/src/frontend.rs new file mode 100644 index 0000000..166481d --- /dev/null +++ b/crates/ogar-flow-m365/src/frontend.rs @@ -0,0 +1,379 @@ +//! Lowering an admitted PowerShell-shaped pipeline into `ogar-loco` calls. +//! +//! This is a declarative input format, not PowerShell. The admitted subset: +//! +//! ```text +//! pipeline := stage ( "|" stage )* +//! stage := label argument* +//! argument := "-" Name value? | value +//! value := Word | "double-quoted" | 'single-quoted' | value "," value +//! ``` +//! +//! with one shape per operation: +//! +//! | operation | admitted form | +//! |---|---| +//! | `query_user` | `Get-MgUser -UserId ` or `Get-MgUser ` | +//! | `read_license_evidence`, `read_membership` | no arguments | +//! | `filter` | `Where-Object -eq\|-ne\|-contains ` | +//! | `project` | `Select-Object [-Property] , , …` | +//! | `export_report` | `Export-Report [-Format JSON]` | +//! +//! Anything else is refused: script blocks, variables, subexpressions, +//! statement separators, redirection, escapes, unknown parameters, and every +//! label [`resolve`](crate::adjacency::resolve) refuses. + +use lance_graph_contract::class_view::FieldMask; +use ogar_loco::{Call, FnIndex, FunctionBody, Program}; + +use crate::adjacency::{self, FlowShape, OperationId, ResolveError}; +use crate::vocab::{self, Comparison, ExportFormat, FLOW_SHAPE, ReportField}; + +/// Characters that only appear in PowerShell constructs this frontend does +/// not admit (script blocks, variables, subexpressions, separators, +/// redirection, escapes, splatting, hashtables, call operators). +const REFUSED_CHARS: &[char] = &['{', '}', '$', ';', '&', '(', ')', '`', '>', '<', '[', ']']; + +/// Characters refused only where a token starts (`@params`, `@(…)`, `@{…}`); +/// inside a bareword such as `john.doe@example.test` they are literal. +const REFUSED_AT_START: &[char] = &['@']; + +/// One resolved stage, recorded for the report's command trace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TraceEntry { + /// The label exactly as written. + pub label: String, + /// The canonical operation it resolved to. + pub operation: &'static str, + /// The call byte it lowered to. + pub fn_index: FnIndex, +} + +/// A lowered pipeline: one `ogar-loco` program plus the strings its calls +/// index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompiledFlow { + /// The program. One body: the entry. + pub program: Program, + /// The argument table `get_user` and `where` index by value byte. + pub args: Vec, + /// One entry per stage, in order. + pub trace: Vec, + /// The user reference the pipeline asked for. + pub requested: String, +} + +/// Why a pipeline did not compile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompileError { + /// The input uses PowerShell syntax this frontend does not admit. + UnsupportedSyntax { + /// The offending character. + found: char, + }, + /// An unterminated quoted string. + UnterminatedString, + /// A stage with no command. + EmptyStage, + /// The label did not resolve to an executable operation. + Resolve(ResolveError), + /// A parameter the operation does not take. + UnknownParameter { + /// The stage's label. + label: String, + /// The parameter as written. + parameter: String, + }, + /// The stage's arguments do not match its admitted form. + BadArguments { + /// The stage's label. + label: String, + /// What was expected. + expected: &'static str, + }, + /// A property name that is not a [`ReportField`]. + UnknownField(String), + /// `-contains` on a scalar field, or `-eq`/`-ne` on a list field. + ComparisonMismatch { + /// The field. + field: ReportField, + }, + /// An export format this host does not produce. + UnknownFormat(String), + /// A stage cannot consume what the previous stage produces. + ShapeMismatch { + /// The stage's label. + label: String, + /// What the stage consumes. + expected: FlowShape, + /// What the previous stage produced. + found: FlowShape, + }, + /// The pipeline does not end in a report. + NoExport, + /// More strings than one value byte can index. + TooManyArguments, + /// More stages than one function body holds. + TooManyStages, +} + +impl core::fmt::Display for CompileError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{self:?}") + } +} + +impl core::error::Error for CompileError {} + +impl From for CompileError { + fn from(e: ResolveError) -> Self { + Self::Resolve(e) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Token { + /// `-Name`. + Param(String), + /// A bare word or quoted string. + Value(String), + /// `,`. + Comma, +} + +/// Append to the current stage. `stages` always holds at least one. +fn push(stages: &mut [Vec], tok: Token) { + if let Some(s) = stages.last_mut() { + s.push(tok); + } +} + +/// Split the pipeline into stages and tokens, refusing unadmitted syntax. +fn tokenize(source: &str) -> Result>, CompileError> { + let mut stages = vec![Vec::new()]; + let mut chars = source.chars().peekable(); + while let Some(c) = chars.next() { + match c { + c if c.is_whitespace() => {} + '|' => stages.push(Vec::new()), + ',' => push(&mut stages, Token::Comma), + '"' | '\'' => { + let mut s = String::new(); + loop { + match chars.next() { + None => return Err(CompileError::UnterminatedString), + Some(q) if q == c => break, + Some(x) if REFUSED_CHARS.contains(&x) => { + return Err(CompileError::UnsupportedSyntax { found: x }); + } + Some(x) => s.push(x), + } + } + push(&mut stages, Token::Value(s)); + } + c if REFUSED_CHARS.contains(&c) || REFUSED_AT_START.contains(&c) => { + return Err(CompileError::UnsupportedSyntax { found: c }); + } + c => { + let mut word = String::from(c); + while let Some(&n) = chars.peek() { + if n.is_whitespace() || n == '|' || n == ',' || n == '"' || n == '\'' { + break; + } + if REFUSED_CHARS.contains(&n) { + return Err(CompileError::UnsupportedSyntax { found: n }); + } + word.push(n); + chars.next(); + } + let tok = match word.strip_prefix('-') { + Some(name) if !name.is_empty() => Token::Param(name.to_owned()), + _ => Token::Value(word), + }; + push(&mut stages, tok); + } + } + } + Ok(stages) +} + +/// Values separated by commas, with no parameters among them. +fn value_list(tokens: &[Token]) -> Option> { + let mut out = Vec::new(); + let mut expect_value = true; + for t in tokens { + match (t, expect_value) { + (Token::Value(v), true) => { + out.push(v.as_str()); + expect_value = false; + } + (Token::Comma, false) => expect_value = true, + _ => return None, + } + } + (!out.is_empty() && !expect_value).then_some(out) +} + +struct Lowerer { + args: Vec, +} + +impl Lowerer { + fn intern(&mut self, s: &str) -> Result { + if let Some(i) = self.args.iter().position(|a| a == s) { + return Ok(i as u8); + } + let i = u8::try_from(self.args.len()).map_err(|_| CompileError::TooManyArguments)?; + self.args.push(s.to_owned()); + Ok(i) + } + + fn lower( + &mut self, + label: &str, + op: OperationId, + args: &[Token], + ) -> Result { + let bad = |expected| CompileError::BadArguments { + label: label.to_owned(), + expected, + }; + let unknown = |p: &str| CompileError::UnknownParameter { + label: label.to_owned(), + parameter: p.to_owned(), + }; + let spec = adjacency::operation(op); + let f = spec + .fn_index + .expect("resolve only returns operations with a call byte"); + match f { + vocab::GET_USER => { + if let Some(Token::Param(p)) = args + .iter() + .find(|t| matches!(t, Token::Param(p) if !p.eq_ignore_ascii_case("UserId"))) + { + return Err(unknown(p)); + } + let user = match args { + [Token::Param(p), Token::Value(v)] if p.eq_ignore_ascii_case("UserId") => v, + [Token::Value(v)] => v, + _ => return Err(bad("-UserId ")), + }; + Ok(Call::with_value(f, self.intern(user)?)) + } + vocab::GET_LICENSE_EVIDENCE | vocab::GET_MEMBERSHIP => match args { + [] => Ok(Call::new(f)), + [Token::Param(p), ..] => Err(unknown(p)), + _ => Err(bad("no arguments")), + }, + vocab::WHERE => { + let [Token::Value(field), Token::Param(cmp), Token::Value(value)] = args else { + if let Some(Token::Param(p)) = args.first() { + return Err(unknown(p)); + } + return Err(bad(" -eq|-ne|-contains ")); + }; + let field = ReportField::from_name(field) + .ok_or_else(|| CompileError::UnknownField(field.clone()))?; + let cmp = match cmp.to_ascii_lowercase().as_str() { + "eq" => Comparison::Eq, + "ne" => Comparison::Ne, + "contains" => Comparison::Contains, + _ => return Err(unknown(cmp)), + }; + if field.is_list() != (cmp == Comparison::Contains) { + return Err(CompileError::ComparisonMismatch { field }); + } + Ok(Call::with_values( + f, + [field.position(), cmp.byte(), self.intern(value)?], + )) + } + vocab::SELECT => { + let rest = match args { + [Token::Param(p), rest @ ..] if p.eq_ignore_ascii_case("Property") => rest, + [Token::Param(p), ..] => return Err(unknown(p)), + rest => rest, + }; + let names = value_list(rest).ok_or_else(|| bad(", , …"))?; + let mut mask = FieldMask::EMPTY; + for n in names { + let field = ReportField::from_name(n) + .ok_or_else(|| CompileError::UnknownField(n.to_owned()))?; + mask = mask.with(field.position()); + } + let packed = + vocab::pack_mask(mask).ok_or_else(|| bad("fields below position 24"))?; + Ok(Call::with_values(f, packed)) + } + vocab::EXPORT => { + let format = match args { + [] => ExportFormat::Json, + [Token::Param(p), Token::Value(v)] if p.eq_ignore_ascii_case("Format") => { + if v.eq_ignore_ascii_case("JSON") { + ExportFormat::Json + } else { + return Err(CompileError::UnknownFormat(v.clone())); + } + } + [Token::Param(p), ..] => return Err(unknown(p)), + _ => return Err(bad("[-Format JSON]")), + }; + Ok(Call::with_value(f, format as u8)) + } + _ => unreachable!("every executable operation is lowered above"), + } + } +} + +/// Compile a pipeline into a [`CompiledFlow`]. +/// +/// # Errors +/// +/// The first [`CompileError`] found. Nothing partial is returned. +pub fn compile(source: &str) -> Result { + let stages = tokenize(source)?; + let mut lowerer = Lowerer { args: Vec::new() }; + let mut calls = Vec::with_capacity(stages.len()); + let mut trace = Vec::with_capacity(stages.len()); + let mut requested = None; + let mut flowing = FlowShape::Nothing; + for stage in &stages { + let (Token::Value(label), args) = stage.split_first().ok_or(CompileError::EmptyStage)? + else { + return Err(CompileError::EmptyStage); + }; + let (op_id, op) = adjacency::resolve(label)?; + if op.input != flowing { + return Err(CompileError::ShapeMismatch { + label: label.clone(), + expected: op.input, + found: flowing, + }); + } + let call = lowerer.lower(label, op_id, args)?; + if call.function == vocab::GET_USER { + requested = Some(lowerer.args[usize::from(call.values[0])].clone()); + } + calls.push(call); + trace.push(TraceEntry { + label: label.clone(), + operation: op.name, + fn_index: call.function, + }); + flowing = op.output; + } + if flowing != FlowShape::Report { + return Err(CompileError::NoExport); + } + let body = + FunctionBody::from_calls(FLOW_SHAPE, &calls).map_err(|_| CompileError::TooManyStages)?; + Ok(CompiledFlow { + program: Program { + functions: vec![body], + }, + args: lowerer.args, + trace, + requested: requested.expect("a pipeline ending in a report starts with its one source"), + }) +} diff --git a/crates/ogar-flow-m365/src/host.rs b/crates/ogar-flow-m365/src/host.rs new file mode 100644 index 0000000..c1f5217 --- /dev/null +++ b/crates/ogar-flow-m365/src/host.rs @@ -0,0 +1,91 @@ +//! The host seams: where observed facts come from, and who decides what an +//! actor may see. +//! +//! Both are traits so a fixture today and the production adapters tomorrow +//! (`ogar-az` observations, the OGAR RBAC binding) drop in without the +//! dialect changing. + +use lance_graph_contract::class_view::FieldMask; +use lance_graph_contract::sla::TenantId; +use ogar_auth::user::AuthenticatedUser; + +use crate::adjacency::CapabilitySet; +use crate::vocab::ReportField; + +/// One field value. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum FieldValue { + /// A scalar string. + Text(String), + /// A boolean. + Bool(bool), + /// A list of strings (licenses, memberships). + List(Vec), +} + +/// One observed directory user, carrying only the fields that were asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserObservation { + /// The row key used to attach license and membership facts. It is never + /// exported unless [`ReportField::ObjectId`] is permitted and selected. + pub object_id: String, + /// The requested fields that were observed. + pub fields: Vec<(ReportField, FieldValue)>, +} + +/// Where the facts in a report came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceStamp { + /// A stable identifier for the observation source. + pub source_id: String, + /// The version of the observation snapshot the facts were read from. + pub observed_version: u64, +} + +/// Directory facts, as observed. Read-only. +/// +/// Every query is scoped to the tenant the authority granted, never to a +/// tenant named in the pipeline. +pub trait DirectoryObservations { + /// The snapshot every answer below is read from. + fn source(&self) -> SourceStamp; + + /// The user named by `user_ref` (UPN or object id) in `tenant`, carrying + /// only the fields in `fields`. `None` when no such user is observed in + /// that tenant. + fn find_user( + &self, + tenant: TenantId, + user_ref: &str, + fields: FieldMask, + ) -> Option; + + /// Observed license SKUs of `object_id` in `tenant`. + fn licenses(&self, tenant: TenantId, object_id: &str) -> Vec; + + /// Observed group memberships of `object_id` in `tenant`. + fn memberships(&self, tenant: TenantId, object_id: &str) -> Vec; +} + +/// What an actor may do and see in one flow run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportScope { + /// The only tenant the run may read. + pub tenant: TenantId, + /// The operations the run may perform. + pub capabilities: CapabilitySet, + /// The fields the run may read, filter on, and export. + pub fields: FieldMask, + /// The version of the policy this scope was derived from. + pub policy_version: u32, +} + +/// Decides an actor's [`ReportScope`]. +/// +/// The input is the canonical, already-verified OGAR user. Nothing in a +/// pipeline (a `-UserId`, an email address, a command label) reaches this +/// call, so nothing in a pipeline can widen the scope. +pub trait ReportAuthority { + /// The scope `actor` may run report flows under; `None` = no access. + fn scope_for(&self, actor: &AuthenticatedUser) -> Option; +} diff --git a/crates/ogar-flow-m365/src/lib.rs b/crates/ogar-flow-m365/src/lib.rs new file mode 100644 index 0000000..c0df763 --- /dev/null +++ b/crates/ogar-flow-m365/src/lib.rs @@ -0,0 +1,101 @@ +//! `ogar-flow-m365` — PowerShell-shaped command labels over the `ogar-loco` +//! call ABI. +//! +//! ```text +//! "Get-MgUser -UserId … | Get-IamLicenseEvidence | Select-Object … | Export-Report" +//! │ frontend::compile (strings stop here) +//! ▼ +//! label ─► adjacency::Operation ─► vocab call byte ─► ogar_loco::Program (Quads body) +//! │ +//! VerifiedLogin ─► ReportAuthority ─► ReportScope │ ogar_loco::Interpreter::run +//! │ ▼ +//! └────► FlowDialect ─► DirectoryObservations +//! │ +//! ▼ +//! Report ─► to_json (export boundary) +//! ``` +//! +//! - [`adjacency`] — the label → typed operation table. Labels are metadata; +//! the operation (effect, capability, shapes, backend, call byte) is +//! authoritative. Aliases are several labels naming one operation. +//! - [`vocab`] — the flow [`Vocabulary`](ogar_loco::Vocabulary): six domain +//! call bytes at `0x90..=0x95`, validated by `ogar-loco`'s conformance +//! check. +//! - [`frontend`] — lowers the admitted read-only pipeline subset into one +//! `ogar-loco` function body and refuses everything else. +//! - [`dialect`] — the [`Dialect`](ogar_loco::Dialect) the `ogar-loco` +//! interpreter runs the body under. +//! - [`host`] — the two host seams: observed directory facts, and the report +//! authority. +//! +//! `ogar-loco` is used unchanged: no new opcode, no change to its call ABI, +//! its interpreter or its conformance rules. +//! +//! The production authority (OGAR RBAC) and observation adapters (`ogar-az` +//! over Microsoft Graph) are not wired here; see the crate README. + +#![warn(missing_docs)] +#![forbid(unsafe_code)] + +pub mod adjacency; +pub mod dialect; +#[cfg(any(test, feature = "fixture"))] +pub mod fixture; +pub mod frontend; +pub mod host; +pub mod report; +pub mod vocab; + +#[cfg(test)] +mod tests; + +pub use adjacency::{ + Capability, CapabilitySet, Effect, LABELS, OPERATIONS, Operation, ResolveError, resolve, +}; +pub use dialect::{FlowDialect, FlowError}; +pub use frontend::{CompileError, CompiledFlow, TraceEntry, compile}; +pub use host::{ + DirectoryObservations, FieldValue, ReportAuthority, ReportScope, SourceStamp, UserObservation, +}; +pub use report::{Report, ReportRow}; +pub use vocab::{FlowVocabulary, ReportField}; + +use ogar_auth::federation::VerifiedLogin; +use ogar_loco::{Interpreter, RunError}; + +/// Run a compiled flow for a verified login. +/// +/// The actor is the login's canonical OGAR user. Its scope comes from +/// `authority`, before anything runs; an actor with no scope gets +/// [`FlowError::NoScope`] and the host is never queried. +/// +/// # Errors +/// +/// `RunError::Dialect` carrying a [`FlowError`] for a refused call or a +/// missing scope, or any other [`RunError`] the `ogar-loco` interpreter +/// reports. A failed run returns no report. +pub fn run_flow( + login: &VerifiedLogin, + flow: &CompiledFlow, + host: &H, + authority: &A, +) -> Result> { + let scope = authority + .scope_for(login.authenticated_user()) + .ok_or(RunError::Dialect(FlowError::NoScope))?; + let vocab = vocab::checked(); + if !flow.program.references_are_resolvable(&vocab) { + return Err(RunError::Dialect(FlowError::NoReport)); + } + let dialect = FlowDialect::new(host, &scope, flow); + let mut interp = Interpreter::new(&vocab, &flow.program, dialect); + interp.run()?; + if !interp.stack().is_empty() { + return Err(RunError::Dialect(FlowError::NoReport)); + } + interp + .dialect() + .report() + .cloned() + .ok_or(RunError::Dialect(FlowError::NoReport)) +} diff --git a/crates/ogar-flow-m365/src/report.rs b/crates/ogar-flow-m365/src/report.rs new file mode 100644 index 0000000..04f7fce --- /dev/null +++ b/crates/ogar-flow-m365/src/report.rs @@ -0,0 +1,136 @@ +//! The report a flow produces, and its deterministic JSON rendering. +//! +//! JSON exists only here, at the export boundary. Nothing upstream of +//! `export` serializes anything. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use lance_graph_contract::sla::TenantId; + +use crate::frontend::TraceEntry; +use crate::host::{FieldValue, SourceStamp}; +use crate::vocab::ReportField; + +/// The format tag every rendered report carries. +pub const REPORT_FORMAT: &str = "ogar-flow-m365/report.v1"; + +/// One exported row: only the selected, permitted fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReportRow { + /// The fields, in mask-position order. + pub fields: BTreeMap, +} + +/// A finished report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + /// The user reference the pipeline asked for. + pub requested: String, + /// The tenant the run was scoped to. + pub tenant: TenantId, + /// The policy version the scope came from. + pub policy_version: u32, + /// The fields the scope permitted. + pub permitted_fields: Vec, + /// Where the facts came from. + pub source: SourceStamp, + /// The rows. + pub rows: Vec, + /// One entry per pipeline stage. + pub trace: Vec, +} + +fn json_str(out: &mut String, s: &str) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + +fn json_value(out: &mut String, v: &FieldValue) { + match v { + FieldValue::Text(s) => json_str(out, s), + FieldValue::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + FieldValue::List(xs) => { + out.push('['); + for (i, x) in xs.iter().enumerate() { + if i > 0 { + out.push(','); + } + json_str(out, x); + } + out.push(']'); + } + } +} + +impl Report { + /// Render as compact JSON. Key order is fixed, so equal reports render to + /// equal bytes. + #[must_use] + pub fn to_json(&self) -> String { + let mut o = String::new(); + o.push_str("{\"format\":"); + json_str(&mut o, REPORT_FORMAT); + o.push_str(",\"requested\":"); + json_str(&mut o, &self.requested); + let _ = write!( + o, + ",\"scope\":{{\"tenant\":{},\"policy_version\":{},\"fields\":[", + self.tenant, self.policy_version + ); + for (i, f) in self.permitted_fields.iter().enumerate() { + if i > 0 { + o.push(','); + } + json_str(&mut o, f.name()); + } + o.push_str("]},\"source\":{\"id\":"); + json_str(&mut o, &self.source.source_id); + let _ = write!( + o, + ",\"observed_version\":{}}},\"rows\":[", + self.source.observed_version + ); + for (i, row) in self.rows.iter().enumerate() { + if i > 0 { + o.push(','); + } + o.push('{'); + for (j, (k, v)) in row.fields.iter().enumerate() { + if j > 0 { + o.push(','); + } + json_str(&mut o, k.name()); + o.push(':'); + json_value(&mut o, v); + } + o.push('}'); + } + o.push_str("],\"trace\":["); + for (i, t) in self.trace.iter().enumerate() { + if i > 0 { + o.push(','); + } + o.push_str("{\"label\":"); + json_str(&mut o, &t.label); + o.push_str(",\"operation\":"); + json_str(&mut o, t.operation); + let _ = write!(o, ",\"fn\":\"{:#04x}\"}}", t.fn_index.0); + } + o.push_str("]}"); + o + } +} diff --git a/crates/ogar-flow-m365/src/tests.rs b/crates/ogar-flow-m365/src/tests.rs new file mode 100644 index 0000000..c9fafa1 --- /dev/null +++ b/crates/ogar-flow-m365/src/tests.rs @@ -0,0 +1,825 @@ +//! Falsifiers F1–F10 for the command adjacency slice. All data is synthetic. + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use lance_graph_contract::class_view::FieldMask; +use lance_graph_contract::sla::TenantId; +use ogar_loco::vocabulary::conformance; +use ogar_loco::{Call, DOMAIN_FLOOR, FnIndex, FunctionBody, Program, RunError}; + +use crate::adjacency::{self, ConceptBinding, Effect, LABELS, OPERATIONS, ResolveError}; +use crate::fixture::{ + self, FixtureAuthority, FixtureDirectory, OTHER_TENANT, OUTSIDER, READER, TEST_TENANT, +}; +use crate::host::{DirectoryObservations, FieldValue, SourceStamp, UserObservation}; +use crate::report::{Report, ReportRow}; +use crate::vocab::{self, FLOW_SHAPE, FlowVocabulary, ReportField}; +use crate::{CompileError, FlowError, compile, run_flow}; + +const JOHN_DOE_101: &str = r#"Get-MgUser -UserId "john.doe@example.test" | + Get-IamLicenseEvidence | + Select-Object DisplayName, Department, Licenses | + Export-Report -Format JSON"#; + +fn reader() -> ogar_auth::federation::VerifiedLogin { + fixture::login("fixture-signed:reader").expect("fixture reader logs in") +} + +fn run(src: &str) -> Result> { + let flow = compile(src).expect("pipeline compiles"); + run_flow( + &reader(), + &flow, + &FixtureDirectory, + &FixtureAuthority::standard(), + ) +} + +// ── The executable slice ──────────────────────────────────────────────────── + +#[test] +fn john_doe_101_runs_through_loco_and_reports_the_three_selected_fields() { + let report = run(JOHN_DOE_101).expect("reader is scoped"); + assert_eq!(report.requested, "john.doe@example.test"); + assert_eq!(report.tenant, TEST_TENANT); + assert_eq!(report.policy_version, 1); + assert_eq!(report.source.source_id, "fixture:john-doe-101"); + assert_eq!(report.source.observed_version, 7); + assert_eq!(report.rows.len(), 1); + let row = &report.rows[0].fields; + assert_eq!(row.len(), 3); + assert_eq!( + row.get(&ReportField::DisplayName), + Some(&FieldValue::Text("John Doe".into())) + ); + assert_eq!( + row.get(&ReportField::Department), + Some(&FieldValue::Text("Operations".into())) + ); + assert_eq!( + row.get(&ReportField::Licenses), + Some(&FieldValue::List(vec![ + "M365 E3".into(), + "Teams Phone".into() + ])) + ); + let ops: Vec<_> = report.trace.iter().map(|t| t.operation).collect(); + assert_eq!( + ops, + [ + "query_user", + "read_license_evidence", + "select_fields", + "export_report" + ] + ); + assert_eq!( + report.to_json(), + concat!( + r#"{"format":"ogar-flow-m365/report.v1","requested":"john.doe@example.test","#, + r#""scope":{"tenant":32343,"policy_version":1,"fields":["ObjectId","UserPrincipalName","DisplayName","Department","AccountEnabled","Licenses","Memberships","MailboxAvailable"]},"#, + r#""source":{"id":"fixture:john-doe-101","observed_version":7},"#, + r#""rows":[{"DisplayName":"John Doe","Department":"Operations","Licenses":["M365 E3","Teams Phone"]}],"#, + r#""trace":[{"label":"Get-MgUser","operation":"query_user","fn":"0x90"},"#, + r#"{"label":"Get-IamLicenseEvidence","operation":"read_license_evidence","fn":"0x91"},"#, + r#"{"label":"Select-Object","operation":"select_fields","fn":"0x94"},"#, + r#"{"label":"Export-Report","operation":"export_report","fn":"0x95"}]}"# + ) + ); +} + +#[test] +fn the_lowered_program_is_one_quads_body_of_four_domain_calls() { + let flow = compile(JOHN_DOE_101).unwrap(); + assert_eq!(flow.program.len(), 1); + let body = flow.program.entry(); + assert_eq!(body.shape(), FLOW_SHAPE); + let fns: Vec<_> = body.calls().map(|c| c.function).collect(); + assert_eq!( + fns, + [ + vocab::GET_USER, + vocab::GET_LICENSE_EVIDENCE, + vocab::SELECT, + vocab::EXPORT + ] + ); + assert!(fns.iter().all(|f| f.is_domain_specific())); + assert_eq!(flow.args, ["john.doe@example.test"]); +} + +#[test] +fn memberships_and_filters_compose_in_one_pipeline() { + let report = run(r#"Get-MgUser john.doe@example.test | Get-IamMembership | + Where-Object Memberships -contains "servicedesk" | + Select-Object DisplayName, Memberships | Export-Report"#) + .unwrap(); + assert_eq!(report.rows.len(), 1); + assert_eq!( + report.rows[0].fields.get(&ReportField::Memberships), + Some(&FieldValue::List(vec![ + "Reporting-Readers".into(), + "ServiceDesk".into() + ])) + ); + // The same filter on a value John does not hold keeps nothing. + let none = run(r#"Get-MgUser john.doe@example.test | Get-IamMembership | + Where-Object Memberships -contains "Board" | + Select-Object DisplayName | Export-Report"#) + .unwrap(); + assert!(none.rows.is_empty()); + let dept = run( + r#"Get-MgUser john.doe@example.test | Where-Object Department -ne Operations | + Select-Object DisplayName | Export-Report"#, + ) + .unwrap(); + assert!(dept.rows.is_empty()); +} + +// ── F1: command resolution ────────────────────────────────────────────────── + +#[test] +fn f1_known_labels_resolve_to_their_typed_operation() { + let cases = [ + ("Get-MgUser", "query_user", vocab::GET_USER), + ("get-mguser", "query_user", vocab::GET_USER), + ( + "Get-IamLicenseEvidence", + "read_license_evidence", + vocab::GET_LICENSE_EVIDENCE, + ), + ( + "Get-IamMembership", + "read_membership", + vocab::GET_MEMBERSHIP, + ), + ("Where-Object", "filter_rows", vocab::WHERE), + ("Select-Object", "select_fields", vocab::SELECT), + ("Export-Report", "export_report", vocab::EXPORT), + ]; + for (label, name, f) in cases { + let (_, op) = adjacency::resolve(label).unwrap(); + assert_eq!((op.name, op.fn_index), (name, Some(f)), "{label}"); + } +} + +#[test] +fn f1_unknown_labels_are_refused() { + for label in ["Get-MgUsr", "ForEach-Object", "Get-Process", ""] { + assert_eq!( + adjacency::resolve(label), + Err(ResolveError::Unknown(label.to_owned())) + ); + } + assert!(matches!( + compile("Get-MgUserX -UserId a | Export-Report"), + Err(CompileError::Resolve(ResolveError::Unknown(_))) + )); +} + +// ── F2: alias equivalence ─────────────────────────────────────────────────── + +#[test] +fn f2_aliases_lower_to_identical_programs_and_identical_reports() { + let a = compile( + "Get-MgUser john.doe@example.test | Where-Object Department -eq Operations | Select-Object DisplayName | Export-Report", + ) + .unwrap(); + let b = compile( + "Get-AzureADUser john.doe@example.test | ? Department -eq Operations | select DisplayName | Export-Report", + ) + .unwrap(); + assert_eq!(a.program, b.program); + assert_eq!(a.args, b.args); + let ra = run_flow( + &reader(), + &a, + &FixtureDirectory, + &FixtureAuthority::standard(), + ) + .unwrap(); + let rb = run_flow( + &reader(), + &b, + &FixtureDirectory, + &FixtureAuthority::standard(), + ) + .unwrap(); + assert_eq!(ra.rows, rb.rows); + // Only the trace's as-written labels differ. + let ops = |r: &Report| { + r.trace + .iter() + .map(|t| (t.operation, t.fn_index)) + .collect::>() + }; + assert_eq!(ops(&ra), ops(&rb)); + assert_ne!(ra.trace[0].label, rb.trace[0].label); +} + +#[test] +fn f2_every_alias_group_shares_one_operation_row() { + let mut by_op: BTreeMap> = BTreeMap::new(); + for l in LABELS { + by_op.entry(l.operation.0).or_default().push(l.text); + } + assert_eq!(by_op[&0], ["Get-MgUser", "Get-AzureADUser"]); + assert_eq!(by_op[&3], ["Where-Object", "where", "?"]); + // No two labels collide case-insensitively. + for (i, a) in LABELS.iter().enumerate() { + for b in &LABELS[i + 1..] { + assert!( + !a.text.eq_ignore_ascii_case(b.text), + "{} / {}", + a.text, + b.text + ); + } + } +} + +// ── F3: canonical vocabulary reuse ────────────────────────────────────────── + +#[test] +fn f3_no_classid_is_minted_and_no_label_is_a_concept() { + for op in OPERATIONS { + // An operation name that matched a minted concept would be a second + // spelling of it. + assert_eq!( + ogar_vocab::canonical_concept_id(op.name), + None, + "{}", + 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(_) + )); + } + for l in LABELS { + let normal = l.text.to_ascii_lowercase().replace('-', "_"); + assert_eq!( + ogar_vocab::canonical_concept_id(&normal), + None, + "{}", + l.text + ); + } +} + +#[test] +fn f3_call_bytes_are_unique_and_comparisons_reuse_the_shared_core_codebook() { + let bytes: Vec<_> = OPERATIONS.iter().filter_map(|o| o.fn_index).collect(); + let mut sorted = bytes.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), bytes.len()); + assert!(bytes.iter().all(|f| f.0 >= DOMAIN_FLOOR)); + for c in [ + vocab::Comparison::Eq, + vocab::Comparison::Ne, + vocab::Comparison::Contains, + ] { + assert!(FnIndex(c.byte()).is_shared_core()); + assert!(ogar_loco::vocabulary::shared_core::name(FnIndex(c.byte())).is_some()); + } +} + +// ── F4: read-only enforcement ─────────────────────────────────────────────── + +#[test] +fn f4_mutating_and_executing_labels_are_known_and_refused() { + for label in [ + "Set-MgUser", + "Update-MgUser", + "Remove-MgUser", + "New-MgGroupMember", + "Set-MgUserLicense", + "Invoke-Expression", + "iex", + ] { + let err = adjacency::resolve(label).unwrap_err(); + let ResolveError::Unavailable { effect, .. } = err else { + panic!("{label}: {err:?}") + }; + assert!( + matches!(effect, Effect::Mutation | Effect::Execution), + "{label}" + ); + } + assert!(matches!( + compile("Get-MgUser john.doe@example.test | Set-MgUser | Export-Report"), + Err(CompileError::Resolve(ResolveError::Unavailable { .. })) + )); +} + +#[test] +fn f4_no_operation_with_a_write_effect_has_a_call_byte() { + for op in OPERATIONS { + if matches!(op.effect, Effect::Mutation | Effect::Execution) { + assert_eq!(op.fn_index, None, "{}", op.name); + assert!(!op.is_available()); + } + } +} + +#[test] +fn f4_a_hand_built_body_with_an_uncovered_byte_is_refused_by_the_dialect() { + let mut flow = compile(JOHN_DOE_101).unwrap(); + for f in [ + FnIndex(0xA0), + FnIndex::ADD, + FnIndex::PRINT, + FnIndex::VAR_SET, + ] { + flow.program = Program { + functions: vec![ + FunctionBody::from_calls( + FLOW_SHAPE, + &[Call::with_value(vocab::GET_USER, 0), Call::new(f)], + ) + .unwrap(), + ], + }; + let err = run_flow( + &reader(), + &flow, + &FixtureDirectory, + &FixtureAuthority::standard(), + ); + assert_eq!( + err, + Err(RunError::Dialect(FlowError::UnsupportedCall { call: f })), + "{f:?}" + ); + } +} + +// ── F5: actor isolation ───────────────────────────────────────────────────── + +/// A host that records every query, to prove a denied run never reads. +#[derive(Default)] +struct CountingHost { + queries: RefCell>, +} + +impl DirectoryObservations for CountingHost { + fn source(&self) -> SourceStamp { + FixtureDirectory.source() + } + fn find_user(&self, t: TenantId, r: &str, f: FieldMask) -> Option { + self.queries + .borrow_mut() + .push(format!("user {t} {r} {:#x}", f.0)); + FixtureDirectory.find_user(t, r, f) + } + fn licenses(&self, t: TenantId, k: &str) -> Vec { + self.queries.borrow_mut().push(format!("licenses {t} {k}")); + FixtureDirectory.licenses(t, k) + } + fn memberships(&self, t: TenantId, k: &str) -> Vec { + self.queries + .borrow_mut() + .push(format!("memberships {t} {k}")); + FixtureDirectory.memberships(t, k) + } +} + +#[test] +fn f5_an_actor_without_scope_gets_no_data_and_the_host_is_never_queried() { + let outsider = fixture::login("fixture-signed:outsider").unwrap(); + assert_eq!(outsider.authenticated_user().user.id, OUTSIDER); + let flow = compile(JOHN_DOE_101).unwrap(); + let host = CountingHost::default(); + let out = run_flow(&outsider, &flow, &host, &FixtureAuthority::standard()); + assert_eq!(out, Err(RunError::Dialect(FlowError::NoScope))); + assert!(host.queries.borrow().is_empty()); +} + +#[test] +fn f5_a_missing_capability_fails_before_that_operation_reads_anything() { + let mut scope = fixture::reader_scope(); + scope.capabilities = + crate::CapabilitySet::of(&[crate::Capability::ReadIdentity, crate::Capability::Export]); + let authority = FixtureAuthority::default().grant(READER, TEST_TENANT, scope); + let host = CountingHost::default(); + let flow = compile(JOHN_DOE_101).unwrap(); + let out = run_flow(&reader(), &flow, &host, &authority); + assert_eq!( + out, + Err(RunError::Dialect(FlowError::CapabilityDenied { + operation: "read_license_evidence", + capability: crate::Capability::ReadLicenses, + })) + ); + let q = host.queries.borrow(); + assert_eq!(q.len(), 1, "{q:?}"); + assert!(q[0].starts_with("user ")); + // No export capability: no report, even though every read was allowed. + let mut scope = fixture::reader_scope(); + scope.capabilities = crate::CapabilitySet::of(&[ + crate::Capability::ReadIdentity, + crate::Capability::ReadLicenses, + ]); + let authority = FixtureAuthority::default().grant(READER, TEST_TENANT, scope); + assert!(matches!( + run_flow(&reader(), &flow, &FixtureDirectory, &authority), + Err(RunError::Dialect(FlowError::CapabilityDenied { + operation: "export_report", + .. + })) + )); +} + +#[test] +fn f5_another_tenants_user_is_indistinguishable_from_no_user() { + // The pipeline names a user in OTHER_TENANT; the reader is scoped to + // TEST_TENANT, so the query runs there and finds nothing. + let foreign = run( + "Get-MgUser john.doe@other.test | Get-IamLicenseEvidence | Select-Object DisplayName, Licenses | Export-Report", + ) + .unwrap(); + let missing = run( + "Get-MgUser nobody@example.test | Get-IamLicenseEvidence | Select-Object DisplayName, Licenses | Export-Report", + ) + .unwrap(); + assert!(foreign.rows.is_empty()); + assert_eq!(foreign.tenant, TEST_TENANT); + let strip = |r: Report| Report { + requested: String::new(), + trace: Vec::new(), + ..r + }; + assert_eq!(strip(foreign), strip(missing)); + assert!( + FixtureDirectory + .find_user(OTHER_TENANT, "john.doe@other.test", FieldMask(!0)) + .is_some() + ); +} + +#[test] +fn f5_pipeline_arguments_cannot_choose_the_actor() { + // Naming another actor as a parameter is refused outright. + for src in [ + "Get-MgUser john.doe@example.test -RunAs reader | Export-Report", + "Get-MgUser -Actor reader | Export-Report", + "Get-MgUser -UserId john.doe@example.test | Get-IamLicenseEvidence -Credential reader | Export-Report", + ] { + assert!( + matches!( + compile(src), + Err(CompileError::UnknownParameter { .. } | CompileError::BadArguments { .. }) + ), + "{src}" + ); + } + // Asking about the reader's own subject does not change whose scope runs. + let flow = compile("Get-MgUser reader | Export-Report").unwrap(); + let outsider = fixture::login("fixture-signed:outsider").unwrap(); + assert_eq!( + run_flow( + &outsider, + &flow, + &FixtureDirectory, + &FixtureAuthority::standard() + ), + Err(RunError::Dialect(FlowError::NoScope)) + ); +} + +#[test] +fn f5_an_unverified_assertion_never_becomes_an_actor() { + assert!(fixture::login("reader").is_err()); + assert!(fixture::login("fixture-signed:nobody").is_err()); +} + +// ── F6: field projection ──────────────────────────────────────────────────── + +/// A host that ignores the requested projection and returns every field. +struct OversharingHost; + +impl DirectoryObservations for OversharingHost { + fn source(&self) -> SourceStamp { + FixtureDirectory.source() + } + fn find_user(&self, t: TenantId, r: &str, _f: FieldMask) -> Option { + FixtureDirectory.find_user(t, r, FieldMask(!0)) + } + fn licenses(&self, t: TenantId, k: &str) -> Vec { + FixtureDirectory.licenses(t, k) + } + fn memberships(&self, t: TenantId, k: &str) -> Vec { + FixtureDirectory.memberships(t, k) + } +} + +#[test] +fn f6_the_unpermitted_field_is_never_requested_from_the_host() { + let host = CountingHost::default(); + let flow = compile(JOHN_DOE_101).unwrap(); + run_flow(&reader(), &flow, &host, &FixtureAuthority::standard()).unwrap(); + let q = host.queries.borrow(); + let mask = u64::from_str_radix( + q[0].rsplit(' ').next().unwrap().trim_start_matches("0x"), + 16, + ) + .unwrap(); + assert_eq!( + mask & (1 << ReportField::Mail.position()), + 0, + "Mail was requested" + ); + assert_ne!(mask & (1 << ReportField::DisplayName.position()), 0); +} + +#[test] +fn f6_an_oversharing_host_cannot_leak_the_fourth_field() { + let flow = compile( + "Get-MgUser john.doe@example.test | Get-IamLicenseEvidence | Select-Object DisplayName, Department, Licenses | Export-Report", + ) + .unwrap(); + let r = run_flow( + &reader(), + &flow, + &OversharingHost, + &FixtureAuthority::standard(), + ) + .unwrap(); + assert_eq!(r.rows[0].fields.len(), 3); + let keys: Vec<_> = r.rows[0].fields.keys().copied().collect(); + assert_eq!( + keys, + [ + ReportField::DisplayName, + ReportField::Department, + ReportField::Licenses + ] + ); + let json = r.to_json(); + let rows = &json[json.find("\"rows\"").unwrap()..json.find("\"trace\"").unwrap()]; + assert!(!rows.contains("Mail") && !rows.contains('@'), "{rows}"); + // Selecting the unpermitted field is refused, not silently dropped. + let flow = compile( + "Get-MgUser john.doe@example.test | Select-Object DisplayName, Mail | Export-Report", + ) + .unwrap(); + assert_eq!( + run_flow( + &reader(), + &flow, + &OversharingHost, + &FixtureAuthority::standard() + ), + Err(RunError::Dialect(FlowError::FieldDenied { + field: ReportField::Mail + })) + ); + // Filtering on it is refused too: a filter would leak it through the row count. + let flow = compile( + "Get-MgUser john.doe@example.test | Where-Object Mail -eq john.doe@example.test | Select-Object DisplayName | Export-Report", + ) + .unwrap(); + assert_eq!( + run_flow( + &reader(), + &flow, + &OversharingHost, + &FixtureAuthority::standard() + ), + Err(RunError::Dialect(FlowError::FieldDenied { + field: ReportField::Mail + })) + ); +} + +#[test] +fn f6_selecting_an_unloaded_field_is_refused() { + assert_eq!( + run("Get-MgUser john.doe@example.test | Select-Object Licenses | Export-Report"), + Err(RunError::Dialect(FlowError::FieldNotLoaded { + field: ReportField::Licenses + })) + ); +} + +// ── F7: determinism ───────────────────────────────────────────────────────── + +#[test] +fn f7_identical_inputs_produce_identical_bytes() { + let a = run(JOHN_DOE_101).unwrap().to_json(); + for _ in 0..8 { + assert_eq!(run(JOHN_DOE_101).unwrap().to_json(), a); + } + let mut changed = fixture::reader_scope(); + changed.policy_version = 2; + let auth = FixtureAuthority::default().grant(READER, TEST_TENANT, changed); + let b = run_flow( + &reader(), + &compile(JOHN_DOE_101).unwrap(), + &FixtureDirectory, + &auth, + ) + .unwrap() + .to_json(); + assert_ne!(a, b, "the policy version is part of the result"); +} + +// ── F8: LOCO parity ───────────────────────────────────────────────────────── + +/// The John Doe report computed directly in Rust, with no LOCO program. +fn direct_john_doe_report() -> Report { + let scope = fixture::reader_scope(); + let host = FixtureDirectory; + let obs = host + .find_user( + scope.tenant, + "john.doe@example.test", + ReportField::USER_FIELDS.intersect(scope.fields), + ) + .unwrap(); + let mut fields = BTreeMap::new(); + for (k, v) in obs.fields { + if matches!(k, ReportField::DisplayName | ReportField::Department) { + fields.insert(k, v); + } + } + let mut lic = host.licenses(scope.tenant, &obs.object_id); + lic.sort(); + fields.insert(ReportField::Licenses, FieldValue::List(lic)); + let flow = compile(JOHN_DOE_101).unwrap(); + Report { + requested: "john.doe@example.test".into(), + tenant: scope.tenant, + policy_version: scope.policy_version, + permitted_fields: ReportField::ALL + .into_iter() + .filter(|f| scope.fields.has(f.position())) + .collect(), + source: host.source(), + rows: vec![ReportRow { fields }], + trace: flow.trace, + } +} + +#[test] +fn f8_the_loco_run_equals_the_direct_typed_reference() { + assert_eq!(run(JOHN_DOE_101).unwrap(), direct_john_doe_report()); +} + +// ── F9: vocabulary conformance ────────────────────────────────────────────── + +#[test] +fn f9_the_flow_vocabulary_passes_loco_conformance() { + assert_eq!(conformance::check(&FlowVocabulary), Ok(())); + let checked = conformance::validate(FlowVocabulary).unwrap(); + let t = checked.table(); + for op in OPERATIONS.iter().filter(|o| o.is_available()) { + let f = op.fn_index.unwrap(); + assert!(t.stack_arity(f).is_some(), "{}", op.name); + assert_eq!(t.body_refs(f), 0); + assert!(t.name(f).is_some()); + } + assert!( + compile(JOHN_DOE_101) + .unwrap() + .program + .references_are_resolvable(&checked) + ); +} + +#[test] +fn f9_every_domain_byte_outside_the_six_is_refused_by_the_table() { + let t = *vocab::checked().table(); + let covered: Vec = (DOMAIN_FLOOR..=255) + .filter(|&b| t.stack_arity(FnIndex(b)).is_some()) + .collect(); + assert_eq!(covered, [0x90, 0x91, 0x92, 0x93, 0x94, 0x95]); +} + +// ── F10: unsupported syntax ───────────────────────────────────────────────── + +#[test] +fn f10_script_blocks_variables_and_separators_are_refused() { + for (src, found) in [ + ( + "Get-MgUser a | Where-Object { $_.Department -eq 'Ops' } | Export-Report", + '{', + ), + ("Get-MgUser $user | Export-Report", '$'), + ("Get-MgUser a; Remove-MgUser a", ';'), + ("Get-MgUser (Get-Content users.txt) | Export-Report", '('), + ("Get-MgUser a | Export-Report > out.json", '>'), + ("Get-MgUser \"$(whoami)\" | Export-Report", '$'), + ("Get-MgUser a `\n | Export-Report", '`'), + ("& Get-MgUser a | Export-Report", '&'), + ("Get-MgUser @params | Export-Report", '@'), + ] { + assert_eq!( + compile(src), + Err(CompileError::UnsupportedSyntax { found }), + "{src}" + ); + } +} + +#[test] +fn f10_unsupported_stages_and_shapes_are_refused() { + assert_eq!(compile("Get-MgUser a"), Err(CompileError::NoExport)); + assert!(matches!( + compile("Get-IamLicenseEvidence | Export-Report"), + Err(CompileError::ShapeMismatch { .. }) + )); + assert!(matches!( + compile("Get-MgUser a | Get-MgUser b | Export-Report"), + Err(CompileError::ShapeMismatch { .. }) + )); + assert!(matches!( + compile("Get-MgUser a | Export-Report | Select-Object DisplayName"), + Err(CompileError::ShapeMismatch { .. }) + )); + assert!(matches!( + compile("Get-MgUser a | Where-Object Licenses -eq x | Export-Report"), + Err(CompileError::ComparisonMismatch { .. }) + )); + assert!(matches!( + compile("Get-MgUser a | Where-Object Department -like x* | Export-Report"), + Err(CompileError::UnknownParameter { .. }) + )); + assert!(matches!( + compile("Get-MgUser a | Select-Object Salary | Export-Report"), + Err(CompileError::UnknownField(_)) + )); + assert!(matches!( + compile("Get-MgUser a | Export-Report -Format CSV"), + Err(CompileError::UnknownFormat(_)) + )); + assert_eq!( + compile("Get-MgUser 'a | Export-Report"), + Err(CompileError::UnterminatedString) + ); + assert_eq!( + compile("Get-MgUser a || Export-Report"), + Err(CompileError::EmptyStage) + ); +} + +#[test] +fn f6_an_oversharing_host_does_not_materialize_the_field_in_the_run() { + use ogar_loco::Dialect; + let flow = compile(JOHN_DOE_101).unwrap(); + let scope = fixture::reader_scope(); + let mut dialect = crate::FlowDialect::new(&OversharingHost, &scope, &flow); + let mut stack = Vec::new(); + dialect + .call(vocab::GET_USER, [0, 0, 0], &mut stack) + .unwrap(); + let loaded = stack[0].loaded_fields(); + assert!(!loaded.has(ReportField::Mail.position()), "{loaded:?}"); + assert!(loaded.has(ReportField::DisplayName.position())); +} + +#[test] +fn f5_the_host_is_only_ever_asked_about_the_scoped_tenant() { + let host = CountingHost::default(); + let flow = compile( + "Get-MgUser john.doe@other.test | Get-IamLicenseEvidence | Get-IamMembership | Select-Object DisplayName | Export-Report", + ) + .unwrap(); + run_flow(&reader(), &flow, &host, &FixtureAuthority::standard()).unwrap(); + let q = host.queries.borrow(); + assert!(!q.is_empty()); + for line in q.iter() { + assert!(line.contains(&format!(" {TEST_TENANT} ")), "{line}"); + } +} + +#[test] +fn a_run_that_leaves_values_on_the_stack_returns_no_report() { + let mut flow = compile(JOHN_DOE_101).unwrap(); + flow.program = Program { + functions: vec![ + FunctionBody::from_calls( + FLOW_SHAPE, + &[ + Call::with_value(vocab::GET_USER, 0), + Call::with_value(vocab::EXPORT, 1), + Call::with_value(vocab::GET_USER, 0), + ], + ) + .unwrap(), + ], + }; + assert_eq!( + run_flow( + &reader(), + &flow, + &FixtureDirectory, + &FixtureAuthority::standard() + ), + Err(RunError::Dialect(FlowError::NoReport)) + ); +} diff --git a/crates/ogar-flow-m365/src/vocab.rs b/crates/ogar-flow-m365/src/vocab.rs new file mode 100644 index 0000000..df65b7a --- /dev/null +++ b/crates/ogar-flow-m365/src/vocab.rs @@ -0,0 +1,271 @@ +//! The flow vocabulary: six domain call bytes, and the codebooks their value +//! bytes resolve against. +//! +//! Every flow body uses [`LaneShape::Quads`] (`function : value ×3`, 90 calls +//! per node), because `Where-Object` needs three immediates +//! (`field : comparison : argument`) and `Select-Object` packs a 24-bit field +//! mask into its three value bytes. +//! +//! | byte | name | pops | pushes | value bytes | +//! |---|---|---|---|---| +//! | `0x90` | `get_user` | 0 | population | `arg` (index into the flow's argument table) | +//! | `0x91` | `get_license_evidence` | 1 | population | — | +//! | `0x92` | `get_membership` | 1 | population | — | +//! | `0x93` | `where` | 1 | population | `field : comparison : arg` | +//! | `0x94` | `select` | 1 | population | 24-bit field mask, little-endian | +//! | `0x95` | `export` | 1 | — | `format` | +//! +//! The comparison byte is a shared-core function index ([`FnIndex::EQ`], +//! [`FnIndex::NEQ`], [`FnIndex::LIST_CONTAINS`]) used as an operator name, so +//! filtering reuses the codebook `ogar-loco` already defines instead of a +//! second operator table. + +use lance_graph_contract::class_view::FieldMask; +use ogar_loco::{FnIndex, LaneShape, ValueCodebook, Vocabulary}; + +/// Every flow body's lane carving. +pub const FLOW_SHAPE: LaneShape = LaneShape::Quads; + +/// `get_user`: look up one directory user. +pub const GET_USER: FnIndex = FnIndex(0x90); +/// `get_license_evidence`: attach observed license facts. +pub const GET_LICENSE_EVIDENCE: FnIndex = FnIndex(0x91); +/// `get_membership`: attach observed group memberships. +pub const GET_MEMBERSHIP: FnIndex = FnIndex(0x92); +/// `where`: keep the rows matching one comparison. +pub const WHERE: FnIndex = FnIndex(0x93); +/// `select`: project the rows onto a field mask. +pub const SELECT: FnIndex = FnIndex(0x94); +/// `export`: render the population as a report. +pub const EXPORT: FnIndex = FnIndex(0x95); + +const DOMAIN: [(FnIndex, &str, u8, bool, Option); 6] = [ + (GET_USER, "get_user", 0, true, Some(ARG_CODEBOOK)), + (GET_LICENSE_EVIDENCE, "get_license_evidence", 1, true, None), + (GET_MEMBERSHIP, "get_membership", 1, true, None), + (WHERE, "where", 1, true, Some(PREDICATE_CODEBOOK)), + (SELECT, "select", 1, true, Some(FIELD_MASK_CODEBOOK)), + (EXPORT, "export", 1, false, Some(FORMAT_CODEBOOK)), +]; + +/// `get_user`'s value byte indexes the compiled flow's argument table. +pub const ARG_CODEBOOK: ValueCodebook = ValueCodebook { + id: 0, + name: "flow_args", +}; +/// `where`'s value bytes are `field : comparison : arg`. +pub const PREDICATE_CODEBOOK: ValueCodebook = ValueCodebook { + id: 1, + name: "field_comparison_arg", +}; +/// `select`'s value bytes are a 24-bit [`ReportField`] mask. +pub const FIELD_MASK_CODEBOOK: ValueCodebook = ValueCodebook { + id: 2, + name: "report_field_mask", +}; +/// `export`'s value byte is an [`ExportFormat`]. +pub const FORMAT_CODEBOOK: ValueCodebook = ValueCodebook { + id: 3, + name: "export_format", +}; + +/// The flow vocabulary. Validate it with +/// [`ogar_loco::vocabulary::conformance::validate`] (or call [`checked`]). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FlowVocabulary; + +fn domain(f: FnIndex) -> Option<&'static (FnIndex, &'static str, u8, bool, Option)> { + DOMAIN.iter().find(|d| d.0 == f) +} + +impl Vocabulary for FlowVocabulary { + fn domain_stack_arity(&self, f: FnIndex) -> Option { + domain(f).map(|d| d.2) + } + fn domain_body_refs(&self, _f: FnIndex) -> u8 { + 0 + } + fn domain_pushes_result(&self, f: FnIndex) -> Option { + domain(f).map(|d| d.3) + } + fn domain_name(&self, f: FnIndex) -> Option<&'static str> { + domain(f).map(|d| d.1) + } + fn domain_value_codebook(&self, f: FnIndex) -> Option { + domain(f).and_then(|d| d.4) + } +} + +/// The validated vocabulary the interpreter runs under. +/// +/// # Panics +/// +/// Never in practice: conformance is pinned by this crate's tests. +#[must_use] +pub fn checked() -> ogar_loco::CheckedVocabulary { + ogar_loco::vocabulary::conformance::validate(FlowVocabulary) + .expect("FlowVocabulary conforms; pinned by this crate's tests") +} + +/// A field a report row can carry. The discriminant is its position in a +/// [`FieldMask`]; positions stay below 24 so a mask fits `select`'s three +/// value bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum ReportField { + /// Entra object id. + ObjectId = 0, + /// `userPrincipalName`. + UserPrincipalName = 1, + /// `displayName`. + DisplayName = 2, + /// `department`. + Department = 3, + /// `mail`. + Mail = 4, + /// `accountEnabled`. + AccountEnabled = 5, + /// Observed license SKUs (attached by `get_license_evidence`). + Licenses = 6, + /// Observed group memberships (attached by `get_membership`). + Memberships = 7, + /// Whether a primary mailbox was observed. + MailboxAvailable = 8, +} + +impl ReportField { + /// Every field, in mask-position order. + pub const ALL: [ReportField; 9] = [ + Self::ObjectId, + Self::UserPrincipalName, + Self::DisplayName, + Self::Department, + Self::Mail, + Self::AccountEnabled, + Self::Licenses, + Self::Memberships, + Self::MailboxAvailable, + ]; + + /// The fields `get_user` can read (everything but the attached facts). + pub const USER_FIELDS: FieldMask = FieldMask::from_positions(&[0, 1, 2, 3, 4, 5, 8]); + + /// The PowerShell-style property name. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::ObjectId => "ObjectId", + Self::UserPrincipalName => "UserPrincipalName", + Self::DisplayName => "DisplayName", + Self::Department => "Department", + Self::Mail => "Mail", + Self::AccountEnabled => "AccountEnabled", + Self::Licenses => "Licenses", + Self::Memberships => "Memberships", + Self::MailboxAvailable => "MailboxAvailable", + } + } + + /// Whether the field holds a list (and so admits `-contains`). + #[must_use] + pub const fn is_list(self) -> bool { + matches!(self, Self::Licenses | Self::Memberships) + } + + /// The field at mask position `p`. + #[must_use] + pub const fn from_position(p: u8) -> Option { + if (p as usize) < Self::ALL.len() { + Some(Self::ALL[p as usize]) + } else { + None + } + } + + /// Resolve a property name, case-insensitively. + #[must_use] + pub fn from_name(s: &str) -> Option { + Self::ALL + .into_iter() + .find(|f| f.name().eq_ignore_ascii_case(s)) + } + + /// This field's mask position. + #[must_use] + pub const fn position(self) -> u8 { + self as u8 + } +} + +/// Pack a field mask into `select`'s three value bytes. +/// +/// Returns `None` when the mask uses a position at or above 24. +#[must_use] +pub const fn pack_mask(mask: FieldMask) -> Option<[u8; 3]> { + if mask.0 >> 24 != 0 { + return None; + } + Some([mask.0 as u8, (mask.0 >> 8) as u8, (mask.0 >> 16) as u8]) +} + +/// Unpack `select`'s three value bytes into a field mask. +#[must_use] +pub const fn unpack_mask(v: [u8; 3]) -> FieldMask { + FieldMask(v[0] as u64 | (v[1] as u64) << 8 | (v[2] as u64) << 16) +} + +/// A report output format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum ExportFormat { + /// Deterministic JSON (`ogar-flow-m365/report.v1`). + Json = 1, +} + +impl ExportFormat { + /// Decode a format byte. + #[must_use] + pub const fn from_byte(b: u8) -> Option { + match b { + 1 => Some(Self::Json), + _ => None, + } + } +} + +/// A `where` comparison, named by its shared-core function index. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Comparison { + /// `-eq` → [`FnIndex::EQ`]. + Eq, + /// `-ne` → [`FnIndex::NEQ`]. + Ne, + /// `-contains` on a list field → [`FnIndex::LIST_CONTAINS`]. + Contains, +} + +impl Comparison { + /// The shared-core byte that names this comparison. + #[must_use] + pub const fn byte(self) -> u8 { + match self { + Self::Eq => FnIndex::EQ.0, + Self::Ne => FnIndex::NEQ.0, + Self::Contains => FnIndex::LIST_CONTAINS.0, + } + } + + /// Decode a comparison byte. + #[must_use] + pub const fn from_byte(b: u8) -> Option { + if b == FnIndex::EQ.0 { + Some(Self::Eq) + } else if b == FnIndex::NEQ.0 { + Some(Self::Ne) + } else if b == FnIndex::LIST_CONTAINS.0 { + Some(Self::Contains) + } else { + None + } + } +}