diff --git a/.claude/board/ISSUES.md b/.claude/board/ISSUES.md index ac4a26c..f7458e9 100644 --- a/.claude/board/ISSUES.md +++ b/.claude/board/ISSUES.md @@ -5,13 +5,13 @@ ## ISS-PRESET-VERBS-PROVISIONAL — the preset roles' verbs are written from seat names, not from HubSpot -- **Status:** DECIDED 2026-10-21 (operator, EXO-50 message 006): AMENDED, not approved. The six role names stay. The verb table stays only as a tagged compatibility/simulation fixture, never as tenant runtime access. EffectiveAccess = seat/subscription ceiling ∧ tenant permission grants ∧ owner/team scope ∧ per-property outer+ordinal authorization ∧ action-specific gates. Super Admin is an explicit status, not a bypass of tenant isolation, schema/policy validation or seat gates. Admin is not blanket FULL/DELETE. View-only has no implicit read of sensitive fields. ACT (email send, enrolment, invoice, bulk), DELETE and EXPORT each need an explicit grant and are never inherited from WRITE/CREATE. A missing, unknown, stale or unread tenant grant fails closed and never falls back to the fixture. The presets are not reported as HubSpot-verified until a sandbox oracle exists (ISS-ORACLE-PORTAL). Code still follows the old table until the D-IAM-3 code PR. +- **Status:** DECIDED 2026-10-21 (operator, EXO-50 message 006): AMENDED, not approved. The six role names stay. The verb table stays only as a tagged compatibility/simulation fixture, never as tenant runtime access. EffectiveAccess = seat/subscription ceiling ∧ tenant permission grants ∧ owner/team scope ∧ per-property outer+ordinal authorization ∧ action-specific gates. Super Admin is an explicit status, not a bypass of tenant isolation, schema/policy validation or seat gates. Admin is not blanket FULL/DELETE. View-only has no implicit read of sensitive fields. ACT (email send, enrolment, invoice, bulk), DELETE and EXPORT each need an explicit grant and are never inherited from WRITE/CREATE. A missing, unknown, stale or unread tenant grant fails closed and never falls back to the fixture. The presets are not reported as HubSpot-verified until a sandbox oracle exists (ISS-ORACLE-PORTAL). Implemented in the D-IAM-3 code PR (2026-10-21): the runtime bind grants nothing, the table is `PresetFixture`. - **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:** DECIDED 2026-10-21 (operator, EXO-50 message 006): permission sets are versioned tenant DATA with stable numeric ids, separate from the static `RoleId` presets. A permission set may replace a user's configured permissions and is not assumed to only narrow a preset (HubSpot user-management docs, 2026-09). Seat, role template and grant are three different things. Implementation: D-IAM-1 / D-IAM-3 code PR. +- **Status:** DECIDED 2026-10-21 (operator, EXO-50 message 006): permission sets are versioned tenant DATA with stable numeric ids, separate from the static `RoleId` presets. A permission set may replace a user's configured permissions and is not assumed to only narrow a preset (HubSpot user-management docs, 2026-09). Seat, role template and grant are three different things. Implemented 2026-10-21: `hubspo_iam::tenant` (`TenantPolicy`, `PermissionSet`, `Seat`). Persistence is still open. - **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 diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 6396e67..b6f6ac9 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -222,7 +222,10 @@ `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 +- *Superseded 2026-10-21 by the D-IAM-3 code PR (see the next section):* + `Catalogue` is renamed `PresetFixture`, bound only by + `bind_preset_fixture()`; `bind()` now grants nothing. +- `hubspo_iam::roles::Catalogue` was 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, @@ -241,7 +244,7 @@ (`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`). -- 2026-10-21 ruling: the verb table is a fixture only. Runtime access must come from versioned tenant permission sets and fail closed (`ISS-PRESET-VERBS-PROVISIONAL`, `ISS-PERMISSION-SETS-NOT-ROLES`, both decided). The code still uses `Catalogue` until the D-IAM-3 code PR. +- 2026-10-21 ruling: the verb table is a fixture only. Runtime access must come from versioned tenant permission sets and fail closed (`ISS-PRESET-VERBS-PROVISIONAL`, `ISS-PERMISSION-SETS-NOT-ROLES`, both decided). The D-IAM-3 code PR implements it. ## Per-property read authorization (D-IAM-3, contract half) @@ -266,6 +269,43 @@ directory group accepted. - Not yet wired into any output path, and no default grants per preset. +## Tenant access and the first output path (D-IAM-3, code half) + +- A role name grants nothing: `hubspo_iam::roles::bind()` binds the plug + through `RoleNames` (the six names, no grants), so the binding's own + decisions all refuse. The verb table is `PresetFixture` + (`bind_preset_fixture()`), for simulation and tests only. +- `hubspo_iam::tenant`: `TenantPolicy` (one tenant, a declared + `PolicyVersion` plus a generation bumped on every change) holds + `PermissionSet`s with stable `PermissionSetId`s, assigned to a role or a + principal. A set lists its class grants, ownership scopes, outer field + masks, inner ordinals and named `ActionGrant`s; nothing is implied. + `Seat` is the ceiling (version, concepts, actions). +- `hubspo_iam::access::RequestAccess::compile`, once per request per + concept: rows from the broadest scope of the sets that read the concept, + properties via `Projection::compile_holders`, plain verbs + (`READ`/`WRITE`/`CREATE`), and named actions, each needing its verb and + its gate in one set and the seat. `ACT`/`DELETE` are never plain verbs. + Stamped with concept, schema version, policy version and generation, + seat version and role-resolution version (`Stamp` gained `seat` and + `roles`); any difference refuses as `Stale`. +- `hubspo_compat::contacts`: `lookup` answers `NotFound` for a missing + record, another tenant's record, an owner outside the rows, or no read + grant; it projects by ordinal before naming properties. `check_search` + refuses filters and sorts on hidden or unknown properties. +- Tests: `hubspo-iam` 40 lib + 4 directory + 8 scope, `hubspo-compat` 6. + The negative tests from the ruling are all present (missing policy, + revocation, other concept or schema, view-only without the personal + properties, ACT/DELETE without a gate or seat, hidden-predicate search), + plus seat-change and role-change invalidation and super admin with no set. +- Ten disable runs measured red: seat concept check, seat action check, + named-gate check, plain-verb check, scope from non-reading sets, role + version dropped from the stamp, tenant check, row-scope check, search + gate, `RoleNames` returning the preset grants. +- Open: the store read that produces a record's `(ordinal, value)` pairs + (D-API-1), persistence of `TenantPolicy`, default sets per preset for + simulation, and every other output path (UI, export, events, workflows). + ## 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 dab46ff..4d3da00 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 | 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. 2026-10-21 ruling (ISS-PRESET-VERBS-PROVISIONAL, amended): `Catalogue` is a fixture, not tenant runtime access; runtime grants come from versioned tenant permission sets (ISS-PERMISSION-SETS-NOT-ROLES) and fail closed. To change in the D-IAM-3 code PR. | +| 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. 2026-10-21 ruling (ISS-PRESET-VERBS-PROVISIONAL, amended): `Catalogue` is a fixture, not tenant runtime access; runtime grants come from versioned tenant permission sets (ISS-PERMISSION-SETS-NOT-ROLES) and fail closed. 2026-10-21 D-IAM-3 code PR: `bind()` grants nothing (`RoleNames`); the table is `PresetFixture`; runtime grants come from `hubspo_iam::tenant`. Open: tenant permission-set persistence and default sets for simulation. | | 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 | 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 2026-10-21 ruling: proceed with a first output path (contact lookup): actors resolved once per request; a projection stamped with schema, grant, role and seat versions; owner/team scope and per-ordinal grants before serialization, export, cache or diagnostics; ACT/DELETE/EXPORT gated individually. Required negative tests: missing policy, revocation, other concept or snapshot, view-only on sensitive fields, unauthorized ACT/DELETE, hidden-predicate inference. | +| 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 2026-10-21 ruling: proceed with a first output path (contact lookup): actors resolved once per request; a projection stamped with schema, grant, role and seat versions; owner/team scope and per-ordinal grants before serialization, export, cache or diagnostics; ACT/DELETE/EXPORT gated individually. Required negative tests: missing policy, revocation, other concept or snapshot, view-only on sensitive fields, unauthorized ACT/DELETE, hidden-predicate inference. 2026-10-21 code PR: `hubspo_iam::access::RequestAccess` (tenant sets × seat × scope × ordinals × named action gates, stamped with schema, policy, seat and role versions) and the first output path `hubspo_compat::contacts::{lookup, check_search}`; all six negative tests plus seat/role invalidation; ten disable runs red. Open: store read for D-API-1, the other output paths. | | 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-compat/Cargo.toml b/crates/hubspo-compat/Cargo.toml index 6caeea2..8be65d5 100644 --- a/crates/hubspo-compat/Cargo.toml +++ b/crates/hubspo-compat/Cargo.toml @@ -8,5 +8,16 @@ license.workspace = true repository.workspace = true publish = false +[dependencies] +hubspo-iam = { path = "../hubspo-iam" } +hubspo-query = { path = "../hubspo-query" } +lance-graph-contract.workspace = true + +[dev-dependencies] +hubspo-generated = { path = "../hubspo-generated" } +hubspo-port = { path = "../hubspo-port" } +ogar-dir-core.workspace = true +ogar-vocab.workspace = true + [lints] workspace = true diff --git a/crates/hubspo-compat/src/contacts.rs b/crates/hubspo-compat/src/contacts.rs new file mode 100644 index 0000000..d45bd7a --- /dev/null +++ b/crates/hubspo-compat/src/contacts.rs @@ -0,0 +1,355 @@ +//! The first authorized output path (D-IAM-3): a contact by id. +//! +//! Everything a caller can learn passes through one compiled +//! [`RequestAccess`] before a value is shaped for output: +//! +//! 1. the stamp is checked first, so stale grants answer nothing; +//! 2. a record the request may not see is [`LookupError::NotFound`], the same +//! answer as a record that does not exist: no read grant, another +//! tenant's record, or an owner outside the request's rows; +//! 3. the values are projected by ordinal, so a hidden property is absent +//! from the output, not blanked; the packed run never leaves as a run; +//! 4. only then are ordinals named. +//! +//! [`check_search`] is the same gate for a search: every property a filter +//! or a sort reads must be readable, or the search is refused before it is +//! compiled, since which rows match would reveal the hidden values. +//! +//! The caller supplies the record's values as `(ordinal, value)` pairs; the +//! store read that produces them lands with D-API-1. + +use hubspo_iam::access::RequestAccess; +use hubspo_iam::fields::{Refusal, Schema, Stamp}; +use hubspo_iam::tenant::TenantId; +use hubspo_query::filter::FilterGroup; +use lance_graph_contract::rbac::OpMask; + +/// One stored record as the store hands it over. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Record { + /// The tenant the record belongs to. + pub tenant: TenantId, + /// The owner code on the owner lane; `None` when unassigned. + pub owner: Option, + /// Every stored property value, by ordinal. + pub values: Vec<(u16, V)>, +} + +/// Why a lookup returned no record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LookupError { + /// No such record, or one the request may not see. + NotFound, + /// The access was compiled for another stamp. + Refused(Refusal), + /// `schema` is not the one the access was compiled for, so its ordinals + /// cannot be named. + SchemaMismatch, +} + +impl From for LookupError { + fn from(r: Refusal) -> Self { + LookupError::Refused(r) + } +} + +fn same_schema(access: &RequestAccess, schema: &Schema) -> bool { + let stamp = access.stamp(); + stamp.concept == schema.concept && stamp.schema == schema.version +} + +/// The readable properties of `record`, by internal name, in record order. +/// +/// # Errors +/// +/// See [`LookupError`]. +pub fn lookup( + access: &RequestAccess, + current: &Stamp, + schema: &Schema, + record: Option>, +) -> Result, LookupError> { + access.check(current)?; + if !same_schema(access, schema) { + return Err(LookupError::SchemaMismatch); + } + if !access.may(current, OpMask::READ)? { + return Err(LookupError::NotFound); + } + let record = record.ok_or(LookupError::NotFound)?; + if record.tenant != access.tenant() || !access.rows(current)?.admits(record.owner) { + return Err(LookupError::NotFound); + } + let visible = access + .projection(current)? + .project(current, record.values)?; + Ok(visible + .into_iter() + .filter_map(|(o, v)| schema.property(o).map(|p| (p.name, v))) + .collect()) +} + +/// Why a search was refused before it ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SearchRefusal { + /// A filter or sort names no property of the schema. + UnknownProperty(String), + /// A filter or sort reads a property the request may not read, or the + /// access is stale. + Refused(Refusal), + /// See [`LookupError::SchemaMismatch`]. + SchemaMismatch, + /// The request holds no read on the concept. + NoRead, +} + +/// Check a search's filter groups and sort properties against the request's +/// access before the search is compiled. +/// +/// # Errors +/// +/// See [`SearchRefusal`]. +pub fn check_search( + access: &RequestAccess, + current: &Stamp, + schema: &Schema, + groups: &[FilterGroup], + sorts: &[&str], +) -> Result<(), SearchRefusal> { + access.check(current).map_err(SearchRefusal::Refused)?; + if !same_schema(access, schema) { + return Err(SearchRefusal::SchemaMismatch); + } + if !access + .may(current, OpMask::READ) + .map_err(SearchRefusal::Refused)? + { + return Err(SearchRefusal::NoRead); + } + let names = groups + .iter() + .flat_map(|g| g.filters.iter().map(|f| f.property.as_str())) + .chain(sorts.iter().copied()); + let mut reads = Vec::new(); + for name in names { + reads.push( + schema + .ordinal(name) + .ok_or_else(|| SearchRefusal::UnknownProperty(name.to_string()))?, + ); + } + access + .require(current, &reads) + .map_err(SearchRefusal::Refused) +} + +#[cfg(test)] +mod tests { + use super::*; + use hubspo_generated::standard_properties::{CONTACT, PACKED_RUN_POSITION, Slot}; + use hubspo_iam::fields::{OrdinalSet, PolicyVersion, RoleVersion, SchemaVersion, SeatVersion}; + use hubspo_iam::scope::{OwnerScope, RowScope}; + use hubspo_iam::tenant::{Assignee, PermissionSet, PermissionSetId, Seat, TenantPolicy}; + use hubspo_port::{ROLE_SUPER_ADMIN, ROLE_VIEW_ONLY}; + use hubspo_query::filter::{Operator, PropertyFilter}; + use lance_graph_contract::class_view::WideFieldMask; + use lance_graph_contract::rbac::ClassGrant; + use ogar_dir_core::Guid128; + use ogar_vocab::class_ids; + + const T: TenantId = TenantId(3); + const C: u16 = class_ids::CRM_CONTACT; + const ME: Guid128 = Guid128([9; 16]); + const MINE: u32 = 42; + + fn contact() -> Schema { + Schema { + concept: C, + version: SchemaVersion(1), + properties: CONTACT, + } + } + + fn ord(name: &str) -> u16 { + contact().ordinal(name).unwrap() + } + + fn every_field() -> WideFieldMask { + let lanes = CONTACT.iter().filter_map(|p| match p.slot { + Slot::Lane(n) => Some(n), + Slot::Packed => None, + }); + WideFieldMask::from_positions( + &std::iter::once(PACKED_RUN_POSITION) + .chain(lanes) + .collect::>(), + ) + } + + /// View-only reads email and firstname on contacts it owns. + fn policy() -> TenantPolicy { + let mut props = OrdinalSet::new(); + props.insert(ord("email")); + props.insert(ord("firstname")); + let mut p = TenantPolicy::new(T, PolicyVersion(1)); + p.add_set( + PermissionSetId(10), + PermissionSet { + grants: vec![ClassGrant::new(C, OpMask::READ)], + scopes: vec![(C, OwnerScope::Owned)], + fields: vec![(C, every_field())], + properties: vec![(C, props)], + ..PermissionSet::default() + }, + ) + .unwrap(); + p.assign(Assignee::Role(ROLE_VIEW_ONLY), PermissionSetId(10)) + .unwrap(); + p + } + + fn seat() -> Seat { + Seat { + version: SeatVersion(1), + concepts: vec![C], + actions: Vec::new(), + } + } + + fn access(p: &TenantPolicy, role: &'static str) -> (RequestAccess, Stamp) { + let a = RequestAccess::compile( + p, + T, + &seat(), + &ME, + &[role], + RoleVersion(1), + &contact(), + |_| RowScope::Rows { + owners: vec![MINE], + unassigned: false, + }, + ) + .unwrap(); + let now = RequestAccess::current_stamp(p, &contact(), &seat(), RoleVersion(1)); + (a, now) + } + + /// A record carrying every contact property, value = its name. + fn record(tenant: TenantId, owner: Option) -> Option> { + Some(Record { + tenant, + owner, + values: CONTACT.iter().map(|p| (p.position, p.name)).collect(), + }) + } + + #[test] + fn a_visible_contact_carries_only_its_granted_properties() { + let p = policy(); + let (a, now) = access(&p, ROLE_VIEW_ONLY); + let out = lookup(&a, &now, &contact(), record(T, Some(MINE))).unwrap(); + let mut names: Vec<&str> = out.iter().map(|(n, _)| *n).collect(); + names.sort_unstable(); + assert_eq!(names, ["email", "firstname"]); + assert!( + out.iter().all(|(n, v)| n == v), + "values travel with their names" + ); + assert!( + !names.contains(&"date_of_birth"), + "no packed-run property leaks" + ); + } + + #[test] + fn an_invisible_record_is_indistinguishable_from_a_missing_one() { + let p = policy(); + let (a, now) = access(&p, ROLE_VIEW_ONLY); + for rec in [ + None, + record(TenantId(4), Some(MINE)), + record(T, Some(MINE + 1)), + record(T, None), + ] { + assert_eq!( + lookup(&a, &now, &contact(), rec), + Err(LookupError::NotFound) + ); + } + } + + #[test] + fn a_role_with_no_set_sees_nothing_even_super_admin() { + let p = policy(); + let (a, now) = access(&p, ROLE_SUPER_ADMIN); + assert_eq!( + lookup(&a, &now, &contact(), record(T, Some(MINE))), + Err(LookupError::NotFound) + ); + assert_eq!( + check_search(&a, &now, &contact(), &[], &["email"]), + Err(SearchRefusal::NoRead) + ); + } + + #[test] + fn a_revoked_set_refuses_the_old_access() { + let mut p = policy(); + let (a, _) = access(&p, ROLE_VIEW_ONLY); + p.unassign(Assignee::Role(ROLE_VIEW_ONLY), PermissionSetId(10)); + let now = RequestAccess::current_stamp(&p, &contact(), &seat(), RoleVersion(1)); + assert!(matches!( + lookup(&a, &now, &contact(), record(T, Some(MINE))), + Err(LookupError::Refused(Refusal::Stale { .. })) + )); + } + + #[test] + fn another_schema_or_concept_is_refused() { + let p = policy(); + let (a, now) = access(&p, ROLE_VIEW_ONLY); + for schema in [ + Schema { + version: SchemaVersion(2), + ..contact() + }, + Schema { + concept: class_ids::CRM_DEAL, + ..contact() + }, + ] { + assert_eq!( + lookup(&a, &now, &schema, record(T, Some(MINE))), + Err(LookupError::SchemaMismatch) + ); + } + } + + #[test] + fn a_filter_or_sort_on_a_hidden_property_is_refused() { + let p = policy(); + let (a, now) = access(&p, ROLE_VIEW_ONLY); + let group = |prop: &str| FilterGroup { + filters: vec![PropertyFilter::new(prop, Operator::Eq, "x")], + }; + assert_eq!( + check_search(&a, &now, &contact(), &[group("email")], &["firstname"]), + Ok(()) + ); + assert_eq!( + check_search(&a, &now, &contact(), &[group("date_of_birth")], &[]), + Err(SearchRefusal::Refused(Refusal::Hidden(ord( + "date_of_birth" + )))) + ); + assert_eq!( + check_search(&a, &now, &contact(), &[group("email")], &["city"]), + Err(SearchRefusal::Refused(Refusal::Hidden(ord("city")))) + ); + assert_eq!( + check_search(&a, &now, &contact(), &[group("no_such")], &[]), + Err(SearchRefusal::UnknownProperty("no_such".into())) + ); + } +} diff --git a/crates/hubspo-compat/src/lib.rs b/crates/hubspo-compat/src/lib.rs index de0a9f4..3dd59a9 100644 --- a/crates/hubspo-compat/src/lib.rs +++ b/crates/hubspo-compat/src/lib.rs @@ -4,5 +4,8 @@ //! //! - Zone: zone 3 (wire formats allowed). //! - Deliverables: D-API-1..5, D-STO-6 (`.claude/plans/hubspo-integration-v1.md`). -//! - First real code lands in wave W11. Until then this crate is empty on -//! purpose; Wave 0 only fixes the workspace shape. +//! - [`contacts`]: the first authorized output path (D-IAM-3): one contact +//! by id, and the gate a search's filters and sorts pass before they run. +//! The REST wire format around it lands with D-API-1. + +pub mod contacts; diff --git a/crates/hubspo-iam/src/access.rs b/crates/hubspo-iam/src/access.rs new file mode 100644 index 0000000..bb46376 --- /dev/null +++ b/crates/hubspo-iam/src/access.rs @@ -0,0 +1,671 @@ +//! D-IAM-3: one request's effective access to one concept, compiled once. +//! +//! [`RequestAccess::compile`] reads the tenant's permission sets +//! ([`crate::tenant`]) for the principal and its resolved roles and folds +//! them into everything an output path needs before it touches a value: +//! +//! - the rows it may see ([`RowScope`], D-IAM-2); +//! - the properties it may read ([`Projection`], by stable ordinal); +//! - the plain verbs it holds (`READ`, `WRITE`, `CREATE`); +//! - the named actions it may perform (`ACT`, `DELETE`, `EXPORT`), each +//! needing its verb **and** a named gate in the same set **and** the seat. +//! +//! The result is stamped with the concept, its schema version, the policy's +//! version and generation, the seat version and the role-resolution +//! version. Every check presents the current stamp; any change in between +//! (a grant edited, a set revoked, a schema bumped, a seat changed, a group +//! re-read) refuses the request with [`Refusal::Stale`] instead of +//! answering from the old grants. +//! +//! Nothing here keys on a role name: a super admin holds what its sets +//! give it, and a role with no set holds nothing. + +use crate::fields::{Projection, Refusal, RoleVersion, Schema, Stamp}; +use crate::scope::{OwnerScope, RowScope}; +use crate::tenant::{Seat, TenantId, TenantPolicy}; +use lance_graph_contract::rbac::{OpMask, RoleId}; +use ogar_dir_core::Guid128; + +/// Why access could not be compiled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccessError { + /// The policy belongs to another tenant than the request. + WrongTenant { + /// The policy's tenant. + policy: TenantId, + /// The request's tenant. + request: TenantId, + }, +} + +/// The verbs [`RequestAccess::may`] answers. `ACT` and `DELETE` are only +/// ever answered by [`RequestAccess::allows`], with a named gate. +const PLAIN: OpMask = OpMask::READ.union(OpMask::WRITE).union(OpMask::CREATE); + +/// One request's compiled access to one concept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestAccess { + tenant: TenantId, + stamp: Stamp, + rows: RowScope, + verbs: OpMask, + actions: Vec<&'static str>, + projection: Projection, +} + +impl RequestAccess { + /// The stamp a request presents now: compare it with the compiled one. + #[must_use] + pub fn current_stamp( + policy: &TenantPolicy, + schema: &Schema, + seat: &Seat, + roles: RoleVersion, + ) -> Stamp { + Stamp { + concept: schema.concept, + schema: schema.version, + inner: policy.generation(), + outer: policy.version(), + seat: seat.version, + roles, + } + } + + /// Compile `principal`'s access to `schema`'s concept. + /// + /// `roles` are the principal's roles as resolved for this request + /// (`crate::roles::request_actors`), at resolution version + /// `role_version`. `row_scope` turns the broadest ownership scope the + /// granting sets give into rows (`crate::scope::visible_owners`); it is + /// not called when no set grants a read. + /// + /// A concept the seat does not reach, or one no assigned set grants, + /// compiles to no rows, no properties and no verbs. + /// + /// # Errors + /// + /// [`AccessError::WrongTenant`] when `policy` is another tenant's. + #[expect( + clippy::too_many_arguments, + reason = "one argument per input the access is compiled from" + )] + pub fn compile( + policy: &TenantPolicy, + tenant: TenantId, + seat: &Seat, + principal: &Guid128, + roles: &[RoleId], + role_version: RoleVersion, + schema: &Schema, + row_scope: impl FnOnce(OwnerScope) -> RowScope, + ) -> Result { + if policy.tenant() != tenant { + return Err(AccessError::WrongTenant { + policy: policy.tenant(), + request: tenant, + }); + } + let concept = schema.concept; + let stamp = Self::current_stamp(policy, schema, seat, role_version); + let sets = if seat.concepts.contains(&concept) { + policy.sets_for(principal, roles) + } else { + Vec::new() + }; + + let verbs_of = |set: &crate::tenant::PermissionSet| { + set.grants + .iter() + .filter(|g| g.target_classid == concept) + .fold(OpMask::NONE, |acc, g| acc.union(g.op_mask)) + }; + let readers: Vec<_> = sets + .iter() + .copied() + .filter(|s| verbs_of(s).contains(OpMask::READ)) + .collect(); + + let verbs = sets + .iter() + .fold(OpMask::NONE, |acc, s| acc.union(verbs_of(s))); + + let broadest = readers + .iter() + .filter_map(|s| s.scope(concept)) + .max_by_key(|scope| match scope { + OwnerScope::Owned => 0, + OwnerScope::Team => 1, + OwnerScope::Everything => 2, + }); + let rows = broadest.map_or(RowScope::Nothing, row_scope); + + let projection = Projection::compile_holders( + schema, + readers.iter().map(|s| s.property_holder(concept)), + stamp, + ); + + let mut actions: Vec<&'static str> = sets + .iter() + .flat_map(|s| { + s.actions + .iter() + .filter(|a| a.concept == concept && s.allows_action(concept, a.action)) + .map(|a| a.action) + }) + .filter(|a| seat.actions.contains(a)) + .collect(); + actions.sort_unstable(); + actions.dedup(); + + Ok(Self { + tenant, + stamp, + rows, + verbs, + actions, + projection, + }) + } + + /// The tenant the access was compiled for. + #[must_use] + pub fn tenant(&self) -> TenantId { + self.tenant + } + + /// What the access is valid for. + #[must_use] + pub fn stamp(&self) -> Stamp { + self.stamp + } + + /// Refuse a request presenting another stamp. + /// + /// # Errors + /// + /// [`Refusal::Stale`] when `current` differs from the compiled stamp. + pub fn check(&self, current: &Stamp) -> Result<(), Refusal> { + if *current == self.stamp { + Ok(()) + } else { + Err(Refusal::Stale { + compiled: self.stamp, + current: *current, + }) + } + } + + /// The rows the request may see. + /// + /// # Errors + /// + /// [`Refusal::Stale`]. + pub fn rows(&self, current: &Stamp) -> Result<&RowScope, Refusal> { + self.check(current)?; + Ok(&self.rows) + } + + /// The readable properties. + /// + /// # Errors + /// + /// [`Refusal::Stale`]. + pub fn projection(&self, current: &Stamp) -> Result<&Projection, Refusal> { + self.check(current)?; + Ok(&self.projection) + } + + /// Whether the request holds the plain verbs `verb` (any of `READ`, + /// `WRITE`, `CREATE`). Always `false` for a mask containing `ACT` or + /// `DELETE`: those are named actions, answered by [`Self::allows`]. + /// + /// # Errors + /// + /// [`Refusal::Stale`]. + pub fn may(&self, current: &Stamp, verb: OpMask) -> Result { + self.check(current)?; + Ok(PLAIN.contains(verb) && self.verbs.contains(verb)) + } + + /// Whether the request may perform `action` (`email.send`, + /// `crate::tenant::ACTION_DELETE`, `crate::tenant::ACTION_EXPORT`, ...): + /// one set holds both its verb and its named gate, and the seat allows + /// it. + /// + /// # Errors + /// + /// [`Refusal::Stale`]. + pub fn allows(&self, current: &Stamp, action: &str) -> Result { + self.check(current)?; + Ok(self.actions.contains(&action)) + } + + /// Check the properties a filter, sort or aggregate reads; a hidden one + /// is refused, never evaluated. + /// + /// # Errors + /// + /// [`Refusal::Stale`], or the first unknown or hidden ordinal. + pub fn require(&self, current: &Stamp, reads: &[u16]) -> Result<(), Refusal> { + self.check(current)?; + self.projection.require(current, reads) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fields::{OrdinalSet, PolicyVersion, SchemaVersion, SeatVersion}; + use crate::tenant::{ + ACTION_DELETE, ACTION_EXPORT, ActionGrant, Assignee, PermissionSet, PermissionSetId, + }; + use hubspo_generated::standard_properties::{CONTACT, PACKED_RUN_POSITION, Slot}; + use hubspo_port::{ROLE_SALES_USER, ROLE_SUPER_ADMIN, ROLE_VIEW_ONLY}; + use lance_graph_contract::class_view::WideFieldMask; + use lance_graph_contract::rbac::ClassGrant; + use ogar_vocab::class_ids; + + const T: TenantId = TenantId(7); + const C: u16 = class_ids::CRM_CONTACT; + const ME: Guid128 = Guid128([1; 16]); + + fn contact() -> Schema { + Schema { + concept: C, + version: SchemaVersion(1), + properties: CONTACT, + } + } + + fn ord(name: &str) -> u16 { + contact().ordinal(name).unwrap() + } + + fn every_field() -> WideFieldMask { + let lanes = CONTACT.iter().filter_map(|p| match p.slot { + Slot::Lane(n) => Some(n), + Slot::Packed => None, + }); + WideFieldMask::from_positions( + &std::iter::once(PACKED_RUN_POSITION) + .chain(lanes) + .collect::>(), + ) + } + + fn ordinals(names: &[&str]) -> OrdinalSet { + let mut s = OrdinalSet::new(); + for n in names { + s.insert(ord(n)); + } + s + } + + fn reader(op: OpMask, scope: OwnerScope, props: &[&str]) -> PermissionSet { + PermissionSet { + grants: vec![ClassGrant::new(C, op)], + scopes: vec![(C, scope)], + fields: vec![(C, every_field())], + properties: vec![(C, ordinals(props))], + ..PermissionSet::default() + } + } + + fn seat(actions: &[&'static str]) -> Seat { + Seat { + version: SeatVersion(1), + concepts: vec![C], + actions: actions.to_vec(), + } + } + + fn policy_with(set: PermissionSet, to: Assignee) -> TenantPolicy { + let mut p = TenantPolicy::new(T, PolicyVersion(1)); + p.add_set(PermissionSetId(1), set).unwrap(); + p.assign(to, PermissionSetId(1)).unwrap(); + p + } + + fn owned_rows(scope: OwnerScope) -> RowScope { + match scope { + OwnerScope::Everything => RowScope::All, + _ => RowScope::Rows { + owners: vec![42], + unassigned: false, + }, + } + } + + fn compile(policy: &TenantPolicy, seat: &Seat, roles: &[RoleId]) -> RequestAccess { + RequestAccess::compile( + policy, + T, + seat, + &ME, + roles, + RoleVersion(1), + &contact(), + owned_rows, + ) + .unwrap() + } + + fn now(policy: &TenantPolicy, seat: &Seat) -> Stamp { + RequestAccess::current_stamp(policy, &contact(), seat, RoleVersion(1)) + } + + #[test] + fn an_assigned_set_grants_its_rows_properties_and_verbs() { + let p = policy_with( + reader( + OpMask::READ.union(OpMask::WRITE), + OwnerScope::Owned, + &["email", "city"], + ), + Assignee::Role(ROLE_SALES_USER), + ); + let s = seat(&[]); + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let cur = now(&p, &s); + assert_eq!( + a.rows(&cur).unwrap(), + &RowScope::Rows { + owners: vec![42], + unassigned: false + } + ); + assert!(a.may(&cur, OpMask::READ).unwrap()); + assert!(a.may(&cur, OpMask::WRITE).unwrap()); + assert!(!a.may(&cur, OpMask::CREATE).unwrap()); + assert_eq!(a.require(&cur, &[ord("email"), ord("city")]), Ok(())); + assert_eq!( + a.require(&cur, &[ord("date_of_birth")]), + Err(Refusal::Hidden(ord("date_of_birth"))) + ); + } + + #[test] + fn a_missing_policy_grants_nothing() { + let p = TenantPolicy::new(T, PolicyVersion(1)); + let s = seat(&[ACTION_EXPORT]); + for role in [ROLE_SUPER_ADMIN, ROLE_SALES_USER, ROLE_VIEW_ONLY] { + let a = compile(&p, &s, &[role]); + let cur = now(&p, &s); + assert_eq!(a.rows(&cur).unwrap(), &RowScope::Nothing, "{role}"); + assert!(!a.may(&cur, OpMask::READ).unwrap(), "{role}"); + assert!(!a.allows(&cur, ACTION_EXPORT).unwrap(), "{role}"); + assert_eq!( + a.require(&cur, &[ord("email")]), + Err(Refusal::Hidden(ord("email"))), + "{role}" + ); + } + } + + #[test] + fn super_admin_is_not_a_bypass() { + // A set assigned to sales; the super admin holds no set. + let p = policy_with( + reader(OpMask::READ, OwnerScope::Everything, &["email"]), + Assignee::Role(ROLE_SALES_USER), + ); + let s = seat(&[]); + let a = compile(&p, &s, &[ROLE_SUPER_ADMIN]); + let cur = now(&p, &s); + assert_eq!(a.rows(&cur).unwrap(), &RowScope::Nothing); + assert!(!a.may(&cur, OpMask::READ).unwrap()); + // The same principal holding sales gets exactly sales' grants. + let b = compile(&p, &s, &[ROLE_SUPER_ADMIN, ROLE_SALES_USER]); + assert_eq!(b.rows(&cur).unwrap(), &RowScope::All); + } + + #[test] + fn view_only_reads_no_property_it_was_not_given() { + // Outer mask admits every field; the inner grant leaves the personal + // properties out. Nothing sensitive is implied by the role or by READ. + let p = policy_with( + reader( + OpMask::READ, + OwnerScope::Everything, + &["email", "firstname"], + ), + Assignee::Role(ROLE_VIEW_ONLY), + ); + let s = seat(&[]); + let a = compile(&p, &s, &[ROLE_VIEW_ONLY]); + let cur = now(&p, &s); + let row: Vec<(u16, u16)> = CONTACT.iter().map(|q| (q.position, q.position)).collect(); + let seen = a.projection(&cur).unwrap().project(&cur, row).unwrap(); + let mut want = vec![ord("email"), ord("firstname")]; + want.sort_unstable(); + let mut got: Vec = seen.iter().map(|(o, _)| *o).collect(); + got.sort_unstable(); + assert_eq!(got, want); + assert!(!a.may(&cur, OpMask::WRITE).unwrap()); + } + + #[test] + fn act_delete_and_export_need_a_named_gate_and_the_seat() { + let full = OpMask::READ + .union(OpMask::WRITE) + .union(OpMask::CREATE) + .union(OpMask::ACT) + .union(OpMask::DELETE); + let mut set = reader(full, OwnerScope::Everything, &["email"]); + let p_no_gate = policy_with(set.clone(), Assignee::Role(ROLE_SALES_USER)); + let s_all = seat(&["email.send", ACTION_DELETE, ACTION_EXPORT]); + let a = compile(&p_no_gate, &s_all, &[ROLE_SALES_USER]); + let cur = now(&p_no_gate, &s_all); + for action in ["email.send", ACTION_DELETE, ACTION_EXPORT] { + assert!(!a.allows(&cur, action).unwrap(), "{action}: verbs alone"); + } + assert!(!a.may(&cur, OpMask::DELETE).unwrap(), "DELETE is not plain"); + assert!(!a.may(&cur, OpMask::ACT).unwrap(), "ACT is not plain"); + + set.actions = ["email.send", ACTION_DELETE, ACTION_EXPORT] + .into_iter() + .map(|action| ActionGrant { concept: C, action }) + .collect(); + let p = policy_with(set, Assignee::Role(ROLE_SALES_USER)); + // Gated, but the seat allows only the email send. + let s = seat(&["email.send"]); + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let cur = now(&p, &s); + assert!(a.allows(&cur, "email.send").unwrap()); + assert!(!a.allows(&cur, ACTION_DELETE).unwrap()); + assert!(!a.allows(&cur, ACTION_EXPORT).unwrap()); + assert!(!a.allows(&cur, "sequence.enroll").unwrap()); + // Seat and gate both: allowed. + let a = compile(&p, &s_all, &[ROLE_SALES_USER]); + let cur = now(&p, &s_all); + assert!(a.allows(&cur, ACTION_DELETE).unwrap()); + assert!(a.allows(&cur, ACTION_EXPORT).unwrap()); + } + + #[test] + fn write_does_not_imply_act_or_delete() { + let mut set = reader( + OpMask::READ.union(OpMask::WRITE).union(OpMask::CREATE), + OwnerScope::Everything, + &["email"], + ); + set.actions = vec![ + ActionGrant { + concept: C, + action: "email.send", + }, + ActionGrant { + concept: C, + action: ACTION_DELETE, + }, + ]; + let p = policy_with(set, Assignee::Role(ROLE_SALES_USER)); + let s = seat(&["email.send", ACTION_DELETE]); + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let cur = now(&p, &s); + assert!(!a.allows(&cur, "email.send").unwrap()); + assert!(!a.allows(&cur, ACTION_DELETE).unwrap()); + } + + #[test] + fn revocation_refuses_the_old_access() { + let mut p = policy_with( + reader(OpMask::READ, OwnerScope::Everything, &["email"]), + Assignee::Principal(ME), + ); + let s = seat(&[]); + let a = compile(&p, &s, &[]); + assert_eq!(a.require(&now(&p, &s), &[ord("email")]), Ok(())); + p.unassign(Assignee::Principal(ME), PermissionSetId(1)); + let cur = now(&p, &s); + assert!(matches!( + a.require(&cur, &[ord("email")]), + Err(Refusal::Stale { .. }) + )); + assert!(matches!(a.rows(&cur), Err(Refusal::Stale { .. }))); + // Recompiled: nothing. + let b = compile(&p, &s, &[]); + assert_eq!(b.rows(&cur).unwrap(), &RowScope::Nothing); + } + + #[test] + fn a_seat_role_or_schema_change_refuses_the_old_access() { + let p = policy_with( + reader(OpMask::READ, OwnerScope::Everything, &["email"]), + Assignee::Role(ROLE_SALES_USER), + ); + let s = seat(&[]); + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let base = now(&p, &s); + assert_eq!(a.check(&base), Ok(())); + let reseated = Seat { + version: SeatVersion(2), + ..s.clone() + }; + let changes = [ + now(&p, &reseated), + RequestAccess::current_stamp(&p, &contact(), &s, RoleVersion(2)), + RequestAccess::current_stamp( + &p, + &Schema { + version: SchemaVersion(2), + ..contact() + }, + &s, + RoleVersion(1), + ), + RequestAccess::current_stamp( + &p, + &Schema { + concept: class_ids::CRM_DEAL, + ..contact() + }, + &s, + RoleVersion(1), + ), + ]; + for cur in changes { + assert!( + matches!(a.check(&cur), Err(Refusal::Stale { .. })), + "{cur:?}" + ); + assert!(matches!( + a.may(&cur, OpMask::READ), + Err(Refusal::Stale { .. }) + )); + } + } + + #[test] + fn a_seat_that_does_not_reach_the_concept_grants_nothing() { + let p = policy_with( + reader(OpMask::READ, OwnerScope::Everything, &["email"]), + Assignee::Role(ROLE_SALES_USER), + ); + let s = Seat { + concepts: vec![class_ids::CRM_DEAL], + ..seat(&[]) + }; + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let cur = now(&p, &s); + assert_eq!(a.rows(&cur).unwrap(), &RowScope::Nothing); + assert!(!a.may(&cur, OpMask::READ).unwrap()); + } + + #[test] + fn another_tenants_policy_is_refused() { + let p = TenantPolicy::new(TenantId(8), PolicyVersion(1)); + assert_eq!( + RequestAccess::compile( + &p, + T, + &seat(&[]), + &ME, + &[], + RoleVersion(1), + &contact(), + owned_rows + ), + Err(AccessError::WrongTenant { + policy: TenantId(8), + request: T + }) + ); + } + + #[test] + fn a_grant_on_another_concept_does_not_reach_this_one() { + let mut set = reader(OpMask::READ, OwnerScope::Everything, &["email"]); + set.grants = vec![ClassGrant::new(class_ids::CRM_DEAL, OpMask::READ)]; + let p = policy_with(set, Assignee::Role(ROLE_SALES_USER)); + let s = seat(&[]); + let a = compile(&p, &s, &[ROLE_SALES_USER]); + let cur = now(&p, &s); + assert_eq!(a.rows(&cur).unwrap(), &RowScope::Nothing); + assert_eq!( + a.require(&cur, &[ord("email")]), + Err(Refusal::Hidden(ord("email"))) + ); + } + + #[test] + fn the_broadest_scope_of_the_reading_sets_wins() { + let mut p = TenantPolicy::new(T, PolicyVersion(1)); + p.add_set( + PermissionSetId(1), + reader(OpMask::READ, OwnerScope::Owned, &["email"]), + ) + .unwrap(); + // A broader scope on a set without READ does not widen the rows. + p.add_set( + PermissionSetId(2), + reader(OpMask::WRITE, OwnerScope::Everything, &[]), + ) + .unwrap(); + p.assign(Assignee::Principal(ME), PermissionSetId(1)) + .unwrap(); + p.assign(Assignee::Principal(ME), PermissionSetId(2)) + .unwrap(); + let s = seat(&[]); + let a = compile(&p, &s, &[]); + let cur = now(&p, &s); + assert_eq!( + a.rows(&cur).unwrap(), + &RowScope::Rows { + owners: vec![42], + unassigned: false + } + ); + p.add_set( + PermissionSetId(3), + reader(OpMask::READ, OwnerScope::Everything, &[]), + ) + .unwrap(); + p.assign(Assignee::Principal(ME), PermissionSetId(3)) + .unwrap(); + let a = compile(&p, &s, &[]); + assert_eq!(a.rows(&now(&p, &s)).unwrap(), &RowScope::All); + } +} diff --git a/crates/hubspo-iam/src/fields.rs b/crates/hubspo-iam/src/fields.rs index 62463af..e88558e 100644 --- a/crates/hubspo-iam/src/fields.rs +++ b/crates/hubspo-iam/src/fields.rs @@ -75,7 +75,9 @@ impl Schema { .map(|p| p.position) } - fn property(&self, ordinal: u16) -> Option<&StandardProperty> { + /// The property at `ordinal`, if the schema has one. + #[must_use] + pub fn property(&self, ordinal: u16) -> Option<&StandardProperty> { self.properties .get(usize::from(ordinal)) .filter(|p| p.position == ordinal) @@ -243,10 +245,39 @@ pub struct Stamp { pub inner: u64, /// The outer policy version. pub outer: PolicyVersion, + /// The version of the principal's seat (the ceiling on what any grant + /// can unlock). [`SeatVersion::NONE`] where no seat model applies. + pub seat: SeatVersion, + /// The version of the principal's role resolution (the directory + /// snapshot and the group-role policy it was read from). + /// [`RoleVersion::NONE`] where roles are passed in directly. + pub roles: RoleVersion, +} + +/// The version of a request's role resolution. Changing a group's grants or +/// a principal's membership invalidates every projection compiled under the +/// old roles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct RoleVersion(pub u64); + +impl RoleVersion { + /// Roles passed in directly, not resolved from a directory. + pub const NONE: Self = Self(0); +} + +/// The version of a principal's seat assignment. Changing the seat +/// invalidates every projection compiled under the old one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct SeatVersion(pub u32); + +impl SeatVersion { + /// No seat model: the role-keyed path of this module. + pub const NONE: Self = Self(0); } impl Stamp { - /// The current stamp for `schema` under `inner` and `outer`. + /// The current stamp for `schema` under `inner` and `outer`, with no + /// seat model ([`SeatVersion::NONE`]). #[must_use] pub fn of(schema: &Schema, inner: &PropertyGrants, outer: PolicyVersion) -> Self { Self { @@ -254,8 +285,22 @@ impl Stamp { schema: schema.version, inner: inner.generation(), outer, + seat: SeatVersion::NONE, + roles: RoleVersion::NONE, } } + + /// The same stamp under `seat`. + #[must_use] + pub fn with_seat(self, seat: SeatVersion) -> Self { + Self { seat, ..self } + } + + /// The same stamp under role resolution `roles`. + #[must_use] + pub fn with_roles(self, roles: RoleVersion) -> Self { + Self { roles, ..self } + } } /// Why a read was refused. @@ -299,10 +344,28 @@ impl Projection { outer: impl Fn(RoleId) -> Option, policy: PolicyVersion, inner: &PropertyGrants, + ) -> Self { + let holders = roles + .iter() + .map(|&role| (outer(role), inner.of(role, schema.concept))); + Self::compile_holders(schema, holders, Stamp::of(schema, inner, policy)) + } + + /// Compile from grant holders directly: each holder is one role or one + /// permission set, given as its outer mask and its inner ordinals on the + /// concept. A property is readable when one holder admits it on both + /// masks; a holder missing either admits nothing. `stamp` is what the + /// projection is valid for; its concept and schema must be `schema`'s. + #[must_use] + pub fn compile_holders<'a>( + schema: &Schema, + holders: impl IntoIterator, Option<&'a OrdinalSet>)>, + stamp: Stamp, ) -> Self { let mut readable = OrdinalSet::new(); - for &role in roles { - let (Some(mask), Some(granted)) = (outer(role), inner.of(role, schema.concept)) else { + let consistent = stamp.concept == schema.concept && stamp.schema == schema.version; + for (mask, granted) in holders { + let (Some(mask), Some(granted), true) = (mask, granted, consistent) else { continue; }; for p in schema.properties { @@ -316,7 +379,7 @@ impl Projection { } } Self { - stamp: Stamp::of(schema, inner, policy), + stamp, known: u16::try_from(schema.properties.len()).unwrap_or(u16::MAX), readable, } diff --git a/crates/hubspo-iam/src/lib.rs b/crates/hubspo-iam/src/lib.rs index 3693f97..c8a7c56 100644 --- a/crates/hubspo-iam/src/lib.rs +++ b/crates/hubspo-iam/src/lib.rs @@ -4,14 +4,20 @@ //! //! - Zone: zone 2 (no serde, no wire formats). //! - Deliverables: D-IAM-1..7 (`.claude/plans/hubspo-integration-v1.md`). +//! - [`access`]: D-IAM-3, one request's effective access to one concept, +//! compiled once from the tenant's permission sets and stamped. //! - [`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. +//! - [`roles`]: D-IAM-1, the six role names bound to OGAR with no grants, +//! the preset verb table as a fixture, 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. +//! - [`tenant`]: the tenant's permission sets, versioned data with stable +//! ids assigned to roles or principals, and the seat ceiling. +pub mod access; pub mod fields; pub mod roles; pub mod scope; +pub mod tenant; diff --git a/crates/hubspo-iam/src/roles.rs b/crates/hubspo-iam/src/roles.rs index 59d314b..34a3655 100644 --- a/crates/hubspo-iam/src/roles.rs +++ b/crates/hubspo-iam/src/roles.rs @@ -1,34 +1,43 @@ -//! D-IAM-1: HubSpot's preset roles as grants over the plugged concepts. +//! D-IAM-1: the six preset role names, bound to OGAR's authority, and the +//! request's roles read from the directory. //! -//! [`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. +//! ## A role name grants nothing //! -//! ## What this is not +//! Operator ruling 2026-10-21 (`ISS-PRESET-VERBS-PROVISIONAL`): a role name +//! describes a user; it does not grant a permission. [`bind`] binds +//! `hubspo_port::RBAC_PLUG` through [`RoleNames`], a [`GrantSource`] that +//! defines the six names and carries **no grants**, so every decision the +//! returned binding makes on its own is a refusal. Access comes from the +//! tenant's permission sets ([`crate::tenant`]) compiled per request +//! ([`crate::access`]). //! -//! - **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 binding still does two jobs: it checks the plug against OGAR (every +//! plugged concept minted, every name declared, the wire mirror in step) and +//! it filters the roles a principal carries down to the declared names +//! ([`request_actors`]). //! -//! ## The verb table +//! ## The preset fixture //! -//! 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`]. +//! [`PresetFixture`] keeps the provisional verb table below as a +//! compatibility and simulation fixture: crm-sim and the tests may bind it +//! through [`bind_preset_fixture`]. It is never a runtime access source and +//! is not HubSpot-verified; no sandbox oracle has confirmed it. +//! +//! - **Not actor assignment.** Neither source knows an 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. +//! +//! ## The fixture's verb table +//! +//! Written from the seat names, not from a HubSpot export. Every role reads +//! every plugged concept; the table 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 | //! |---|---|---|---| @@ -38,12 +47,10 @@ //! | 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, + RBAC_CLASSIDS, RBAC_PLUG, RBAC_ROLES, 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}; @@ -140,13 +147,33 @@ 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`. +/// The six role names with no grants: the [`GrantSource`] [`bind`] uses. +#[derive(Debug, Clone, Copy, Default)] +pub struct RoleNames; + +impl GrantSource for RoleNames { + /// Always empty: actors come from the request's identity. + fn roles_of(&self, _actor: ActorId<'_>) -> &[RoleId] { + &[] + } + + /// Always empty: a role name grants nothing. + fn grants_of(&self, _role: RoleId) -> &[ClassGrant] { + &[] + } + + fn defines_role(&self, role: RoleId) -> bool { + RBAC_ROLES.contains(&role) + } +} + +/// The provisional preset verb table, kept as a compatibility and simulation +/// fixture. Not a runtime access source (see the module docs). #[derive(Debug, Clone, Copy, Default)] -pub struct Catalogue; +pub struct PresetFixture; -impl GrantSource for Catalogue { - /// Always empty: actors come from the request's identity, not from the - /// catalogue (see the module docs). +impl GrantSource for PresetFixture { + /// Always empty: actors come from the request's identity. fn roles_of(&self, _actor: ActorId<'_>) -> &[RoleId] { &[] } @@ -164,15 +191,26 @@ impl GrantSource for Catalogue { } } -/// Bind `RBAC_PLUG` against OGAR's authority with the preset grants. +/// Bind `RBAC_PLUG` against OGAR's authority with the role names and no +/// grants: the runtime binding. /// /// # 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 +/// role [`RoleNames`] does not define, or a concept the contract's wire /// mirror disagrees on. pub fn bind() -> Result { - OgarRbac::new(Catalogue).bind(&RBAC_PLUG) + OgarRbac::new(RoleNames).bind(&RBAC_PLUG) +} + +/// Bind `RBAC_PLUG` with the [`PresetFixture`] grants, for simulation and +/// compatibility tests only. +/// +/// # Errors +/// +/// As [`bind`]. +pub fn bind_preset_fixture() -> Result { + OgarRbac::new(PresetFixture).bind(&RBAC_PLUG) } /// Why a request's roles could not be resolved. @@ -214,7 +252,7 @@ pub fn request_actors( #[cfg(test)] mod tests { use super::*; - use hubspo_port::{CONSUMER, CRM_CLASSIDS, RBAC_ROLES}; + use hubspo_port::{CONSUMER, CRM_CLASSIDS}; use lance_graph_contract::rbac::{ClassId, ClassRbac, Operation}; use lance_graph_contract::rbac_plug::ActorSource; use std::collections::BTreeSet; @@ -239,7 +277,8 @@ mod tests { #[test] fn the_plug_binds_with_every_preset() { - let binding = bind().expect("RBAC plug drifted from OGAR or the wire mirror"); + let binding = + bind_preset_fixture().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); @@ -251,21 +290,50 @@ mod tests { } } + #[test] + fn the_runtime_bind_declares_every_role_and_grants_nothing() { + let binding = bind().expect("RBAC plug drifted from OGAR or the wire mirror"); + let bound: Vec = binding.declared_grants().iter().map(|(r, _)| *r).collect(); + assert_eq!(bound, RBAC_ROLES); + let ops = [ + Operation::Read { + depth: lance_graph_contract::property::PrefetchDepth::Full, + }, + write(), + Operation::Act { + action: "email.send", + }, + ]; + for &role in RBAC_ROLES { + assert!(RoleNames.defines_role(role), "{role}"); + assert!( + binding.grants_for(role).is_ok_and(<[_]>::is_empty), + "{role}" + ); + for &id in RBAC_CLASSIDS { + for op in &ops { + assert_eq!(binding.permits(role, class(id), op), Ok(false), "{role}"); + } + } + } + assert!(!RoleNames.defines_role("crm_owner")); + } + #[test] fn every_plug_role_is_defined_and_nothing_else_is() { for &role in RBAC_ROLES { - assert!(Catalogue.defines_role(role), "{role}"); + assert!(PresetFixture.defines_role(role), "{role}"); } - assert!(!Catalogue.defines_role("crm_owner")); + assert!(!PresetFixture.defines_role("crm_owner")); assert!( - !Catalogue.defines_role("CRM_SUPER_ADMIN"), + !PresetFixture.defines_role("CRM_SUPER_ADMIN"), "names are exact" ); } #[test] fn a_concept_outside_the_plug_is_refused_not_granted() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); assert!(!RBAC_CLASSIDS.contains(&class_ids::PATIENT)); assert_eq!( binding.permits(ROLE_SUPER_ADMIN, class(class_ids::PATIENT), &write()), @@ -277,7 +345,7 @@ mod tests { #[test] fn every_role_reads_every_plugged_concept() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); let read = Operation::Read { depth: lance_graph_contract::property::PrefetchDepth::Full, }; @@ -290,7 +358,7 @@ mod tests { #[test] fn view_only_reads_and_nothing_more() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); for &id in RBAC_CLASSIDS { assert_eq!(verbs_of(&binding, ROLE_VIEW_ONLY, id), OpMask::READ); } @@ -298,7 +366,7 @@ mod tests { #[test] fn only_the_admins_delete() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); let deleters: BTreeSet = RBAC_ROLES .iter() .copied() @@ -313,7 +381,7 @@ mod tests { #[test] fn super_admin_holds_every_verb_everywhere_and_admin_falls_short_on_users() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().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); @@ -327,7 +395,7 @@ mod tests { #[test] fn each_seat_edits_exactly_its_own_concepts() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); let editable = |role| -> BTreeSet { RBAC_CLASSIDS .iter() @@ -384,7 +452,7 @@ mod tests { #[test] fn create_travels_with_write() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); for &role in RBAC_ROLES { for &id in RBAC_CLASSIDS { let v = verbs_of(&binding, role, id); @@ -414,7 +482,7 @@ mod tests { #[test] fn a_sales_rep_writes_deals_but_not_tickets() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().expect("binds"); let rbac = binding.with_actors(Request); let held = |actor, concept| { rbac.actor_roles(actor) @@ -429,7 +497,7 @@ mod tests { #[test] fn sequences_are_enrolled_not_edited_by_sales() { - let binding = bind().expect("binds"); + let binding = bind_preset_fixture().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)); diff --git a/crates/hubspo-iam/src/scope.rs b/crates/hubspo-iam/src/scope.rs index 4c7389a..c978d2a 100644 --- a/crates/hubspo-iam/src/scope.rs +++ b/crates/hubspo-iam/src/scope.rs @@ -58,6 +58,20 @@ pub enum RowScope { } impl RowScope { + /// Whether a single row with owner code `owner` (`None`: no owner) is + /// admitted. For a one-record lookup; a scan uses [`RowScope::filter`]. + #[must_use] + pub fn admits(&self, owner: Option) -> bool { + match self { + RowScope::All => true, + RowScope::Nothing => false, + RowScope::Rows { owners, unassigned } => match owner { + Some(code) => owners.binary_search(&code).is_ok(), + None => *unassigned, + }, + } + } + /// The filter over the owner lane `owner`, whose validity plane is /// `owner_valid` (a row has an owner exactly when its bit is set). /// diff --git a/crates/hubspo-iam/src/tenant.rs b/crates/hubspo-iam/src/tenant.rs new file mode 100644 index 0000000..d6ad49e --- /dev/null +++ b/crates/hubspo-iam/src/tenant.rs @@ -0,0 +1,370 @@ +//! Tenant permission policy: the only runtime source of access. +//! +//! Operator ruling 2026-10-21 (`ISS-PRESET-VERBS-PROVISIONAL`, +//! `ISS-PERMISSION-SETS-NOT-ROLES`): a role name describes a user and grants +//! nothing. Access comes from **permission sets**, versioned tenant data +//! with stable numeric ids, assigned to a role or directly to a principal. +//! A set may grant more than any preset; it is not a narrowing of one. +//! The preset verb table in [`crate::roles`] is a fixture and is never read +//! here. +//! +//! Effective access to a concept is the conjunction of: +//! +//! 1. the principal's [`Seat`]: the ceiling of concepts and actions any +//! grant can unlock; +//! 2. a permission set that grants the verb ([`ClassGrant`]); +//! 3. the set's ownership scope on the concept (D-IAM-2); +//! 4. for properties, the set's outer field mask and inner ordinals +//! (D-IAM-3, [`crate::fields`]); +//! 5. for an action, an explicit named gate in the same set. +//! +//! `ACT`, `DELETE` and `EXPORT` are never inherited from `WRITE` or +//! `CREATE`: each needs both its verb bit and a named [`ActionGrant`] in +//! the same set, and the seat must allow the action. A missing, unknown or +//! unassigned set grants nothing. + +use crate::fields::{OrdinalSet, PolicyVersion, SeatVersion}; +use crate::scope::OwnerScope; +use lance_graph_contract::class_view::WideFieldMask; +use lance_graph_contract::rbac::{ClassGrant, OpMask, Operation, RoleId, grants_permit}; +use ogar_dir_core::Guid128; + +/// A permission set's stable id within a tenant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PermissionSetId(pub u32); + +/// A tenant (a HubSpot portal). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TenantId(pub u32); + +/// The action name for deleting records; needs [`OpMask::DELETE`]. +pub const ACTION_DELETE: &str = "delete"; +/// The action name for exporting records; needs [`OpMask::READ`]. +pub const ACTION_EXPORT: &str = "export"; + +/// The verb an action name needs besides its named gate: `delete` needs +/// `DELETE`, `export` needs `READ`, every other action needs `ACT`. +#[must_use] +pub fn action_verb(action: &str) -> OpMask { + match action { + ACTION_DELETE => OpMask::DELETE, + ACTION_EXPORT => OpMask::READ, + _ => OpMask::ACT, + } +} + +/// One named action a set allows on a concept (`email.send`, +/// `sequence.enroll`, `invoice.create`, [`ACTION_EXPORT`], +/// [`ACTION_DELETE`], ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionGrant { + /// The concept the action runs on. + pub concept: u16, + /// The action name. + pub action: &'static str, +} + +/// One permission set: everything it grants is listed; nothing is implied. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PermissionSet { + /// Verbs per concept. + pub grants: Vec, + /// Named action gates. + pub actions: Vec, + /// Ownership scope per concept; a concept without one admits no row. + pub scopes: Vec<(u16, OwnerScope)>, + /// Outer field mask per concept (ClassView positions). + pub fields: Vec<(u16, WideFieldMask)>, + /// Readable property ordinals per concept. + pub properties: Vec<(u16, OrdinalSet)>, +} + +impl PermissionSet { + /// The scope this set gives on `concept`. + #[must_use] + pub fn scope(&self, concept: u16) -> Option { + self.scopes + .iter() + .find_map(|(c, s)| (*c == concept).then_some(*s)) + } + + /// This set's outer mask and inner ordinals on `concept`. + #[must_use] + pub fn property_holder(&self, concept: u16) -> (Option, Option<&OrdinalSet>) { + ( + self.fields + .iter() + .find_map(|(c, m)| (*c == concept).then(|| m.clone())), + self.properties + .iter() + .find_map(|(c, o)| (*c == concept).then_some(o)), + ) + } + + /// Whether this set allows `action` on `concept`: the verb the action + /// needs and the named gate, both in this set. + #[must_use] + pub fn allows_action(&self, concept: u16, action: &str) -> bool { + let verb = action_verb(action); + self.grants + .iter() + .any(|g| g.target_classid == concept && g.op_mask.contains(verb)) + && self + .actions + .iter() + .any(|a| a.concept == concept && a.action == action) + } + + /// Whether this set permits `op` on `class` (verb gate only). + #[must_use] + pub fn permits(&self, class: u32, op: &Operation<'_>) -> bool { + grants_permit(&self.grants, class, op) + } +} + +/// Who a set is assigned to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Assignee { + /// Everyone holding the role. + Role(RoleId), + /// One directory principal. + Principal(Guid128), +} + +/// The principal's seat: the ceiling of what any grant can unlock. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Seat { + /// Bumped whenever the seat assignment or its capabilities change. + pub version: SeatVersion, + /// Concepts the seat can reach at all. + pub concepts: Vec, + /// Action names the seat can perform at all. + pub actions: Vec<&'static str>, +} + +impl Default for SeatVersion { + fn default() -> Self { + Self::NONE + } +} + +/// Why the policy refused a change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyError { + /// A set with this id already exists. + DuplicateSet(PermissionSetId), + /// No set with this id. + UnknownSet(PermissionSetId), +} + +/// One tenant's permission policy at one version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantPolicy { + tenant: TenantId, + version: PolicyVersion, + generation: u64, + sets: Vec<(PermissionSetId, PermissionSet)>, + assigned: Vec<(Assignee, PermissionSetId)>, +} + +impl TenantPolicy { + /// An empty policy: grants nothing. + #[must_use] + pub fn new(tenant: TenantId, version: PolicyVersion) -> Self { + Self { + tenant, + version, + generation: 0, + sets: Vec::new(), + assigned: Vec::new(), + } + } + + /// The tenant the policy belongs to. + #[must_use] + pub fn tenant(&self) -> TenantId { + self.tenant + } + + /// The declared policy version. + #[must_use] + pub fn version(&self) -> PolicyVersion { + self.version + } + + /// Bumped by every change, so a projection compiled before a change is + /// refused after it even when the declared version was not bumped. + #[must_use] + pub fn generation(&self) -> u64 { + self.generation + } + + /// Add a set. + /// + /// # Errors + /// + /// [`PolicyError::DuplicateSet`] when `id` is taken. + pub fn add_set(&mut self, id: PermissionSetId, set: PermissionSet) -> Result<(), PolicyError> { + if self.sets.iter().any(|(i, _)| *i == id) { + return Err(PolicyError::DuplicateSet(id)); + } + self.sets.push((id, set)); + self.generation += 1; + Ok(()) + } + + /// Replace a set's content (a tenant editing a permission set). + /// + /// # Errors + /// + /// [`PolicyError::UnknownSet`] when `id` does not exist. + pub fn replace_set( + &mut self, + id: PermissionSetId, + set: PermissionSet, + ) -> Result<(), PolicyError> { + let slot = self + .sets + .iter_mut() + .find(|(i, _)| *i == id) + .ok_or(PolicyError::UnknownSet(id))?; + slot.1 = set; + self.generation += 1; + Ok(()) + } + + /// Assign a set. + /// + /// # Errors + /// + /// [`PolicyError::UnknownSet`] when `id` does not exist. + pub fn assign(&mut self, to: Assignee, id: PermissionSetId) -> Result<(), PolicyError> { + if !self.sets.iter().any(|(i, _)| *i == id) { + return Err(PolicyError::UnknownSet(id)); + } + self.assigned.push((to, id)); + self.generation += 1; + Ok(()) + } + + /// Withdraw every assignment of `id` to `to`. + pub fn unassign(&mut self, to: Assignee, id: PermissionSetId) { + self.assigned.retain(|(a, i)| !(*a == to && *i == id)); + self.generation += 1; + } + + /// The sets that apply to `principal` holding `roles`, each once. + #[must_use] + pub fn sets_for(&self, principal: &Guid128, roles: &[RoleId]) -> Vec<&PermissionSet> { + let mut ids: Vec = self + .assigned + .iter() + .filter(|(a, _)| match a { + Assignee::Role(r) => roles.contains(r), + Assignee::Principal(p) => p == principal, + }) + .map(|(_, id)| *id) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids.iter() + .filter_map(|id| self.sets.iter().find(|(i, _)| i == id).map(|(_, s)| s)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::class_ids; + + fn g(n: u8) -> Guid128 { + Guid128([n; 16]) + } + + const SALES: RoleId = "crm_sales_user"; + + fn set_with(op: OpMask, actions: &[&'static str]) -> PermissionSet { + PermissionSet { + grants: vec![ClassGrant::new(class_ids::CRM_CONTACT, op)], + actions: actions + .iter() + .map(|&a| ActionGrant { + concept: class_ids::CRM_CONTACT, + action: a, + }) + .collect(), + ..PermissionSet::default() + } + } + + #[test] + fn an_action_needs_its_verb_and_its_named_gate() { + let all = OpMask::READ + .union(OpMask::WRITE) + .union(OpMask::CREATE) + .union(OpMask::ACT) + .union(OpMask::DELETE); + let c = class_ids::CRM_CONTACT; + // The verb alone does not allow it. + assert!(!set_with(all, &[]).allows_action(c, "email.send")); + assert!(!set_with(all, &[]).allows_action(c, ACTION_DELETE)); + assert!(!set_with(all, &[]).allows_action(c, ACTION_EXPORT)); + // The gate alone does not allow it either. + let write = OpMask::READ.union(OpMask::WRITE).union(OpMask::CREATE); + assert!(!set_with(write, &["email.send", ACTION_DELETE]).allows_action(c, "email.send")); + assert!(!set_with(write, &[ACTION_DELETE]).allows_action(c, ACTION_DELETE)); + // Both: allowed, and only that action. + let s = set_with(all, &["email.send"]); + assert!(s.allows_action(c, "email.send")); + assert!(!s.allows_action(c, "sequence.enroll")); + assert!(!s.allows_action(class_ids::CRM_DEAL, "email.send")); + // Export needs READ plus its gate. + assert!(set_with(OpMask::READ, &[ACTION_EXPORT]).allows_action(c, ACTION_EXPORT)); + } + + #[test] + fn sets_apply_by_role_or_principal_and_unknown_sets_are_refused() { + let mut p = TenantPolicy::new(TenantId(1), PolicyVersion(1)); + assert_eq!( + p.assign(Assignee::Role(SALES), PermissionSetId(9)), + Err(PolicyError::UnknownSet(PermissionSetId(9))) + ); + p.add_set(PermissionSetId(1), set_with(OpMask::READ, &[])) + .unwrap(); + p.add_set(PermissionSetId(2), set_with(OpMask::WRITE, &[])) + .unwrap(); + assert_eq!( + p.add_set(PermissionSetId(1), PermissionSet::default()), + Err(PolicyError::DuplicateSet(PermissionSetId(1))) + ); + p.assign(Assignee::Role(SALES), PermissionSetId(1)).unwrap(); + p.assign(Assignee::Principal(g(7)), PermissionSetId(2)) + .unwrap(); + assert_eq!(p.sets_for(&g(7), &[SALES]).len(), 2); + assert_eq!(p.sets_for(&g(8), &[SALES]).len(), 1); + assert_eq!(p.sets_for(&g(7), &[]).len(), 1); + assert!(p.sets_for(&g(8), &[]).is_empty(), "no assignment, no set"); + } + + #[test] + fn every_change_bumps_the_generation() { + let mut p = TenantPolicy::new(TenantId(1), PolicyVersion(1)); + let mut last = p.generation(); + let mut bumped = |p: &TenantPolicy| { + assert!(p.generation() > last); + last = p.generation(); + }; + p.add_set(PermissionSetId(1), PermissionSet::default()) + .unwrap(); + bumped(&p); + p.assign(Assignee::Role(SALES), PermissionSetId(1)).unwrap(); + bumped(&p); + p.replace_set(PermissionSetId(1), set_with(OpMask::READ, &[])) + .unwrap(); + bumped(&p); + p.unassign(Assignee::Role(SALES), PermissionSetId(1)); + bumped(&p); + assert!(p.sets_for(&g(1), &[SALES]).is_empty()); + } +} diff --git a/crates/hubspo-iam/tests/roles.rs b/crates/hubspo-iam/tests/roles.rs index 36f46f4..63ca4fa 100644 --- a/crates/hubspo-iam/tests/roles.rs +++ b/crates/hubspo-iam/tests/roles.rs @@ -2,7 +2,7 @@ //! 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_iam::roles::{RoleRefusal, bind, bind_preset_fixture, 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; @@ -92,7 +92,9 @@ 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(); + // The preset fixture, so the roles' verbs can be checked; the runtime + // binding grants nothing (see `runtime_roles_resolve_but_grant_nothing`). + let binding = bind_preset_fixture().unwrap(); let rep = request_actors(&view, &g(REP), &policy(), &binding).unwrap(); assert_eq!(rep.roles(), &[ROLE_SALES_USER]); @@ -153,3 +155,22 @@ fn an_unread_group_refuses_the_request() { let newcomer = request_actors(&view, &g(NEWCOMER), &gr, &binding).unwrap(); assert!(newcomer.roles().is_empty()); } + +#[test] +fn runtime_roles_resolve_but_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 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, "the names resolve"); + assert!(!writes( + &binding.with_actors(lead.clone()), + lead.subject(), + class_ids::CRM_DEAL + )); +}