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

Filter by extension

Filter by extension


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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
11 changes: 11 additions & 0 deletions crates/hubspo-compat/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading