diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index 204596b..a8e3b66 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -3,6 +3,17 @@ > Open blockers and cross-repo follow-ups. Append new entries at the top; > change only the `Status` line of an existing entry. +## ISS-PRESET-VERBS-PROVISIONAL — the preset roles' verbs are written from seat names, not from HubSpot + +- **Status:** OPEN (D-IAM-1, 2026-10-21; operator to confirm or amend) +- **Where:** `hubspo-iam::roles` (the verb table in the module docs), pinned by `each_seat_edits_exactly_its_own_concepts` and `only_the_admins_delete`. +- **What:** every preset reads every plugged concept. Edit (write + create) and act: sales on contact, company, deal, lead, call, meeting, task, note, email, line item, quote/invoice (list edit only, sequence act only); service on contact, company, ticket, call, meeting, task, note, email (list edit); marketing on contact, company, campaign, email (list, note, task edit). Only the two admins delete; admin cannot delete users and teams (`PROJECT_ACTOR`). No HubSpot export backs this; the second oracle (rule 12) would. + +## ISS-PERMISSION-SETS-NOT-ROLES — tenant permission sets cannot be `RoleId`s + +- **Status:** OPEN (D-IAM-1, 2026-10-21; design needed) +- **Where:** `lance_graph_contract::rbac::RoleId` is `&'static str`; `RbacPlug::roles` is a static list. +- **What:** a HubSpot permission set is created by the tenant at runtime, so it cannot be a plugged role without leaking strings. Candidate shape: a permission set is tenant data that narrows one preset (per-concept verbs ⊆ the preset's, field masks per D-IAM-3), applied after the preset's decision; it never widens. Needs the request path (D-IAM-3) to apply it. ## ISS-SPEAR-DOCUMENT-CONTRACTS — spear has a content store but no document record, reference or authorization - **Status:** OPEN (found 2026-10-21 verifying D-DOC-1; upstream asks for the operator, not filed on spear) @@ -30,7 +41,7 @@ ## ISS-PACKED-RUN-RBAC-GRAIN — a packed run is granted as a whole, and contact's holds personal attributes -- **Status:** DECIDED 2026-10-21 (operator) — option (b), with (c) allowed before the first tenant data. Storage grain may be a run; authorization grain is the property. Field 0 grants the container only; packed properties are checked one by one against an inner mask keyed by stable table ordinal, independent of the 256-position `WideFieldMask`. Requirements recorded on the D-IAM-3 row of STATUS_BOARD. +- **Status:** DECIDED 2026-10-21 (operator) — option (b); (c) is defense in depth only, never a substitute. Contract: `hubspo_iam::fields` (D-IAM-3). Storage grain may be a run; authorization grain is the property. Field 0 grants the container only; packed properties are checked one by one against an inner mask keyed by stable table ordinal, independent of the 256-position `WideFieldMask`. Requirements recorded on the D-IAM-3 row of STATUS_BOARD. - **Where:** `hubspo-harvest::positions::PACKED_GROUPS`, `hubspo-schema::HubSpoClassView` (field 0). - **What:** a role granted field 0 of contact sees all 27 packed properties; one not granted it sees none. HubSpot's `facebook_ads_properties` group holds `date_of_birth`, `gender`, `marital_status` and `military_status` next to `school` and `seniority`, so a role cannot get the job fields without the personal ones. HubSpot itself marks all of them `non_sensitive`; DSGVO may not. - **Options for D-IAM-3:** (a) accept the grain and grant the run only to roles that may see all of it; (b) a second, run-internal mask keyed by table position (the run already stores positions); (c) keep the personal attributes out of the run by narrowing `PACKED_GROUPS` to names, with another group packed instead. Only (c) changes stored slots, and only before the first tenant data is written. diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index cea1e4a..5d8443c 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -216,6 +216,56 @@ `remove` keeping rows (1 red). A `live` gate inside `add` stayed green and was removed: `members` already applies it on every read. +## Preset roles (D-IAM-1, presets half) + +- `hubspo_port::RBAC_ROLES` names six roles: `crm_super_admin`, + `crm_admin`, `crm_sales_user`, `crm_service_user`, `crm_marketing_user`, + `crm_view_only`. The plug declares them; `hubspo-port` no longer holds a + grant source. +- `hubspo_iam::roles::Catalogue` is the `GrantSource`: one const grant per + plugged concept (22) per role, built by a `const fn` from the verb table in + the module docs. `field_mask_of` is the default (`None`); column grants are + D-IAM-3. `roles_of` is empty: actors come from the request's identity, + resolved once per request. +- `hubspo_iam::roles::bind()` binds `RBAC_PLUG` against OGAR green. +- 12 tests: bind, defined roles, plug boundary, every role reads every + concept, view-only reads only, only admins delete, admin vs super admin on + users, each seat's editable set (`==`), create travels with write, actor + decisions through `ActorSource`, sequence enrol vs edit. +- Five disable runs measured red: view-only given edit, sales given + delete, admin given user delete, an unknown role defined, service losing + tickets. +- Request roles from the directory: `hubspo_iam::roles::request_actors` + wraps dir-sim's `ViewActors` (feature `rbac`): roles are granted to + security groups in one version of OGAR's group-role policy + (`GroupRoles::from_policy`), inherited through SID chains only, filtered + by the binding; an unread group refuses the request. 3 integration tests + over a dir-sim view (`tests/roles.rs`). +- Open: `ISS-PRESET-VERBS-PROVISIONAL`, `ISS-PERMISSION-SETS-NOT-ROLES`. + +## Per-property read authorization (D-IAM-3, contract half) + +- `hubspo_iam::fields`: a read needs the outer mask (the role's + `WideFieldMask` over field positions: the property's lane, or 0 for the + packed run) and the inner grant (`PropertyGrants`, a set of stable + property ordinals per role and concept). The inner set names no lane, so + re-slotting a property changes no decision. +- `WideFieldMask` cannot carry the inner set: `u8` positions cannot name + contact's 272 ordinals, and it has no schema version. Hence a local type, + no new upstream abstraction. +- `Projection::compile` (once per request, per concept) records a `Stamp`: + concept, schema version, inner-grant generation, outer `PolicyVersion`. + Every read presents the current stamp: `project` drops hidden and unknown + properties from a row; `require` refuses a filter, sort or aggregate that + reads one; any stamp difference (another concept, schema version, a + revoked grant) refuses everything (Codex on #42). +- 12 tests. Eight disable runs measured red: concept check off, grant + generation ignored, inner grant ignored, outer mask + ignored, stale version accepted, `require` passing hidden properties, + one role's outer mask combined with another's inner grant, an unread + directory group accepted. +- Not yet wired into any output path, and no default grants per preset. + ## Harvest (D-ONT-1, public-catalog half) - `scripts/fetch-hubspot-specs.sh` → `.cache/` (gitignored), then diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 1213544..04234cf 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -71,9 +71,9 @@ | D-id | Deliverable | Size | Status | Evidence | |---|---|---|---|---| -| D-IAM-1 | Role catalogue: Super Admin, Admin, Sales/Service/Marketing user, view-only, custom permission sets; grant source with `field_mask_of` | M | Queued | 2026-10-20 inputs from lance-graph: (1) #1635/#1638 (D-RMAP-DIR-1, lab only) measured a dir-sim `View` walk as a full membership scan per call, 0.4–0.7 ms at 35k–263k rows; resolve an actor's roles once per request, never per record. The compiled closure (32–49 ns) is not admitted (ADMISSION open, D-RMAP-DIR-2 not authorized), so do not depend on `dir-rmap-probe`. (2) #1636 ACTION-GATE: an `ActionDef` with `required_role = None` skips RBAC in both `commit` and `commit_via`; the upstream recommendation is `commit_via` canonical and `None` only for actions declared public. Give every CRM action a role. | +| D-IAM-1 | Role catalogue: Super Admin, Admin, Sales/Service/Marketing user, view-only, custom permission sets; grant source with `field_mask_of` | M | In progress | 2026-10-21 (authorized by the operator): the six presets ship. Role names are in `hubspo_port::RBAC_ROLES`; the grants are `hubspo_iam::roles::Catalogue` (one grant per plugged concept, provisional verb table); `hubspo_iam::roles::bind` binds the plug green. A request's roles come from the directory: `hubspo_iam::roles::request_actors` reads security-group grants (OGAR group-role policy via dir-sim `GroupRoles`/`ViewActors`) once per request and refuses on an unread group. 12 unit + 3 directory tests. Open: tenant permission sets (`ISS-PERMISSION-SETS-NOT-ROLES`); the preset verbs await operator confirmation (`ISS-PRESET-VERBS-PROVISIONAL`). 2026-10-20 inputs from lance-graph: (1) #1635/#1638 (D-RMAP-DIR-1, lab only) measured a dir-sim `View` walk as a full membership scan per call, 0.4–0.7 ms at 35k–263k rows; resolve an actor's roles once per request, never per record. The compiled closure (32–49 ns) is not admitted (ADMISSION open, D-RMAP-DIR-2 not authorized), so do not depend on `dir-rmap-probe`. (2) #1636 ACTION-GATE: an `ActionDef` with `required_role = None` skips RBAC in both `commit` and `commit_via`; the upstream recommendation is `commit_via` canonical and `None` only for actions declared public. Give every CRM action a role. | | D-IAM-2 | Ownership scopes (all / team / owned) as `ScopeSpec`; team hierarchy via `Dn128` subtree | M | Shipped | #30 + #31 (#31 fixes the nested-group P1 via lance-graph #1488 `members_transitive_within`; 8 tests). `hubspo-iam::scope` 2026-10-11: owner set from the dir-sim `View` (team = directly held team group + nested sub-teams), one Quack `IN` over the owner lane, stored as a mask; 6 tests, 2 disable runs red. P-AUTH-1 2026-10-10: scope as stored mask in the fold kernel ≤ 4.3 %; per-row owner check +43–49 %. Unblocked 2026-10-11: roles from directory groups via lance-graph-dir-sim `actors::ViewActors` + `GroupRoles::from_policy` (consumable by git) | -| D-IAM-3 | Field-level permissions (sensitive properties) as per-role `WideFieldMask`; absent from the wire | M | Queued | 2026-10-21 operator ruling (`ISS-PACKED-RUN-RBAC-GRAIN`, option b): storage grain may be a packed run, authorization grain is the property. Field 0 grants the container, never its contents; packed properties are authorized one by one by stable table ordinal, in a mask that is not the 256-position `WideFieldMask`. Requirements: compile authorized fields once per request; check each packed property before serialization, render, export and workflow use; refuse filters, sorts and aggregations on hidden properties; never hand a raw packed run to an untrusted caller (debug, cache, direct API included); fail closed on missing, unknown or stale inner policy, invalidated on grant or schema change; negative test `school` vs `date_of_birth`. Option (c), high-risk properties in their own lanes, allowed before first tenant data. 2026-10-20: also needs D-IAM-1 (roles) and D-ONT-6 (field positions per concept) in HubSPO-rs, which the upstream note below missed. 2026-10-20 unblocked upstream: lance-graph #1556 `RbacBinding::with_class_field_defaults` + `field_mask_for` (role mask, else class default, else None), OGAR #358 `GrantSource::default_field_mask_of` feeding it in `OgarRbac::bind`, lance-graph #1558 `walk_rails_authorized` (hidden field never emitted, hidden rail not followed; use `visible_fields()`). Read lance-graph `.claude/knowledge/access-control-model.md` first: policy data keyed by classid, no reasoning | +| D-IAM-3 | Field-level permissions (sensitive properties) as per-role `WideFieldMask`; absent from the wire | M | In progress | 2026-10-21 contract shipped (not yet wired to any output path): `hubspo_iam::fields` — outer `WideFieldMask` (lane, or position 0 for the run) ∧ inner `PropertyGrants` by stable ordinal; `Projection::compile` once per request, `project` drops hidden/unknown values, `require` refuses filters/sorts/aggregates on them, a projection compiled at another schema version refuses everything. 10 tests incl. `school` vs `date_of_birth`/`gender`/`marital_status`/`military_status`. Open: per-role default grants, wiring into REST/UI/export/events/workflow outputs. 2026-10-21 operator ruling (`ISS-PACKED-RUN-RBAC-GRAIN`, option b): storage grain may be a packed run, authorization grain is the property. Field 0 grants the container, never its contents; packed properties are authorized one by one by stable table ordinal, in a mask that is not the 256-position `WideFieldMask`. Requirements: compile authorized fields once per request; check each packed property before serialization, render, export and workflow use; refuse filters, sorts and aggregations on hidden properties; never hand a raw packed run to an untrusted caller (debug, cache, direct API included); fail closed on missing, unknown or stale inner policy, invalidated on grant or schema change; negative test `school` vs `date_of_birth`. Option (c), high-risk properties in their own lanes, allowed before first tenant data. 2026-10-20: also needs D-IAM-1 (roles) and D-ONT-6 (field positions per concept) in HubSPO-rs, which the upstream note below missed. 2026-10-20 unblocked upstream: lance-graph #1556 `RbacBinding::with_class_field_defaults` + `field_mask_for` (role mask, else class default, else None), OGAR #358 `GrantSource::default_field_mask_of` feeding it in `OgarRbac::bind`, lance-graph #1558 `walk_rails_authorized` (hidden field never emitted, hidden rail not followed; use `visible_fields()`). Read lance-graph `.claude/knowledge/access-control-model.md` first: policy data keyed by classid, no reasoning | | D-IAM-4 | Directory ingest (ogar-ad / ogar-az / native), seat assignment as directory simulation plans | L | Queued | | | D-IAM-5 | Partitioning (business units / brands) as tenant sub-scopes | M | Queued | | | D-IAM-6 | Accumulation guard: exports / cohort reports require an extra grant | S | Queued | | diff --git a/crates/hubspo-iam/Cargo.toml b/crates/hubspo-iam/Cargo.toml index 390153e..691b6e3 100644 --- a/crates/hubspo-iam/Cargo.toml +++ b/crates/hubspo-iam/Cargo.toml @@ -9,11 +9,15 @@ repository.workspace = true publish = false [dependencies] -lance-graph-dir-sim.workspace = true +hubspo-generated = { path = "../hubspo-generated" } +hubspo-port = { path = "../hubspo-port" } +lance-graph-contract.workspace = true +ogar-rbac.workspace = true +ogar-vocab.workspace = true +lance-graph-dir-sim = { workspace = true, features = ["rbac"] } lance-graph-quack.workspace = true ogar-dir-core.workspace = true - [dev-dependencies] lance-graph-mask-risc.workspace = true diff --git a/crates/hubspo-iam/src/fields.rs b/crates/hubspo-iam/src/fields.rs new file mode 100644 index 0000000..62463af --- /dev/null +++ b/crates/hubspo-iam/src/fields.rs @@ -0,0 +1,703 @@ +//! D-IAM-3 contract: per-property read authorization, independent of where +//! a property is stored. +//! +//! Operator ruling 2026-10-21 (`ISS-PACKED-RUN-RBAC-GRAIN`, option b): +//! storage grain may be a packed run, authorization grain is the property. +//! Two masks decide a read, and both must admit it: +//! +//! - **Outer** — the role's `WideFieldMask` over ClassView field positions +//! (lanes `1..=255`, the packed run at position 0). Physical: it grants a +//! column, and for position 0 only the container. +//! - **Inner** — the role's [`PropertyGrants`]: a set of stable property +//! ordinals (the table position, `StandardProperty::position`). Logical: +//! it never mentions a lane or the run, so moving a property between a +//! lane and the run changes no grant. +//! +//! ## Why not a `WideFieldMask` for the inner mask +//! +//! The existing contracts were checked first. `ClassRbac::field_mask` and +//! `GrantSource::field_mask_of` return a `WideFieldMask`, whose positions +//! are `u8` field positions: it cannot name contact's 272 property ordinals, +//! carries no schema version, and is keyed to the physical field list this +//! policy must not depend on. So the inner set is a local type here, not a +//! new upstream abstraction. +//! +//! ## Use, once per request +//! +//! 1. Resolve the actor's roles ([`crate::roles::request_actors`]) and +//! apply tenant and ownership scope to the rows (D-IAM-2) first. +//! 2. [`Projection::compile`] once per concept: the union over the roles of +//! outer ∧ inner, against one [`Schema`] version. +//! 3. Every output path (REST, UI, export, events, workflow payloads) +//! passes values through [`Projection::project`]; every filter, sort and +//! aggregate passes the properties it reads through +//! [`Projection::require`]. A raw packed run never leaves: only +//! `(ordinal, value)` pairs that survived the projection do. +//! +//! ## Fails closed +//! +//! A role with no inner grant on the concept admits nothing; an ordinal the +//! schema does not know is refused; a projection compiled against another +//! schema version refuses everything, so a property added later is never +//! exposed by a mask compiled before it existed. The same holds for the +//! concept (ordinal 8 of a deal is not ordinal 8 of a contact) and for the +//! grants: a projection carries a [`Stamp`] and every read presents the +//! current one, so a revoked grant stops authorizing at once. + +use hubspo_generated::standard_properties::{PACKED_RUN_POSITION, Slot, StandardProperty}; +use lance_graph_contract::class_view::WideFieldMask; +use lance_graph_contract::rbac::RoleId; + +/// A tenant's property schema version for one concept. Bumped whenever a +/// property is added; a projection is only valid for the version it was +/// compiled against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SchemaVersion(pub u32); + +/// One version of a concept's property table: ordinal `i` is `properties[i]`. +#[derive(Debug, Clone, Copy)] +pub struct Schema { + /// The concept the table belongs to. + pub concept: u16, + /// The version this table is. + pub version: SchemaVersion, + /// The properties, by ordinal. + pub properties: &'static [StandardProperty], +} + +impl Schema { + /// The ordinal of `name`, if the schema has it. + #[must_use] + pub fn ordinal(&self, name: &str) -> Option { + self.properties + .iter() + .find(|p| p.name == name) + .map(|p| p.position) + } + + fn property(&self, ordinal: u16) -> Option<&StandardProperty> { + self.properties + .get(usize::from(ordinal)) + .filter(|p| p.position == ordinal) + } +} + +/// A set of property ordinals. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OrdinalSet { + words: Vec, +} + +impl OrdinalSet { + /// The empty set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Add `ordinal`. + pub fn insert(&mut self, ordinal: u16) { + let (w, b) = (usize::from(ordinal) / 64, ordinal % 64); + if self.words.len() <= w { + self.words.resize(w + 1, 0); + } + self.words[w] |= 1 << b; + } + + /// Whether `ordinal` is in the set. + #[must_use] + pub fn contains(&self, ordinal: u16) -> bool { + let (w, b) = (usize::from(ordinal) / 64, ordinal % 64); + self.words.get(w).is_some_and(|word| word & (1 << b) != 0) + } + + /// Add every member of `other`. + pub fn union_with(&mut self, other: &OrdinalSet) { + if self.words.len() < other.words.len() { + self.words.resize(other.words.len(), 0); + } + for (a, b) in self.words.iter_mut().zip(&other.words) { + *a |= b; + } + } + + /// The members, ascending. + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.words.len() * 64) + .filter_map(|i| u16::try_from(i).ok()) + .filter(|&o| self.contains(o)) + } +} + +/// The inner grants: which property ordinals each role may read on each +/// concept. A `(role, concept)` with no entry grants nothing. +#[derive(Debug, Clone, Default)] +pub struct PropertyGrants { + entries: Vec<(RoleId, u16, OrdinalSet)>, + generation: u64, +} + +/// Why a grant could not be recorded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GrantError { + /// The schema has no property of this name. + UnknownProperty(String), +} + +impl PropertyGrants { + /// No grants. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Grant `role` read on the named properties of `schema`'s concept. + /// + /// # Errors + /// + /// [`GrantError::UnknownProperty`] for a name the schema does not have; + /// nothing is recorded then. + pub fn grant( + &mut self, + role: RoleId, + schema: &Schema, + names: &[&str], + ) -> Result<(), GrantError> { + let mut set = OrdinalSet::new(); + for name in names { + let ordinal = schema + .ordinal(name) + .ok_or_else(|| GrantError::UnknownProperty((*name).to_string()))?; + set.insert(ordinal); + } + self.entry(role, schema.concept).union_with(&set); + self.generation += 1; + Ok(()) + } + + /// Grant `role` read on every property `schema` has at its version. + /// A property added in a later version is not included. + pub fn grant_all(&mut self, role: RoleId, schema: &Schema) { + let mut set = OrdinalSet::new(); + for p in schema.properties { + set.insert(p.position); + } + self.entry(role, schema.concept).union_with(&set); + self.generation += 1; + } + + /// Withdraw every grant `role` holds on `concept`. + pub fn revoke(&mut self, role: RoleId, concept: u16) { + self.entries + .retain(|(r, c, _)| !(*r == role && *c == concept)); + self.generation += 1; + } + + /// Bumped by every change to the grants, so a projection compiled + /// before a change is refused after it. + #[must_use] + pub fn generation(&self) -> u64 { + self.generation + } + + fn entry(&mut self, role: RoleId, concept: u16) -> &mut OrdinalSet { + let i = match self + .entries + .iter() + .position(|(r, c, _)| *r == role && *c == concept) + { + Some(i) => i, + None => { + self.entries.push((role, concept, OrdinalSet::new())); + self.entries.len() - 1 + } + }; + &mut self.entries[i].2 + } + + /// The ordinals `role` may read on `concept`, if any grant exists. + #[must_use] + pub fn of(&self, role: RoleId, concept: u16) -> Option<&OrdinalSet> { + self.entries + .iter() + .find(|(r, c, _)| *r == role && *c == concept) + .map(|(_, _, s)| s) + } +} + +/// The version of the outer policy: the role assignments and field masks a +/// projection was compiled from. Its owner bumps it on every grant change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct PolicyVersion(pub u64); + +/// What a projection is valid for: one concept, one schema version, one +/// generation of the inner grants and one outer policy version. A read +/// presents the current stamp; any difference refuses it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Stamp { + /// The concept whose ordinals the projection names. + pub concept: u16, + /// The concept's schema version. + pub schema: SchemaVersion, + /// [`PropertyGrants::generation`]. + pub inner: u64, + /// The outer policy version. + pub outer: PolicyVersion, +} + +impl Stamp { + /// The current stamp for `schema` under `inner` and `outer`. + #[must_use] + pub fn of(schema: &Schema, inner: &PropertyGrants, outer: PolicyVersion) -> Self { + Self { + concept: schema.concept, + schema: schema.version, + inner: inner.generation(), + outer, + } + } +} + +/// Why a read was refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// The projection was compiled for another concept, schema version or + /// grant generation than the read presents. + Stale { + /// What it was compiled for. + compiled: Stamp, + /// What the read presents. + current: Stamp, + }, + /// The schema has no property at this ordinal. + Unknown(u16), + /// The property exists but the request may not read it. + Hidden(u16), +} + +/// One request's readable properties on one concept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Projection { + stamp: Stamp, + known: u16, + readable: OrdinalSet, +} + +impl Projection { + /// Compile the projection for `roles` on `schema`'s concept: a property + /// is readable when some role's outer mask admits its slot (its lane, or + /// position 0 for the packed run) and the same role's inner grant + /// admits its ordinal. + /// + /// `outer` gives a role's outer mask on the concept; `None` admits no + /// field. `policy` is the outer policy version those masks come from. + /// Call once per request. + #[must_use] + pub fn compile( + schema: &Schema, + roles: &[RoleId], + outer: impl Fn(RoleId) -> Option, + policy: PolicyVersion, + inner: &PropertyGrants, + ) -> Self { + let mut readable = OrdinalSet::new(); + for &role in roles { + let (Some(mask), Some(granted)) = (outer(role), inner.of(role, schema.concept)) else { + continue; + }; + for p in schema.properties { + let position = match p.slot { + Slot::Lane(n) => n, + Slot::Packed => PACKED_RUN_POSITION, + }; + if mask.has(position) && granted.contains(p.position) { + readable.insert(p.position); + } + } + } + Self { + stamp: Stamp::of(schema, inner, policy), + known: u16::try_from(schema.properties.len()).unwrap_or(u16::MAX), + readable, + } + } + + /// The concept the projection is for. + #[must_use] + pub fn concept(&self) -> u16 { + self.stamp.concept + } + + /// What the projection is valid for. + #[must_use] + pub fn stamp(&self) -> Stamp { + self.stamp + } + + fn current(&self, current: &Stamp) -> Result<(), Refusal> { + if *current == self.stamp { + Ok(()) + } else { + Err(Refusal::Stale { + compiled: self.stamp, + current: *current, + }) + } + } + + fn decide(&self, ordinal: u16) -> Result<(), Refusal> { + if ordinal >= self.known { + Err(Refusal::Unknown(ordinal)) + } else if self.readable.contains(ordinal) { + Ok(()) + } else { + Err(Refusal::Hidden(ordinal)) + } + } + + /// The values of a row the request may see: every `(ordinal, value)` + /// pair whose property is readable, in input order. Hidden and unknown + /// properties are dropped, so they are absent from the output rather + /// than masked in it. + /// + /// # Errors + /// + /// [`Refusal::Stale`] when `current` is not the compiled stamp (another + /// concept, schema version or grant generation); then nothing is + /// returned. + pub fn project( + &self, + current: &Stamp, + values: impl IntoIterator, + ) -> Result, Refusal> { + self.current(current)?; + Ok(values + .into_iter() + .filter(|(o, _)| self.decide(*o).is_ok()) + .collect()) + } + + /// Check the properties a filter, sort or aggregate reads. A predicate + /// over a hidden property would reveal it through which rows match, so + /// it is refused, not evaluated. + /// + /// # Errors + /// + /// [`Refusal::Stale`], or the first [`Refusal::Unknown`] / + /// [`Refusal::Hidden`] property in `reads`. + pub fn require(&self, current: &Stamp, reads: &[u16]) -> Result<(), Refusal> { + self.current(current)?; + reads.iter().try_for_each(|&o| self.decide(o)) + } + + /// Whether the property named `name` in `schema` is readable under the + /// `current` stamp; `false` when the stamp or `schema` is not the one + /// the projection was compiled for. + #[must_use] + pub fn admits(&self, current: &Stamp, schema: &Schema, name: &str) -> bool { + self.current(current).is_ok() + && schema.concept == self.stamp.concept + && schema.version == self.stamp.schema + && schema + .ordinal(name) + .and_then(|o| schema.property(o)) + .is_some_and(|p| self.decide(p.position).is_ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hubspo_generated::standard_properties::CONTACT; + use hubspo_port::{ROLE_SALES_USER, ROLE_VIEW_ONLY}; + use ogar_vocab::class_ids; + + const V1: SchemaVersion = SchemaVersion(1); + const P0: PolicyVersion = PolicyVersion(1); + + fn contact() -> Schema { + Schema { + concept: class_ids::CRM_CONTACT, + version: V1, + properties: CONTACT, + } + } + + /// Every field of contact, outer-granted: lanes and the packed run. + fn all_fields(_: RoleId) -> Option { + let lanes = CONTACT.iter().filter_map(|p| match p.slot { + Slot::Lane(n) => Some(n), + Slot::Packed => None, + }); + Some(WideFieldMask::from_positions( + &std::iter::once(PACKED_RUN_POSITION) + .chain(lanes) + .collect::>(), + )) + } + + const PERSONAL: [&str; 4] = [ + "date_of_birth", + "gender", + "marital_status", + "military_status", + ]; + + fn ord(name: &str) -> u16 { + contact().ordinal(name).unwrap() + } + + /// A row carrying every property, value = ordinal. + fn full_row() -> Vec<(u16, u16)> { + CONTACT.iter().map(|p| (p.position, p.position)).collect() + } + + fn school_only() -> Projection { + let mut inner = PropertyGrants::new(); + inner + .grant(ROLE_SALES_USER, &contact(), &["school"]) + .unwrap(); + Projection::compile(&contact(), &[ROLE_SALES_USER], all_fields, P0, &inner) + } + + #[test] + fn the_fixture_packs_school_with_the_personal_attributes() { + let s = contact(); + for name in std::iter::once("school").chain(PERSONAL) { + let o = s.ordinal(name).unwrap(); + assert_eq!(s.property(o).unwrap().slot, Slot::Packed, "{name}"); + } + assert!(CONTACT.len() > 256, "ordinals exceed a u8 field position"); + } + + #[test] + fn reading_school_does_not_grant_the_personal_attributes() { + let p = school_only(); + let out = p.project(&p.stamp(), full_row()).unwrap(); + assert_eq!(out, vec![(ord("school"), ord("school"))]); + for name in PERSONAL { + assert!(!p.admits(&p.stamp(), &contact(), name), "{name}"); + assert_eq!( + p.require(&p.stamp(), &[ord(name)]), + Err(Refusal::Hidden(ord(name))) + ); + } + assert!(p.admits(&p.stamp(), &contact(), "school")); + assert_eq!(p.require(&p.stamp(), &[ord("school")]), Ok(())); + } + + #[test] + fn a_predicate_mixing_granted_and_hidden_properties_is_refused() { + let p = school_only(); + assert_eq!( + p.require(&p.stamp(), &[ord("school"), ord("date_of_birth")]), + Err(Refusal::Hidden(ord("date_of_birth"))) + ); + } + + #[test] + fn the_container_without_the_inner_grant_shows_nothing() { + // Outer grants every field, the packed run included; no inner grant. + let p = Projection::compile( + &contact(), + &[ROLE_SALES_USER], + all_fields, + P0, + &PropertyGrants::new(), + ); + assert!(p.project(&p.stamp(), full_row()).unwrap().is_empty()); + } + + #[test] + fn the_inner_grant_without_the_container_shows_nothing_packed() { + let mut inner = PropertyGrants::new(); + inner.grant_all(ROLE_SALES_USER, &contact()); + let lanes_only = |_: RoleId| { + let lanes: Vec = CONTACT + .iter() + .filter_map(|p| match p.slot { + Slot::Lane(n) => Some(n), + Slot::Packed => None, + }) + .collect(); + Some(WideFieldMask::from_positions(&lanes)) + }; + let p = Projection::compile(&contact(), &[ROLE_SALES_USER], lanes_only, P0, &inner); + let out = p.project(&p.stamp(), full_row()).unwrap(); + assert_eq!( + out.len(), + CONTACT.iter().filter(|q| q.slot != Slot::Packed).count() + ); + assert!(!p.admits(&p.stamp(), &contact(), "school")); + assert!(p.admits(&p.stamp(), &contact(), "firstname")); + } + + #[test] + fn a_stale_projection_refuses_everything() { + let p = school_only(); + let current = Stamp { + schema: SchemaVersion(2), + ..p.stamp() + }; + let stale = Refusal::Stale { + compiled: p.stamp(), + current, + }; + assert_eq!(p.project(¤t, full_row()), Err(stale.clone())); + assert_eq!(p.require(¤t, &[ord("school")]), Err(stale)); + assert!(!p.admits(¤t, &contact(), "school")); + } + + #[test] + fn a_projection_for_another_concept_is_refused() { + // A deal projection at the same versions must not read a contact + // row: ordinal 8 is a deal property there and date_of_birth here. + let deal = Schema { + concept: class_ids::CRM_DEAL, + ..contact() + }; + let mut inner = PropertyGrants::new(); + inner.grant_all(ROLE_SALES_USER, &deal); + let p = Projection::compile(&deal, &[ROLE_SALES_USER], all_fields, P0, &inner); + let contact_now = Stamp::of(&contact(), &inner, P0); + assert_eq!(p.stamp().schema, contact_now.schema, "same version"); + assert!(matches!( + p.project(&contact_now, full_row()), + Err(Refusal::Stale { .. }) + )); + assert!(p.require(&contact_now, &[ord("date_of_birth")]).is_err()); + assert!(!p.admits(&p.stamp(), &contact(), "date_of_birth")); + // Silent twin: under its own stamp the deal projection reads. + assert_eq!(p.require(&p.stamp(), &[8]), Ok(())); + } + + #[test] + fn a_grant_change_invalidates_the_projection() { + let mut inner = PropertyGrants::new(); + inner + .grant(ROLE_SALES_USER, &contact(), &["school"]) + .unwrap(); + let p = Projection::compile(&contact(), &[ROLE_SALES_USER], all_fields, P0, &inner); + let before = Stamp::of(&contact(), &inner, P0); + assert_eq!(p.require(&before, &[ord("school")]), Ok(())); + // Inner revocation. + inner.revoke(ROLE_SALES_USER, class_ids::CRM_CONTACT); + let after = Stamp::of(&contact(), &inner, P0); + assert!(matches!( + p.project(&after, full_row()), + Err(Refusal::Stale { .. }) + )); + assert!(p.require(&after, &[ord("school")]).is_err()); + // Outer policy change (a role or field mask withdrawn elsewhere). + let outer_changed = Stamp::of(&contact(), &inner, PolicyVersion(2)); + let recompiled = + Projection::compile(&contact(), &[ROLE_SALES_USER], all_fields, P0, &inner); + assert!(recompiled.require(&outer_changed, &[]).is_err()); + assert!(recompiled.project(&after, full_row()).unwrap().is_empty()); + } + + #[test] + fn unknown_ordinals_are_dropped_and_refused() { + let mut inner = PropertyGrants::new(); + inner.grant_all(ROLE_SALES_USER, &contact()); + let p = Projection::compile(&contact(), &[ROLE_SALES_USER], all_fields, P0, &inner); + let past = u16::try_from(CONTACT.len()).unwrap(); + assert_eq!(p.project(&p.stamp(), [(past, 0u16)]).unwrap(), vec![]); + assert_eq!(p.require(&p.stamp(), &[past]), Err(Refusal::Unknown(past))); + assert_eq!( + inner.grant(ROLE_SALES_USER, &contact(), &["no_such_property"]), + Err(GrantError::UnknownProperty("no_such_property".into())) + ); + } + + #[test] + fn roles_union_but_each_needs_both_masks() { + let mut inner = PropertyGrants::new(); + inner + .grant(ROLE_SALES_USER, &contact(), &["school"]) + .unwrap(); + inner + .grant(ROLE_VIEW_ONLY, &contact(), &["gender"]) + .unwrap(); + // view-only has no outer mask: its inner grant must not combine with + // the sales role's outer mask. + let outer = |r: RoleId| (r == ROLE_SALES_USER).then(|| all_fields(r)).flatten(); + let p = Projection::compile( + &contact(), + &[ROLE_SALES_USER, ROLE_VIEW_ONLY], + outer, + P0, + &inner, + ); + assert!(p.admits(&p.stamp(), &contact(), "school")); + assert!(!p.admits(&p.stamp(), &contact(), "gender")); + let both = |r: RoleId| all_fields(r); + let p = Projection::compile( + &contact(), + &[ROLE_SALES_USER, ROLE_VIEW_ONLY], + both, + P0, + &inner, + ); + assert!( + p.admits(&p.stamp(), &contact(), "school") + && p.admits(&p.stamp(), &contact(), "gender") + ); + } + + #[test] + fn the_inner_policy_ignores_where_a_property_is_stored() { + // The same grants against a table where school has its own lane: + // the inner set names ordinals, so the decision does not move. + let moved: &'static [StandardProperty] = Box::leak( + CONTACT + .iter() + .map(|p| { + let mut q = *p; + if q.name == "school" { + q.slot = Slot::Lane(255); + } + q + }) + .collect::>() + .into_boxed_slice(), + ); + let schema = Schema { + properties: moved, + ..contact() + }; + let mut inner = PropertyGrants::new(); + inner.grant(ROLE_SALES_USER, &schema, &["school"]).unwrap(); + let outer = |_: RoleId| Some(WideFieldMask::from_positions(&[PACKED_RUN_POSITION, 255])); + let p = Projection::compile(&schema, &[ROLE_SALES_USER], outer, P0, &inner); + assert_eq!( + p.project(&p.stamp(), full_row()).unwrap(), + vec![(ord("school"), ord("school"))] + ); + for name in PERSONAL { + assert!(!p.admits(&p.stamp(), &schema, name), "{name}"); + } + } + + #[test] + fn grant_all_covers_only_its_version() { + // Version 1 knows the first 10 properties; version 2 knows all. + let v1 = Schema { + properties: &CONTACT[..10], + ..contact() + }; + let v2 = Schema { + version: SchemaVersion(2), + ..contact() + }; + let mut inner = PropertyGrants::new(); + inner.grant_all(ROLE_SALES_USER, &v1); + let p = Projection::compile(&v2, &[ROLE_SALES_USER], all_fields, P0, &inner); + let out = p.project(&p.stamp(), full_row()).unwrap(); + assert_eq!( + out.len(), + 10, + "properties added after the grant stay hidden" + ); + } +} diff --git a/crates/hubspo-iam/src/lib.rs b/crates/hubspo-iam/src/lib.rs index e813aa6..3693f97 100644 --- a/crates/hubspo-iam/src/lib.rs +++ b/crates/hubspo-iam/src/lib.rs @@ -4,7 +4,14 @@ //! //! - Zone: zone 2 (no serde, no wire formats). //! - Deliverables: D-IAM-1..7 (`.claude/plans/hubspo-integration-v1.md`). +//! - [`fields`]: the D-IAM-3 contract, per-property read authorization by +//! stable ordinal, independent of the packed run. +//! - [`roles`]: D-IAM-1, HubSpot's preset roles as grants over the plugged +//! concepts, the bind of `hubspo_port::RBAC_PLUG`, and the request's roles +//! read from the directory. //! - [`scope`]: D-IAM-2, HubSpot's ownership scopes as a row filter over the //! owner lane. +pub mod fields; +pub mod roles; pub mod scope; diff --git a/crates/hubspo-iam/src/roles.rs b/crates/hubspo-iam/src/roles.rs new file mode 100644 index 0000000..59d314b --- /dev/null +++ b/crates/hubspo-iam/src/roles.rs @@ -0,0 +1,446 @@ +//! D-IAM-1: HubSpot's preset roles as grants over the plugged concepts. +//! +//! [`Catalogue`] is the [`GrantSource`] behind `hubspo_port::RBAC_PLUG`: +//! one `(concept, verbs)` grant per plugged concept for each of the six +//! roles in `hubspo_port::RBAC_ROLES`. [`bind`] resolves the plug against +//! OGAR's authority and returns the [`RbacBinding`] every decision reads. +//! +//! ## What this is not +//! +//! - **Not actor assignment.** [`Catalogue::roles_of`] knows no actor. A +//! user's roles come from the directory: a role is granted to a security +//! group in OGAR's versioned group-role policy, and [`request_actors`] +//! reads them through dir-sim's `ViewActors` once per request. Never per +//! record: a `View` walk is a full membership scan (0.4–0.7 ms at +//! 35k–263k rows, lance-graph D-RMAP-DIR-1). An authenticated user +//! without a directory object goes through `ogar_rbac::IdentityActors` +//! instead; both are `ActorSource`s over the same binding. +//! - **Not field masks.** [`Catalogue::field_mask_of`] is the trait default +//! (`None`, no column restriction). Column grants and the packed-run inner +//! mask are D-IAM-3. +//! - **Not tenant permission sets.** A HubSpot permission set is tenant +//! data, created at runtime, so it cannot be a `&'static str` role; it is +//! an open item on the D-IAM-1 row. +//! +//! ## The verb table +//! +//! The presets are a provisional policy, written from the seat names, not +//! from a HubSpot export. Every role reads every plugged concept; the table +//! below lists what it may do beyond reading. `edit` is +//! [`OpMask::WRITE`] | [`OpMask::CREATE`]; `act` adds [`OpMask::ACT`]; +//! `delete` is [`OpMask::DELETE`]. +//! +//! | role | edit + act | edit | act | +//! |---|---|---|---| +//! | super admin | all, plus delete | — | — | +//! | admin | all, plus delete, except users and teams (no delete) | — | — | +//! | sales user | contact, company, deal, lead, call, meeting, task, note, email, line item, quote/invoice | list | sequence | +//! | service user | contact, company, ticket, call, meeting, task, note, email | list | — | +//! | marketing user | contact, company, campaign, email | list, note, task | — | +//! | view only | — | — | — | +//! +//! Only the two admin roles delete. + +use hubspo_port::{ + RBAC_CLASSIDS, RBAC_PLUG, ROLE_ADMIN, ROLE_MARKETING_USER, ROLE_SALES_USER, ROLE_SERVICE_USER, + ROLE_SUPER_ADMIN, ROLE_VIEW_ONLY, +}; +use lance_graph_contract::rbac::{ActorId, ClassGrant, OpMask, RoleId}; +use lance_graph_contract::rbac_plug::{RbacAuthority, RbacBinding, RbacDrift}; +use lance_graph_dir_sim::View; +use lance_graph_dir_sim::actors::{GroupRoles, ViewActors}; +use ogar_dir_core::Guid128; +use ogar_rbac::{GrantSource, OgarRbac}; +use ogar_vocab::class_ids; + +const READ: OpMask = OpMask::READ; +const EDIT: OpMask = READ.union(OpMask::WRITE).union(OpMask::CREATE); +const EDIT_ACT: OpMask = EDIT.union(OpMask::ACT); +const READ_ACT: OpMask = READ.union(OpMask::ACT); +const FULL: OpMask = EDIT_ACT.union(OpMask::DELETE); + +/// How many concepts each role holds a grant on: every plugged one. +const N: usize = RBAC_CLASSIDS.len(); + +/// The preset a role is built from. +#[derive(Clone, Copy)] +enum Preset { + SuperAdmin, + Admin, + Sales, + Service, + Marketing, + ViewOnly, +} + +/// The verbs `preset` holds on `concept`. +const fn verbs(preset: Preset, concept: u16) -> OpMask { + match preset { + Preset::SuperAdmin => FULL, + Preset::Admin => match concept { + class_ids::PROJECT_ACTOR => EDIT_ACT, + _ => FULL, + }, + Preset::Sales => match concept { + class_ids::CRM_CONTACT + | class_ids::CRM_COMPANY + | class_ids::CRM_DEAL + | class_ids::CRM_LEAD + | class_ids::CRM_CALL + | class_ids::CRM_MEETING + | class_ids::CRM_TASK + | class_ids::CRM_NOTE + | class_ids::EMAIL + | class_ids::COMMERCIAL_LINE_ITEM + | class_ids::COMMERCIAL_DOCUMENT => EDIT_ACT, + class_ids::CRM_LIST => EDIT, + class_ids::CRM_SEQUENCE => READ_ACT, + _ => READ, + }, + Preset::Service => match concept { + class_ids::CRM_CONTACT + | class_ids::CRM_COMPANY + | class_ids::CRM_TICKET + | class_ids::CRM_CALL + | class_ids::CRM_MEETING + | class_ids::CRM_TASK + | class_ids::CRM_NOTE + | class_ids::EMAIL => EDIT_ACT, + class_ids::CRM_LIST => EDIT, + _ => READ, + }, + Preset::Marketing => match concept { + class_ids::CRM_CONTACT + | class_ids::CRM_COMPANY + | class_ids::CRM_CAMPAIGN + | class_ids::EMAIL => EDIT_ACT, + class_ids::CRM_LIST | class_ids::CRM_NOTE | class_ids::CRM_TASK => EDIT, + _ => READ, + }, + Preset::ViewOnly => READ, + } +} + +/// One grant per plugged concept, in [`RBAC_CLASSIDS`] order. +const fn grants(preset: Preset) -> [ClassGrant; N] { + let mut out = [ClassGrant::new(0, OpMask::NONE); N]; + let mut i = 0; + while i < N { + let concept = RBAC_CLASSIDS[i]; + out[i] = ClassGrant::new(concept, verbs(preset, concept)); + i += 1; + } + out +} + +const SUPER_ADMIN: [ClassGrant; N] = grants(Preset::SuperAdmin); +const ADMIN: [ClassGrant; N] = grants(Preset::Admin); +const SALES: [ClassGrant; N] = grants(Preset::Sales); +const SERVICE: [ClassGrant; N] = grants(Preset::Service); +const MARKETING: [ClassGrant; N] = grants(Preset::Marketing); +const VIEW_ONLY: [ClassGrant; N] = grants(Preset::ViewOnly); + +/// The preset grant table: the [`GrantSource`] behind `RBAC_PLUG`. +#[derive(Debug, Clone, Copy, Default)] +pub struct Catalogue; + +impl GrantSource for Catalogue { + /// Always empty: actors come from the request's identity, not from the + /// catalogue (see the module docs). + fn roles_of(&self, _actor: ActorId<'_>) -> &[RoleId] { + &[] + } + + fn grants_of(&self, role: RoleId) -> &[ClassGrant] { + match role { + ROLE_SUPER_ADMIN => &SUPER_ADMIN, + ROLE_ADMIN => &ADMIN, + ROLE_SALES_USER => &SALES, + ROLE_SERVICE_USER => &SERVICE, + ROLE_MARKETING_USER => &MARKETING, + ROLE_VIEW_ONLY => &VIEW_ONLY, + _ => &[], + } + } +} + +/// Bind `RBAC_PLUG` against OGAR's authority with the preset grants. +/// +/// # Errors +/// +/// The drift OGAR reports when the plug names a concept it does not mint, a +/// role the catalogue does not define, or a concept the contract's wire +/// mirror disagrees on. +pub fn bind() -> Result { + OgarRbac::new(Catalogue).bind(&RBAC_PLUG) +} + +/// Why a request's roles could not be resolved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoleRefusal { + /// The principal carries the SID of security groups whose grants were + /// never read, so its roles are not known in full. + UnreadGroups(Vec), +} + +/// The principal's roles for one request: the grants of the security groups +/// it carries in `view`, as `grants` (one version of OGAR's group-role +/// policy, `GroupRoles::from_policy`) states them, kept only where +/// `binding` declares the role. +/// +/// Call once per request and hand the result to +/// `binding.with_actors(..)`. A principal that is not a user in `view` +/// holds no role. +/// +/// # Errors +/// +/// [`RoleRefusal::UnreadGroups`] when a group the principal carries was +/// never read: the request is refused rather than decided on part of the +/// principal's roles. +pub fn request_actors( + view: &View<'_>, + principal: &Guid128, + grants: &GroupRoles, + binding: &RbacBinding, +) -> Result { + let actors = ViewActors::for_principal(view, principal, grants, binding); + if actors.unread_groups().is_empty() { + Ok(actors) + } else { + Err(RoleRefusal::UnreadGroups(actors.unread_groups().to_vec())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hubspo_port::{CONSUMER, CRM_CLASSIDS, RBAC_ROLES}; + use lance_graph_contract::rbac::{ClassId, ClassRbac, Operation}; + use lance_graph_contract::rbac_plug::ActorSource; + use std::collections::BTreeSet; + + fn class(concept: u16) -> ClassId { + u32::from(concept) << 16 + } + + fn verbs_of(binding: &RbacBinding, role: RoleId, concept: u16) -> OpMask { + let grants = binding.grants_for(role).expect("plugged role"); + let mut hits = grants.iter().filter(|g| g.target_classid == concept); + let grant = hits.next().expect("a grant on every plugged concept"); + assert!(hits.next().is_none(), "one grant per concept"); + grant.op_mask + } + + fn write() -> Operation<'static> { + Operation::Write { predicate: "name" } + } + + // ── the bind ── + + #[test] + fn the_plug_binds_with_every_preset() { + let binding = bind().expect("RBAC plug drifted from OGAR or the wire mirror"); + assert_eq!(binding.consumer(), CONSUMER); + let bound: Vec = binding.declared_grants().iter().map(|(r, _)| *r).collect(); + assert_eq!(bound, RBAC_ROLES); + for &(role, ref grants) in binding.declared_grants() { + assert_eq!(grants.len(), RBAC_CLASSIDS.len(), "{role}: nothing dropped"); + } + for &id in RBAC_CLASSIDS { + assert_eq!(binding.plugged(class(id)), Ok(id)); + } + } + + #[test] + fn every_plug_role_is_defined_and_nothing_else_is() { + for &role in RBAC_ROLES { + assert!(Catalogue.defines_role(role), "{role}"); + } + assert!(!Catalogue.defines_role("crm_owner")); + assert!( + !Catalogue.defines_role("CRM_SUPER_ADMIN"), + "names are exact" + ); + } + + #[test] + fn a_concept_outside_the_plug_is_refused_not_granted() { + let binding = bind().expect("binds"); + assert!(!RBAC_CLASSIDS.contains(&class_ids::PATIENT)); + assert_eq!( + binding.permits(ROLE_SUPER_ADMIN, class(class_ids::PATIENT), &write()), + Err(RbacDrift::NotPlugged(class_ids::PATIENT)) + ); + } + + // ── the table's shape ── + + #[test] + fn every_role_reads_every_plugged_concept() { + let binding = bind().expect("binds"); + let read = Operation::Read { + depth: lance_graph_contract::property::PrefetchDepth::Full, + }; + for &role in RBAC_ROLES { + for &id in RBAC_CLASSIDS { + assert_eq!(binding.permits(role, class(id), &read), Ok(true), "{role}"); + } + } + } + + #[test] + fn view_only_reads_and_nothing_more() { + let binding = bind().expect("binds"); + for &id in RBAC_CLASSIDS { + assert_eq!(verbs_of(&binding, ROLE_VIEW_ONLY, id), OpMask::READ); + } + } + + #[test] + fn only_the_admins_delete() { + let binding = bind().expect("binds"); + let deleters: BTreeSet = RBAC_ROLES + .iter() + .copied() + .filter(|&role| { + RBAC_CLASSIDS + .iter() + .any(|&id| verbs_of(&binding, role, id).contains(OpMask::DELETE)) + }) + .collect(); + assert_eq!(deleters, BTreeSet::from([ROLE_SUPER_ADMIN, ROLE_ADMIN])); + } + + #[test] + fn super_admin_holds_every_verb_everywhere_and_admin_falls_short_on_users() { + let binding = bind().expect("binds"); + for &id in RBAC_CLASSIDS { + assert_eq!(verbs_of(&binding, ROLE_SUPER_ADMIN, id), FULL); + let admin = verbs_of(&binding, ROLE_ADMIN, id); + if id == class_ids::PROJECT_ACTOR { + assert_eq!(admin, EDIT_ACT); + } else { + assert_eq!(admin, FULL, "{id:#06x}"); + } + } + } + + #[test] + fn each_seat_edits_exactly_its_own_concepts() { + let binding = bind().expect("binds"); + let editable = |role| -> BTreeSet { + RBAC_CLASSIDS + .iter() + .copied() + .filter(|&id| verbs_of(&binding, role, id).contains(OpMask::WRITE)) + .collect() + }; + use class_ids::*; + assert_eq!( + editable(ROLE_SALES_USER), + BTreeSet::from([ + CRM_CONTACT, + CRM_COMPANY, + CRM_DEAL, + CRM_LEAD, + CRM_CALL, + CRM_MEETING, + CRM_TASK, + CRM_NOTE, + EMAIL, + COMMERCIAL_LINE_ITEM, + COMMERCIAL_DOCUMENT, + CRM_LIST, + ]) + ); + assert_eq!( + editable(ROLE_SERVICE_USER), + BTreeSet::from([ + CRM_CONTACT, + CRM_COMPANY, + CRM_TICKET, + CRM_CALL, + CRM_MEETING, + CRM_TASK, + CRM_NOTE, + EMAIL, + CRM_LIST, + ]) + ); + assert_eq!( + editable(ROLE_MARKETING_USER), + BTreeSet::from([ + CRM_CONTACT, + CRM_COMPANY, + CRM_CAMPAIGN, + EMAIL, + CRM_LIST, + CRM_NOTE, + CRM_TASK, + ]) + ); + assert!(editable(ROLE_VIEW_ONLY).is_empty()); + } + + #[test] + fn create_travels_with_write() { + let binding = bind().expect("binds"); + for &role in RBAC_ROLES { + for &id in RBAC_CLASSIDS { + let v = verbs_of(&binding, role, id); + assert_eq!( + v.contains(OpMask::WRITE), + v.contains(OpMask::CREATE), + "{role} on {id:#06x}" + ); + } + } + } + + // ── decisions through an actor source ── + + /// One request's actors: a sales rep and a viewer. + struct Request; + + impl ActorSource for Request { + fn roles_of(&self, actor: ActorId<'_>) -> &[RoleId] { + match actor { + "rep" => &[ROLE_SALES_USER], + "viewer" => &[ROLE_VIEW_ONLY], + _ => &[], + } + } + } + + #[test] + fn a_sales_rep_writes_deals_but_not_tickets() { + let binding = bind().expect("binds"); + let rbac = binding.with_actors(Request); + let held = |actor, concept| { + rbac.actor_roles(actor) + .iter() + .any(|&r| rbac.grant_permits(r, class(concept), &write())) + }; + assert!(held("rep", class_ids::CRM_DEAL)); + assert!(!held("rep", class_ids::CRM_TICKET)); + assert!(!held("viewer", class_ids::CRM_DEAL)); + assert!(!held("stranger", class_ids::CRM_DEAL), "unknown actor"); + } + + #[test] + fn sequences_are_enrolled_not_edited_by_sales() { + let binding = bind().expect("binds"); + let act = Operation::Act { action: "enroll" }; + let seq = class(class_ids::CRM_SEQUENCE); + assert_eq!(binding.permits(ROLE_SALES_USER, seq, &act), Ok(true)); + assert_eq!(binding.permits(ROLE_SALES_USER, seq, &write()), Ok(false)); + assert_eq!(binding.permits(ROLE_SERVICE_USER, seq, &act), Ok(false)); + } + + #[test] + fn the_crm_concepts_are_inside_the_table() { + let all: BTreeSet = SUPER_ADMIN.iter().map(|g| g.target_classid).collect(); + assert!(CRM_CLASSIDS.iter().all(|id| all.contains(id))); + assert_eq!(all.len(), RBAC_CLASSIDS.len()); + } +} diff --git a/crates/hubspo-iam/tests/roles.rs b/crates/hubspo-iam/tests/roles.rs new file mode 100644 index 0000000..36f46f4 --- /dev/null +++ b/crates/hubspo-iam/tests/roles.rs @@ -0,0 +1,155 @@ +//! D-IAM-1: a request's roles come from the directory (security groups → +//! preset roles, as one version of the group-role policy states them) and +//! are decided against the preset grants through the plugged binding. + +use hubspo_iam::roles::{RoleRefusal, bind, request_actors}; +use hubspo_port::{ROLE_ADMIN, ROLE_SALES_USER, ROLE_VIEW_ONLY}; +use lance_graph_contract::rbac::{ClassId, ClassRbac, Operation}; +use lance_graph_contract::rbac_plug::ActorSource; +use lance_graph_dir_sim::actors::GroupRoles; +use lance_graph_dir_sim::*; +use ogar_dir_core::{DirectoryScope, Guid128}; +use ogar_vocab::class_ids; + +fn g(n: u8) -> Guid128 { + Guid128([n; 16]) +} +const DIR: DirectoryScope = DirectoryScope(Guid128([0x5C; 16])); + +const REP: u8 = 0x11; +const VIEWER: u8 = 0x12; +const LEAD: u8 = 0x13; +const NEWCOMER: u8 = 0x14; +const LOOSE: u8 = 0x15; +/// Security group granted the sales preset. +const SALES: u8 = 0xA1; +/// Security group granted view-only. +const READERS: u8 = 0xA2; +/// Security group granted admin, nested in SALES. +const ADMINS: u8 = 0xA3; +/// Security group whose grants were never read. +const NEW: u8 = 0xA4; +/// Distribution group: no SID, passes on nothing. +const LIST: u8 = 0xD1; + +fn security(security: bool) -> ObservedNode { + let mut n = ObservedNode::group(); + n.security = Some(security); + n +} + +// SALES ⊃ {rep, ADMINS}; ADMINS ⊃ {lead}; READERS ⊃ {viewer}; +// NEW ⊃ {newcomer}; LIST ⊃ {loose}, and LIST is granted sales. +fn observed() -> Observation { + let user = |n: &str| ObservedNode::user(&format!("{n}@x.test"), &format!("{n}@x.test")); + Observation { + scope: DIR, + nodes: vec![ + (g(REP), user("rep")), + (g(VIEWER), user("viewer")), + (g(LEAD), user("lead")), + (g(NEWCOMER), user("newcomer")), + (g(LOOSE), user("loose")), + (g(SALES), security(true)), + (g(READERS), security(true)), + (g(ADMINS), security(true)), + (g(NEW), security(true)), + (g(LIST), security(false)), + ], + members: vec![ + (g(REP), g(SALES)), + (g(ADMINS), g(SALES)), + (g(LEAD), g(ADMINS)), + (g(VIEWER), g(READERS)), + (g(NEWCOMER), g(NEW)), + (g(LOOSE), g(LIST)), + ], + } +} + +fn policy() -> GroupRoles { + let mut gr = GroupRoles::new(); + gr.read(g(SALES), &[ROLE_SALES_USER]) + .read(g(READERS), &[ROLE_VIEW_ONLY]) + .read(g(ADMINS), &[ROLE_ADMIN, "not_a_hubspo_role"]) + .read(g(LIST), &[ROLE_SALES_USER]); + gr +} + +fn class(concept: u16) -> ClassId { + u32::from(concept) << 16 +} + +fn writes(rbac: &impl ClassRbac, actor: &str, concept: u16) -> bool { + let op = Operation::Write { predicate: "name" }; + rbac.actor_roles(actor) + .iter() + .any(|&r| rbac.grant_permits(r, class(concept), &op)) +} + +#[test] +fn roles_come_from_security_groups_through_the_binding() { + let mut st = VersionStore::new(); + let v = st.observe("crm", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let binding = bind().unwrap(); + + let rep = request_actors(&view, &g(REP), &policy(), &binding).unwrap(); + assert_eq!(rep.roles(), &[ROLE_SALES_USER]); + let rbac = binding.with_actors(rep.clone()); + assert!(writes(&rbac, rep.subject(), class_ids::CRM_DEAL)); + assert!(!writes(&rbac, rep.subject(), class_ids::CRM_TICKET)); + assert!(!writes(&rbac, "someone-else", class_ids::CRM_DEAL)); + + // Nested: lead carries ADMINS and, through it, SALES. The role OGAR's + // policy names but the plug does not declare is dropped. + let lead = request_actors(&view, &g(LEAD), &policy(), &binding).unwrap(); + let mut held = lead.roles().to_vec(); + held.sort_unstable(); + let mut want = vec![ROLE_ADMIN, ROLE_SALES_USER]; + want.sort_unstable(); + assert_eq!(held, want); + assert!(writes( + &binding.with_actors(lead.clone()), + lead.subject(), + class_ids::CRM_TICKET + )); + + let viewer = request_actors(&view, &g(VIEWER), &policy(), &binding).unwrap(); + assert_eq!(viewer.roles(), &[ROLE_VIEW_ONLY]); + assert!(!writes( + &binding.with_actors(viewer.clone()), + viewer.subject(), + class_ids::CRM_CONTACT + )); +} + +#[test] +fn a_distribution_group_and_a_stranger_grant_nothing() { + let mut st = VersionStore::new(); + let v = st.observe("crm", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let binding = bind().unwrap(); + let loose = request_actors(&view, &g(LOOSE), &policy(), &binding).unwrap(); + assert!(loose.roles().is_empty(), "LIST has no SID"); + let ghost = request_actors(&view, &g(0xF0), &policy(), &binding).unwrap(); + assert!(ghost.roles().is_empty()); + assert!(ghost.roles_of(ghost.subject()).is_empty()); +} + +#[test] +fn an_unread_group_refuses_the_request() { + let mut st = VersionStore::new(); + let v = st.observe("crm", 1, observed()).unwrap(); + let view = st.view(v).unwrap(); + let binding = bind().unwrap(); + assert_eq!( + request_actors(&view, &g(NEWCOMER), &policy(), &binding), + Err(RoleRefusal::UnreadGroups(vec![g(NEW)])) + ); + // Read with no grants is not unread: the request is decided, with no role. + let mut gr = policy(); + gr.read(g(NEW), &[]); + let newcomer = request_actors(&view, &g(NEWCOMER), &gr, &binding).unwrap(); + assert!(newcomer.roles().is_empty()); +} diff --git a/crates/hubspo-port/Cargo.toml b/crates/hubspo-port/Cargo.toml index 3c0fa5e..0f0546e 100644 --- a/crates/hubspo-port/Cargo.toml +++ b/crates/hubspo-port/Cargo.toml @@ -11,6 +11,8 @@ publish = false [dependencies] lance-graph-contract.workspace = true ogar-vocab.workspace = true + +[dev-dependencies] ogar-rbac.workspace = true [lints] diff --git a/crates/hubspo-port/src/lib.rs b/crates/hubspo-port/src/lib.rs index ce92f21..155e990 100644 --- a/crates/hubspo-port/src/lib.rs +++ b/crates/hubspo-port/src/lib.rs @@ -13,14 +13,15 @@ //! //! | plug | classids | today | //! |---|---|---| -//! | [`RBAC_PLUG`] | the 15 CRM concepts + the 7 core concepts HubSpot objects converge on | binds green ([`bind`]); declares no roles | +//! | [`RBAC_PLUG`] | the 15 CRM concepts + the 7 core concepts HubSpot objects converge on | declares the six preset roles; their grants and the bind are `hubspo-iam::roles` (D-IAM-1) | //! | [`HOT_PLUG`] | the 15 CRM concepts | refuses with `NoCapabilitiesFor` | //! -//! Both gaps are deliberate: +//! The role names live here because they are part of the plug declaration; +//! the grants behind them live in `hubspo-iam`, which depends on this crate, +//! so the bind is done there. +//! +//! The capability gap is deliberate: //! -//! - **No roles.** The role catalogue is D-IAM-1 (`hubspo-iam`). A role -//! declared here before its grants exist would either bind to nothing or -//! need invented grants. //! - **No capabilities.** OGAR has no `crm_actions` table: capabilities are //! declared only once HubSPO-rs implements them (dossier v3 §7, the //! `geo_actions` rule). Until D-ACT-1, activating [`HOT_PLUG`] reports @@ -30,9 +31,8 @@ use lance_graph_contract::hotplug::HotPlug; use lance_graph_contract::ogar_codebook::render_classid; -use lance_graph_contract::rbac::{ActorId, ClassGrant, RoleId}; -use lance_graph_contract::rbac_plug::{RbacBinding, RbacDrift, RbacPlug}; -use ogar_rbac::{GrantSource, OgarRbac}; +use lance_graph_contract::rbac::RoleId; +use lance_graph_contract::rbac_plug::RbacPlug; use ogar_vocab::class_ids; use ogar_vocab::ports::PortSpec; @@ -99,8 +99,29 @@ pub const RBAC_CLASSIDS: &[u16] = &[ class_ids::PROJECT_ACTOR, ]; -/// The roles HubSPO-rs declares. Empty until D-IAM-1 (see the module docs). -pub const RBAC_ROLES: &[RoleId] = &[]; +/// HubSpot's Super Admin: every verb on every plugged concept. +pub const ROLE_SUPER_ADMIN: RoleId = "crm_super_admin"; +/// An account admin: every verb, except deleting users and teams. +pub const ROLE_ADMIN: RoleId = "crm_admin"; +/// The sales seat preset. +pub const ROLE_SALES_USER: RoleId = "crm_sales_user"; +/// The service seat preset. +pub const ROLE_SERVICE_USER: RoleId = "crm_service_user"; +/// The marketing seat preset. +pub const ROLE_MARKETING_USER: RoleId = "crm_marketing_user"; +/// Read-only access to every plugged concept. +pub const ROLE_VIEW_ONLY: RoleId = "crm_view_only"; + +/// The roles HubSPO-rs declares: HubSpot's preset roles (D-IAM-1). Their +/// grants are `hubspo-iam::roles`; tenant permission sets are not roles. +pub const RBAC_ROLES: &[RoleId] = &[ + ROLE_SUPER_ADMIN, + ROLE_ADMIN, + ROLE_SALES_USER, + ROLE_SERVICE_USER, + ROLE_MARKETING_USER, + ROLE_VIEW_ONLY, +]; /// HubSPO-rs's RBAC declaration. pub const RBAC_PLUG: RbacPlug = RbacPlug { @@ -138,44 +159,31 @@ pub fn render_class_id(public_name: &str) -> Option { class_id(public_name).map(|concept| render_classid(HubSpoPort::APP_PREFIX, concept)) } -/// The grant data behind [`RBAC_PLUG`]. Holds no roles and no grants until -/// D-IAM-1, so it defines no role and authorizes nothing. -#[derive(Debug, Clone, Copy, Default)] -pub struct HubSpoGrants; - -impl GrantSource for HubSpoGrants { - fn roles_of(&self, _actor: ActorId<'_>) -> &[RoleId] { - &[] - } - - fn grants_of(&self, _role: RoleId) -> &[ClassGrant] { - &[] - } -} - -/// Bind [`RBAC_PLUG`] against OGAR's authority. This checks that every -/// plugged classid is minted in `ogar-vocab` and agrees with the contract's -/// wire mirror. -/// -/// # Errors -/// -/// The drift OGAR reports when the plug names a class or role it does not -/// know, or when OGAR and the mirror disagree. -pub fn bind() -> Result { - use lance_graph_contract::rbac_plug::RbacAuthority; - OgarRbac::new(HubSpoGrants).bind(&RBAC_PLUG) -} - #[cfg(test)] mod tests { use super::*; use lance_graph_contract::ogar_codebook::{AppPrefix, classid_app_prefix, classid_concept}; - use lance_graph_contract::rbac_plug::RbacAuthority; + use lance_graph_contract::rbac::{ActorId, ClassGrant}; + use lance_graph_contract::rbac_plug::{RbacAuthority, RbacDrift}; + use ogar_rbac::{GrantSource, OgarRbac}; use ogar_vocab::capability_registry::{HotplugDrift, resolve_hotplug}; use ogar_vocab::ports::HUBSPO_ALIASES; use ogar_vocab::{ConceptDomain, concepts_in_domain}; use std::collections::BTreeSet; + /// A source that defines no role, for the refusal arms. + struct NoGrants; + + impl GrantSource for NoGrants { + fn roles_of(&self, _actor: ActorId<'_>) -> &[RoleId] { + &[] + } + + fn grants_of(&self, _role: RoleId) -> &[ClassGrant] { + &[] + } + } + fn set(ids: &[u16]) -> BTreeSet { ids.iter().copied().collect() } @@ -207,26 +215,13 @@ mod tests { assert_eq!(set(RBAC_CLASSIDS), union); } - // ── RBAC: binds green, and each drift arm can fire ── - - #[test] - fn rbac_plug_binds_against_ogar() { - let binding = bind().expect("RBAC plug drifted from OGAR or the wire mirror"); - assert_eq!(binding.consumer(), CONSUMER); - for &id in RBAC_CLASSIDS { - assert_eq!(binding.plugged(u32::from(id) << 16), Ok(id)); - } - } + // ── RBAC: the plug's own refusal arms (the green bind is hubspo-iam's) ── #[test] - fn a_concept_outside_the_plug_is_refused_not_granted() { - let binding = bind().expect("binds"); - let patient = class_ids::PATIENT; - assert!(!RBAC_CLASSIDS.contains(&patient)); - assert_eq!( - binding.plugged(u32::from(patient) << 16), - Err(RbacDrift::NotPlugged(patient)) - ); + fn role_names_are_distinct_and_plugged() { + let names: BTreeSet = RBAC_ROLES.iter().copied().collect(); + assert_eq!(names.len(), 6); + assert_eq!(RBAC_PLUG.roles, RBAC_ROLES); } #[test] @@ -237,7 +232,7 @@ mod tests { roles: &[], }; assert_eq!( - OgarRbac::new(HubSpoGrants).bind(&plug), + OgarRbac::new(NoGrants).bind(&plug), Err(RbacDrift::UnknownClassid(0x05FE)) ); } @@ -247,11 +242,11 @@ mod tests { let plug = RbacPlug { consumer: CONSUMER, classids: CRM_CLASSIDS, - roles: &["crm_super_admin"], + roles: &[ROLE_SUPER_ADMIN], }; assert_eq!( - OgarRbac::new(HubSpoGrants).bind(&plug), - Err(RbacDrift::UnknownRole("crm_super_admin".into())) + OgarRbac::new(NoGrants).bind(&plug), + Err(RbacDrift::UnknownRole(ROLE_SUPER_ADMIN.into())) ); } @@ -312,10 +307,4 @@ mod tests { Some(AppPrefix::HubSpo) ); } - - #[test] - fn the_authority_trait_and_bind_agree() { - // `bind()` is a convenience over the trait; it must not diverge. - assert_eq!(bind(), OgarRbac::new(HubSpoGrants).bind(&RBAC_PLUG)); - } }