diff --git a/docs/deployment/security.md b/docs/deployment/security.md new file mode 100644 index 0000000..cde8fef --- /dev/null +++ b/docs/deployment/security.md @@ -0,0 +1,202 @@ +# Security — what ships, and what a real rollout still has to do + +Duly declares three positions, three permission sets and no sharing rules. This +page is the other half: the bindings a package is not allowed to make, the +enterprise dependency a real deployment needs, and the two grants that are +currently narrower than the product intends. + +Nothing here is advice about writing security code. There is no security code: +`definePosition`, `definePermissionSet` and `defineSharingRule` are the whole +toolkit, and a permission check written into a handler would be one the +platform's RLS does not know about, does not apply on the REST path, does not +apply over MCP, and never appears in an audit. + +--- + +## The model in one table + +| | `duly_member` | `duly_manager` | `duly_admin` | +|:---|:---|:---|:---| +| Who | everyone who owns duties | anyone with reports or a unit | catalog owners, rollout admins | +| `duly_task` | create / read / edit · read **own** · write **own** | inherited | inherited | +| `duly_duty` | create / read · read **own** · write **own** | inherited | **+ edit** · read **org** · write **own** | +| `duly_log_entry` | full control · read **own** · write **own** | inherited | inherited | +| `duly_catalog_item` | read | inherited | **+ create / edit / delete** · write **org** | +| `duly_assignment` | read · read **own** | **+ create / edit** · write **own** | inherited | +| Capabilities | `duly.task.update_status` | inherited | **+ `duly.catalog.apply`, `duly.catalog.sync`** | + +"Inherited" is literal: `src/security/permission-sets.ts` builds each map by +spreading the one above it, and `test/security.test.ts` asserts that every +non-overridden entry is the same object. A grant is written once. + +Each set is self-contained, so binding **one set to one position** is a correct +deployment — a manager holding only `duly_manager` still has every member grant. + +### Three things the table does not say, and should + +**A manager writes nothing below them.** Every read widening above is on the +read axis. `writeScope` never leaves `own` on `duly_task` or `duly_duty` in any +set, administrators included. A manager's only write is raising an assignment; +the fan-out then produces one independently-owned task per assignee, and status +entry belongs to the person who owns the work. This is enforced, not intended: +`test/security.test.ts` walks all three sets on both objects. + +**An administrator is not a super-user.** No `viewAllRecords` or +`modifyAllRecords` anywhere, no `'*'` wildcard, no `adminScope`. The org-wide +correction path is `duly_catalog_sync` — bounded to cadence fields, gated by a +capability only `duly_admin` holds, and reportable. A correction typed into +somebody's duty record would be none of those things, which is why +`duly_admin`'s `allowEdit` on `duly_duty` still sits behind `writeScope: 'own'`. + +**`duly_log_entry` is closed to everyone but its owner.** Including the +administrator. See below. + +--- + +## ⛔ The work log + +`readScope: 'own'` for every position, no exceptions, no admin override, no +sharing rule. This is a product invariant and it outranks convenience: a log +people believe their skip-level can read is a log nobody keeps, and the module +then stops producing the one record it exists to produce. + +The product allows exactly one widening — a record's own +`visibility: 'manager'`, reaching **that person's manager and nobody else**. +**That widening is not shipped, because this platform version cannot express +it.** A criteria sharing rule's recipient is a single static principal resolved +once per rule, never per matched record, so "the owner's manager" has nothing to +resolve through; the nearest expressible recipient, `position: 'duly_manager'`, +would hand every marked entry to every manager in the tenant, which is the +disclosure the invariant exists to prevent. RLS is not a way around it either — +on a `private` object the RLS filter is AND-composed under the sharing layer's +owner filter, so it can only narrow. The measurement is in +`src/security/sharing-rules.ts`; the upstream issue is +**objectstack-ai/objectstack#14103**. + +So today `visibility: 'manager'` stores an intention and grants nothing. That is +fail-closed and it is the right way to be wrong here: a missing grant is visible +and fixable, a grant that reached the wrong people is neither. + +The same gap narrows `duly_assignment`, which should be readable by the people +it is addressed to (`assignees`) and is instead readable by whoever raised it. +Assignees still see their own fanned-out `duly_task`, which is the row they work. + +--- + +## An enterprise runtime is a product dependency + +The manager model is built on the ADR-0057 depth scopes +(`own_and_reports`, `unit`, `unit_and_below`, `org`). Those are resolved by +**`@objectstack/security-enterprise`**. Without it the platform has no manager +chain and no business-unit tree resolver, so depth collapses to owner-only. + +For a real rollout that means: + +```bash +pnpm add @objectstack/security-enterprise +``` + +and adding it to `plugins[]`. Manager visibility is not a feature you can verify +on an open-edition checkout; a manager view there shows you your own rows, and +that is the edition, not a bug. + +### ⛔ Two manager grants are currently narrower than this table implies + +`duly_manager` on `duly_task` and `duly_duty`, and `duly_admin` on `duly_task`, +should read `unit_and_below`. They are authored `own`. + +The reason is not caution. `defineStack` **refuses to load** a permission set +carrying `unit`, `unit_and_below` or `own_and_reports` unless the stack declares +`requires: ['hierarchy-security']` — it is a hard error, not the silent fallback +this repo's own notes describe, and it takes `validate`, `build` and every test +that imports the config. This package may not add that declaration +(`objectstack.config.ts` is off-limits to feature work, and a repo rule forbids +it), so the only authorable depths here are `own` and `org` — and `org` would +hand every manager every task in the tenant. + +`own` is also exactly what an open-edition runtime would have *resolved* +`unit_and_below` to, so nothing about today's behaviour differs. What differs is +that the declaration is now honest, and an enterprise deployment inherits an +under-grant it can see rather than a grant that quietly never worked. + +The three affected grants are recorded machine-readably in +`HIERARCHY_SCOPES_DEFERRED` (`src/security/permission-sets.ts`) and pinned in +both directions by `test/security.test.ts`: widen a grant without deleting its +row and the test fails; delete a row without widening the grant and the test +fails. Tracked as **#46**, which also carries the measurement showing the +"declaring it fails an open-edition boot" belief to be false. + +--- + +## Binding positions to permission sets is a rollout step + +A package cannot do this, and the omission is not an oversight in this app. +`PositionSchema` has no `permissionSets`, no `permissions` and no `users` key and +rejects all three by name: capability reaches a position **only** through +`sys_position_permission_set` rows, which an administrator creates in Setup +(ADR-0090 D3). The one declarative suggestion the platform offers, `isDefault`, +targets the built-in `everyone` anchor and is unavailable to these sets anyway — +the ADR-0090 D5/D9 anchor tier refuses any set carrying `systemPermissions` or a +delete bit, and `duly_member` carries both. + +So a fresh install boots with three positions, three permission sets and **zero +bindings**, and every persona is denied until someone does this: + +| Position | Bind this permission set | +|:---|:---| +| `duly_member` | `duly_member` | +| `duly_manager` | `duly_manager` | +| `duly_admin` | `duly_admin` | + +Then assign people to positions (`sys_user_position`), anchored to their business +unit — the anchor is what the depth scopes resolve against, so an unanchored +assignment is a manager who sees nothing. + +Everyone gets `duly_member`. Managers and administrators get their own set +*instead of*, not in addition to, the member set — each one already contains it. + +--- + +## Actions: the capability gate is the only boundary + +All five actions run their handlers against `ctx.engine`, the trusted facade — +context-less and RLS/FLS-bypassing **by design**. Object permissions never see +those writes, so `requiredPermissions` (ADR-0066 D4: 403 on the platform action +route and the MCP bridge, mirrored as a UI hide) is the entire boundary. + +| Action | Capability | Held by | +|:---|:---|:---| +| `duly_catalog_apply` | `duly.catalog.apply` | `duly_admin` | +| `duly_catalog_sync` | `duly.catalog.sync` | `duly_admin` | +| `duly_task_complete` | `duly.task.update_status` | all three | +| `duly_task_undo` | `duly.task.update_status` | all three | +| `duly_task_skip` | `duly.task.update_status` | all three | + +Apply and sync are **separate capabilities** although one set grants both. +Applying a catalog to a new hire is onboarding; syncing rewrites authored cadence +on duties people are already working to — org-wide when `position_code` is +omitted — and is reportable only after the fact. A deployment that wants an +onboarding administrator who cannot rewrite the org's cadence can express that by +binding a set granting only the first. + +Two things this table is not. `visible` on the task actions is a **UI hide**: the +button disappears, the route does not. And each task handler's re-read of its +subject under the caller's scope is a **row** check ("is this row yours to see"), +which is load-bearing and stays — but it does not answer "is completing a task +something you may do at all". A read-only auditor with unit-wide visibility +passes the row check and must fail the capability gate. That is why both exist. + +--- + +## Verifying a deployment + +```bash +pnpm validate # reports "Security: 3 Positions 3 Permissions" +pnpm test # test/security.test.ts asserts every declared scope +``` + +`test/security.test.ts` asserts the **authored** metadata, never resolved rows — +on an open-edition checkout a row count measures the edition, not the +declaration, and would go green on the day someone deleted a scope. To see +manager visibility actually resolve you need an enterprise runtime and a +populated business-unit tree. diff --git a/src/actions/catalog.actions.ts b/src/actions/catalog.actions.ts index 98bf4bd..0b5ccd5 100644 --- a/src/actions/catalog.actions.ts +++ b/src/actions/catalog.actions.ts @@ -60,6 +60,12 @@ export const CatalogApplyAction = defineAction({ target: CATALOG_APPLY_ACTION, locations: [], variant: 'primary', + // [ADR-0066 D4] The ONLY boundary this action has. The handler runs against + // `ctx.engine`, the trusted facade — context-less and RLS/FLS-bypassing by + // design — so object permissions never see the write. Ungated, anyone who + // could reach the route could mint a duty for any `sys_user` id they typed, + // in bulk. Granted by the `duly_admin` permission set (src/security/). + requiredPermissions: ['duly.catalog.apply'], params: [ { name: 'position_code', @@ -103,6 +109,13 @@ export const CatalogSyncAction = defineAction({ type: 'script', target: CATALOG_SYNC_ACTION, locations: [], + // [ADR-0066 D4] A SEPARATE capability from apply, though `duly_admin` grants + // both. Applying a catalog to a new hire is onboarding; syncing rewrites + // authored cadence on duties people are already working to — org-wide when + // `position_code` is omitted — and is reportable only after the fact. Two + // strings cost nothing and let a deployment hand out the first without the + // second; one merged string would make that distinction unexpressible. + requiredPermissions: ['duly.catalog.sync'], params: [ { name: 'position_code', diff --git a/src/actions/task.actions.ts b/src/actions/task.actions.ts index e8241c0..e5bff64 100644 --- a/src/actions/task.actions.ts +++ b/src/actions/task.actions.ts @@ -66,6 +66,14 @@ export const TaskCompleteAction = defineAction({ // record header rather than whatever registered first. order: 10, visible: P`record.status == "open" || record.status == "in_progress"`, + // [ADR-0066 D4] `visible` above is a UI hide and the handler's re-read is a + // ROW check ("is this row yours to see"). Neither answers the other + // question — "is completing a task a thing you may do at all" — which is + // what this declares: 403 on the platform action route and the MCP bridge. + // A read-only auditor with unit-wide visibility passes the row check and + // must fail this one. Granted by the `duly_member` set, and so by every set + // that inherits it. + requiredPermissions: ['duly.task.update_status'], // The platform's own one-click reversal: the runtime snapshots the record's // prior field values and offers Undo on the success toast. It covers the // mistake noticed IMMEDIATELY; `duly_task_undo` below covers the one noticed @@ -95,6 +103,10 @@ export const TaskUndoAction = defineAction({ variant: 'secondary', order: 20, visible: P`record.status == "done"`, + // [ADR-0066 D4] Same capability as complete: undo is the other half of the + // same one-click promise, and splitting them would produce a deployment + // where a tick cannot be taken back. + requiredPermissions: ['duly.task.update_status'], refreshAfter: true, successMessage: 'Reopened.', }); @@ -123,6 +135,9 @@ export const TaskSkipAction = defineAction({ variant: 'secondary', order: 30, visible: P`record.status == "open" || record.status == "in_progress"`, + // [ADR-0066 D4] Skipping is a status entry like the other two, so it takes + // the same capability. + requiredPermissions: ['duly.task.update_status'], params: [ { name: 'skip_reason', diff --git a/src/security/index.ts b/src/security/index.ts index 54404e9..e771652 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -3,7 +3,48 @@ // Positions (flat capability distribution, ADR-0090 D3), permission sets, and // record sharing rules. Three separate config keys, one barrel — push into the // arrays below rather than editing objectstack.config.ts. +// +// The three axes are separate on purpose and it is worth keeping them straight +// while reading this directory: +// +// positions WHO gets grants — flat, named distribution points +// permissionSets WHAT the grants are — the only capability container +// sharingRules WHICH extra rows a principal reaches, on top of the OWD +// +// Depth ("my reports", "my unit and below", "the org") is none of the three: +// it is the ADR-0057 scope on a permission-set object entry, resolved against +// the business-unit tree and the manager chain. + +import { + AdminPosition, + ManagerPosition, + MemberPosition, +} from './positions.js'; +import { + AdminPermissionSet, + ManagerPermissionSet, + MemberPermissionSet, +} from './permission-sets.js'; +import { dulySharingRuleDefinitions } from './sharing-rules.js'; + +export { AdminPosition, ManagerPosition, MemberPosition }; +export { AdminPermissionSet, ManagerPermissionSet, MemberPermissionSet }; +export { + DULY_CATALOG_APPLY, + DULY_CATALOG_SYNC, + DULY_TASK_UPDATE_STATUS, + HIERARCHY_SCOPES_DEFERRED, +} from './permission-sets.js'; + +export const dulyPositions = [MemberPosition, ManagerPosition, AdminPosition]; + +export const dulyPermissionSets = [ + MemberPermissionSet, + ManagerPermissionSet, + AdminPermissionSet, +]; -export const dulyPositions = []; -export const dulyPermissionSets = []; -export const dulySharingRules = []; +// Empty, and the emptiness is load-bearing — `sharing-rules.ts` carries the +// measurement and the upstream reference (objectstack#14103). Read it before +// adding anything here. +export const dulySharingRules = dulySharingRuleDefinitions; diff --git a/src/security/permission-sets.ts b/src/security/permission-sets.ts new file mode 100644 index 0000000..2586c2b --- /dev/null +++ b/src/security/permission-sets.ts @@ -0,0 +1,331 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePermissionSet } from '@objectstack/spec/security'; +import type { ObjectPermission } from '@objectstack/spec/security'; + +/** + * The three capability bundles. Permission sets are the ONLY capability + * container on the platform (ADR-0090): positions distribute them, business + * units give them depth, and nothing else grants anything. + * + * ── How "inherit" is spelled here ──────────────────────────────────────── + * There is no `extends` key on `PermissionSetSchema`, and there cannot be one + * that means what it says: at runtime a caller's capability is the UNION of + * every set they resolve, merged most-permissively, so "inheritance" is not a + * schema feature — it is what happens when a person holds two sets. + * + * What this file does instead is compose the AUTHORED grants: `MANAGER_OBJECTS` + * spreads `MEMBER_OBJECTS` and overrides the two entries that differ, and + * `ADMIN_OBJECTS` spreads `MANAGER_OBJECTS`. Two things follow, and both are + * the point: + * + * 1. A grant is written once. Nobody can widen `duly_log_entry` for managers + * by editing a copy of the member entry, because there is no copy. + * 2. Each set is self-contained, so binding ONE set to ONE position is a + * correct deployment. A manager who somehow holds only `duly_manager` + * still gets every member grant. + * + * `test/security.test.ts` asserts the composition itself — that the entries + * the card does not widen are byte-identical across the three sets — so a + * future edit that restates instead of inheriting fails a test rather than + * drifting quietly. + * + * ── Object-level bits and DEPTH are two different axes ─────────────────── + * `allowRead`/`allowEdit`/… answer "may this caller use this verb on this + * object at all". `readScope`/`writeScope` (ADR-0057 D1) answer "over whose + * rows". Both must be right; neither implies the other. Every widening in + * this file is on the READ axis. The write axis never leaves `own` on + * `duly_task` and `duly_duty` — see the invariant note on ADMIN_OBJECTS. + * + * ⛔ ── The manager's DEPTH is not authorable on this platform version ───── + * The card specifies `readScope: 'unit_and_below'` for the manager grants on + * `duly_task` and `duly_duty`, on the understanding that an open-edition + * runtime resolves it silently to owner-only. It does not: `defineStack` + * REFUSES TO LOAD. See {@link HIERARCHY_SCOPES_DEFERRED} below for the + * measurement and the tracking issue. The three affected grants are authored + * `'own'` — fail-closed — and the intended value is recorded there so it is + * one edit away, and one failing test away from being forgotten. + * + * ── Depth on `duly_catalog_item` ───────────────────────────────────────── + * `duly_catalog_item` is `public_read`, so `readScope` there would be inert: + * plugin-sharing's `buildReadFilter` returns `null` for any object whose + * effective model is not `private`, before depth is consulted. It is + * deliberately not declared (ADR-0049 — a key that cannot enforce does not + * get authored). `writeScope` is NOT inert on the same object: the WRITE + * filter applies to `private` and `public_read` alike (public_read is + * read-open, write-owned), which is why the admin entry has to say `org` for + * "full control" to mean anything at all. + * + * ── No `fields` block ──────────────────────────────────────────────────── + * Nothing in this schema needs masking: the server-owned columns + * (`completed_at`, `last_update_at`, `last_dispatched_period`) are already + * `readonly` on the object, which is the enforced surface. An FLS entry that + * repeats a readonly flag is declared-and-unenforced twice over. + */ + +/** + * Capabilities this package defines, by declaring them on the sets below. + * + * These strings are the `requiredPermissions` gate on the five actions + * (ADR-0066 D4: 403 on the platform action route and the MCP bridge, mirrored + * as a UI hide). They are deliberately NOT imported by the action files — the + * card's file surface there is the `requiredPermissions` key and nothing else + * — so the link between "the action requires it" and "a set grants it" is held + * by two independent checks: the `capability-reference-unknown` author-time + * rule, and an explicit assertion in `test/security.test.ts`. + * + * ── Why apply and sync are two capabilities, not one ───────────────────── + * Applying a catalog to a new hire is routine onboarding. Syncing rewrites + * authored cadence on duties people are already working to, org-wide when + * `position_code` is omitted, and is reportable only after the fact. Both are + * granted to `duly_admin` here, so nothing is harder to deploy — but a + * customer who wants an onboarding administrator who cannot rewrite the org's + * cadence can express that by binding a set that grants only the first, which + * a single merged capability would make impossible. + */ +export const DULY_TASK_UPDATE_STATUS = 'duly.task.update_status'; +export const DULY_CATALOG_APPLY = 'duly.catalog.apply'; +export const DULY_CATALOG_SYNC = 'duly.catalog.sync'; + +/** + * ⛔ STOPGAP — the ADR-0057 hierarchy depths this package MEANS to grant and + * may not declare today. Written to be deleted, not maintained. Tracked as + * **#46**, which is where the decision lives; this constant is only the + * machine-readable record of what the compromise costs. + * + * ── The measurement ────────────────────────────────────────────────────── + * `defineStack`'s `validateHierarchyScopeCapability` (@objectstack/spec + * 17.2.0) is a HARD ERROR on any permission-set grant whose `readScope` or + * `writeScope` is `unit`, `unit_and_below` or `own_and_reports`, unless the + * stack declares `requires: ['hierarchy-security']`: + * + * ✗ permission set 'duly_manager' grant on 'duly_task' uses + * readScope='unit_and_below', a HIERARCHY scope. Declare + * `requires: ['hierarchy-security']` (provided by + * @objectstack/security-enterprise) — the open edition cannot enforce it + * and would fail closed to owner-only. + * + * It runs inside `defineStack()`, so it takes `validate`, `build` AND the + * three tests that import the config. This is NOT the "silent fallback to + * owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config + * will not load. + * + * The prescribed fix is one line in `objectstack.config.ts`, which this card + * may not touch and which rule 7 forbids on the grounds that it "would fail an + * open-edition boot". Measured on this checkout, with the capability declared + * and no `@objectstack/security-enterprise` installed, that is not what + * happens: `validate`, `test` (278 passing, kernel logged + * `✅ Bootstrap complete`) and `build` all exit 0, with one warning naming the + * missing provider. The full measurement is in #46. Correcting a rule written + * in four places, in the config every parallel task shares, is not a rider on + * a security PR — hence the issue rather than the edit. + * + * ── Why `own` and not `org` ────────────────────────────────────────────── + * `org` IS authorable (it is not a hierarchy scope), and it is the wrong + * answer: it would hand every manager every task and duty in the tenant. + * Under-granting is visible and fixable; over-granting is neither. The three + * entries below are authored `own` — which is also exactly what an + * open-edition runtime would have RESOLVED `unit_and_below` to, so nothing + * about the running behaviour changes here. What changes is that the + * declaration is now honest, and an enterprise deployment inherits an + * under-grant it can see rather than a grant that quietly never worked. + * + * ── How this gets undone ───────────────────────────────────────────────── + * `test/security.test.ts` pins every row: the authored value must equal + * `authored`, and `intended` must be a hierarchy scope the validator would + * reject today. Widen a grant without deleting its row and the test fails; + * delete a row without widening the grant and the test fails. The compromise + * cannot outlive its reason, and it cannot be half-undone. + */ +export const HIERARCHY_SCOPES_DEFERRED = { + 'duly_manager.duly_task.readScope': { authored: 'own', intended: 'unit_and_below' }, + 'duly_manager.duly_duty.readScope': { authored: 'own', intended: 'unit_and_below' }, + 'duly_admin.duly_task.readScope': { authored: 'own', intended: 'unit_and_below' }, +} as const; + +/** + * ⛔ `duly_log_entry` — the entry that must never be widened. + * + * `readScope: 'own'` here, and the same object entry is inherited unchanged by + * `duly_manager` and `duly_admin`. There is no unit scope, no org scope and no + * admin override anywhere in this file, and `test/security.test.ts` walks every + * set to prove it. + * + * This is a product invariant, not a default someone tightened. A log people + * believe their skip-level can read is a log nobody keeps, and the module then + * stops producing the one record it exists to produce. The single widening the + * product allows is a record's OWN `visibility: 'manager'`, reaching that + * person's manager and nobody else — and that widening is NOT expressible on + * this platform version. See `src/security/sharing-rules.ts`, which carries the + * evidence and the upstream reference; the fail-closed state it leaves behind + * is why this entry can stay `own` without a hole opening somewhere else. + */ +const MEMBER_OBJECTS = { + // The whole day's work. Create, read and edit their own rows; delete is not + // granted — a dispatched task is a record of what was owed, and "cancelled" + // is a status, not a deletion. + duly_task: { + allowCreate: true, + allowRead: true, + allowEdit: true, + readScope: 'own', + writeScope: 'own', + }, + + // Read their own duties; create their own. No edit bit: a duty is what the + // organisation (or the person, once) declared is owed, and editing cadence + // after the fact is a correction, which belongs to `duly_admin` and to + // `duly_catalog_sync`. + duly_duty: { + allowCreate: true, + allowRead: true, + readScope: 'own', + writeScope: 'own', + }, + + // Full control of their own log, delete included. This is the one object + // where delete is right: a personal note the author wants gone should go. + duly_log_entry: { + allowCreate: true, + allowRead: true, + allowEdit: true, + allowDelete: true, + readScope: 'own', + writeScope: 'own', + }, + + // Read-only. The catalog describes positions, not people, and its OWD is + // already `public_read`; the grant here is the object-level bit that makes + // the tab usable at all. + duly_catalog_item: { + allowRead: true, + }, + + // A member reads assignments they own — i.e. ones they raised. The card asks + // for "the ones they are on", which needs a per-record recipient the sharing + // surface cannot name; it is the second instance of the same gap the log + // entry hits, and it is filed with it. Failing closed here is the safe half: + // an assignee still sees their own fanned-out `duly_task`, which is the row + // they actually work. + duly_assignment: { + allowRead: true, + readScope: 'own', + }, +} satisfies Record; + +/** + * Manager = member, plus the one write a manager makes. + * + * ⛔ `duly_task` and `duly_duty` are INHERITED UNCHANGED, which is not what + * the card asked for: both should widen to `readScope: 'unit_and_below'` on + * the read axis. That value will not load — see + * {@link HIERARCHY_SCOPES_DEFERRED}. What survives is the half that + * was never in doubt: `writeScope` stays `'own'` on both, inherited rather + * than restated, so the "a manager writes nothing below them" invariant is + * held by the absence of an override rather than by remembering to retype the + * same value. + */ +const MANAGER_OBJECTS = { + ...MEMBER_OBJECTS, + + // Assigning is a manager's only write. Create and edit their own + // assignments; the fan-out then produces one independently-owned task per + // assignee, which is where status entry lives. + duly_assignment: { + ...MEMBER_OBJECTS.duly_assignment, + allowCreate: true, + allowEdit: true, + writeScope: 'own', + }, +} satisfies Record; + +/** + * Administrator = manager, plus the catalog and an org-wide view of duties. + * + * ── The two entries worth reading twice ────────────────────────────────── + * `duly_duty` gets `readScope: 'org'` and `allowEdit` — but `writeScope` stays + * `'own'`, inherited. That pair is deliberate and it is the card's own + * acceptance line ("no position grants a write scope on duly_task/duly_duty + * wider than own") winning over its permission-set line ("edit for + * corrections"). The bit is not inert: an administrator can edit their own + * duties, which a member cannot. What it does not do is let one person quietly + * rewrite another person's obligations one record at a time. + * + * The org-wide correction path is not missing, it is somewhere better: + * `duly_catalog_sync`, which replays cadence from the catalog, is bounded to + * cadence fields, is gated by a capability only this set grants, and reports + * what it touched. A correction that goes through it is auditable; a + * correction typed into somebody's duty record is not. + * + * `duly_log_entry` is inherited untouched. There is no admin override, and + * that is the whole point of the invariant. + */ +const ADMIN_OBJECTS = { + ...MANAGER_OBJECTS, + + // Full control of the catalog. `writeScope: 'org'` is required, not + // decorative: `public_read` is read-open but WRITE-OWNED, so without it an + // administrator could only edit catalog items they personally created. + duly_catalog_item: { + ...MANAGER_OBJECTS.duly_catalog_item, + allowCreate: true, + allowEdit: true, + allowDelete: true, + writeScope: 'org', + }, + + duly_duty: { + ...MANAGER_OBJECTS.duly_duty, + allowEdit: true, + readScope: 'org', + }, +} satisfies Record; + +/** + * `duly_member` — the baseline every Duly user holds. + * + * Not `isDefault: true`, and that is forced rather than chosen: the ADR-0090 + * D5/D9 anchor tier refuses any set carrying `systemPermissions` (and any set + * carrying a delete bit), both of which this set needs. Binding it is + * therefore a rollout step; `docs/deployment/security.md` is the table an + * administrator reproduces in Setup. + */ +export const MemberPermissionSet = definePermissionSet({ + name: 'duly_member', + label: 'Duly — team member', + description: + 'Owns duties, works tasks, keeps a personal work log. Everything is scoped to the holder’s own records.', + objects: MEMBER_OBJECTS, + systemPermissions: [DULY_TASK_UPDATE_STATUS], + tabPermissions: { duly_app: 'visible' }, +}); + +/** + * `duly_manager` — reads down the line, writes assignments. + */ +export const ManagerPermissionSet = definePermissionSet({ + name: 'duly_manager', + label: 'Duly — manager', + description: + 'Everything a team member has, plus unit-and-below read on tasks and duties and the ability to raise assignments. Writes no one else’s status.', + objects: MANAGER_OBJECTS, + systemPermissions: [DULY_TASK_UPDATE_STATUS], + tabPermissions: { duly_app: 'visible' }, +}); + +/** + * `duly_admin` — the catalog and the rollout. + * + * Carries no `viewAllRecords` / `modifyAllRecords` anywhere. A Duly + * administrator is a person with a job, not a super-user, and the personal + * work log is closed to them exactly as it is to everyone else. + */ +export const AdminPermissionSet = definePermissionSet({ + name: 'duly_admin', + label: 'Duly — administrator', + description: + 'Everything a manager has, plus full control of the catalog and an org-wide read of duties. No View All / Modify All, and no widened read on the personal work log.', + objects: ADMIN_OBJECTS, + systemPermissions: [DULY_TASK_UPDATE_STATUS, DULY_CATALOG_APPLY, DULY_CATALOG_SYNC], + tabPermissions: { duly_app: 'visible' }, +}); diff --git a/src/security/positions.ts b/src/security/positions.ts new file mode 100644 index 0000000..15e468f --- /dev/null +++ b/src/security/positions.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePosition } from '@objectstack/spec/identity'; + +/** + * The three positions Duly ships. Flat — there is no position tree. + * + * ── Why flat, and where the hierarchy actually lives ───────────────────── + * ADR-0090 D3 finalises ADR-0057 D5: a position is a named DISTRIBUTION + * point, nothing more. Depth — "my reports", "my unit and below", "the org" — + * is resolved from the business-unit tree (`sys_business_unit`) and the + * manager chain (`sys_user.manager_id`), never from a second tree hung off + * positions. So `duly_manager` is not "above" `duly_member` in any structure + * the platform reads; it is a different bundle of capability, and the reach + * of that capability comes from where its holder sits in the org. + * + * ── A position declares WHO, never WHAT ────────────────────────────────── + * `PositionSchema` has no `permissionSets`, no `permissions` and no `users` + * key, and rejects all three with a named diagnostic. Capability reaches a + * position only through `sys_position_permission_set` bindings, which are + * RUNTIME rows an administrator creates in Setup — a package may not declare + * them and, for a non-anchor position like these three, may not even suggest + * them. That is a real gap in the authoring surface and it is filed upstream; + * see `docs/deployment/security.md`, which carries the binding table a + * rollout has to reproduce by hand. + * + * ── `delegatable` stays false on all three ─────────────────────────────── + * Default, and deliberate for `duly_admin`: ADR-0091 D3 forbids an + * admin-ish position from being self-service delegatable, and the runtime + * refuses the delegation rather than the authoring, so the only place this + * gets decided correctly is here. + */ + +/** + * Everyone who owns duties — which, in a Duly deployment, is everyone. + * + * Not "the lowest tier". A manager and an administrator are both people with + * duties of their own, so both hold this position too; their extra sets add + * reach on top, and the union is what the runtime resolves. + */ +export const MemberPosition = definePosition({ + name: 'duly_member', + label: 'Team member', + description: + 'Anyone who owns duties and keeps a work log. Held by every Duly user, including managers and administrators.', +}); + +/** + * Anyone with reports or a business unit. + * + * Reads down the line and writes nothing there. Assigning is a manager’s + * only write in this product, and `duly_assignment` is the only object the + * manager set opens a create/edit bit on. + */ +export const ManagerPosition = definePosition({ + name: 'duly_manager', + label: 'Manager', + description: + 'Holds reports or a business unit. Reads their tasks and duties; writes only assignments. Status entry stays with the person who owns the work.', +}); + +/** + * Catalog owners and rollout administrators. + * + * The position that runs an onboarding: it owns the catalog and holds the two + * capabilities that gate `duly_catalog_apply` and `duly_catalog_sync`. It is + * NOT a super-user — it carries no View All / Modify All bit anywhere, and on + * `duly_log_entry` it reads exactly what everybody else reads: their own rows. + */ +export const AdminPosition = definePosition({ + name: 'duly_admin', + label: 'Duly administrator', + description: + 'Owns the catalog and runs rollouts. Not a super-user: no View All / Modify All anywhere, and no widened read on the personal work log.', +}); diff --git a/src/security/sharing-rules.ts b/src/security/sharing-rules.ts new file mode 100644 index 0000000..4153581 --- /dev/null +++ b/src/security/sharing-rules.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SharingRuleParsed } from '@objectstack/spec/security'; + +/** + * Record sharing rules — deliberately EMPTY, with the reason on the record. + * + * This file exists and is wired into the barrel because the absence is a + * finding, not an oversight. Deleting it would leave the next author to + * rediscover the same wall, and — far worse — to author their way past it with + * the one recipient the surface does accept, which leaks. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT THE CARD ASKED FOR + * ═══════════════════════════════════════════════════════════════════════════ + * + * `duly_log_entry` where `visibility = 'manager'` → recipient: the owner's + * manager, and nobody else. + * + * The predicate half is trivially expressible: `record.visibility == "manager"` + * lowers to a filter and is a property of the record. The RECIPIENT half is + * not expressible at all on protocol 17. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHY NOT — measured on @objectstack/spec 17.2.0 and the 17.2.0 runtime + * ═══════════════════════════════════════════════════════════════════════════ + * + * 1. `ShareRecipientType` (spec `src/security/sharing.zod.ts`) is exactly five + * members: `user`, `team`, `position`, `unit_and_subordinates`, + * `business_unit`. `sharedWith.value` is documented as "ID or code of the + * recipient" — one static principal per rule. + * + * 2. `plugin-sharing`'s `expandRecipient` reads that static `recipient_id` and + * resolves it ONCE per rule, not once per record: `user` → `[recipient_id]`, + * `position` → every holder of the position, `business_unit` / + * `unit_and_subordinates` → the unit's (and descendants') members. The + * resulting `sys_record_share` rows name the same recipients for every + * record the criteria matched. There is no expansion that reads a FIELD of + * the matched record, so "the manager of THIS row's owner" has nothing to + * resolve through. + * + * 3. The only recipient that is even close — `position: 'duly_manager'` — is + * precisely the leak the product forbids. It would share every + * manager-visible log entry in the tenant with every holder of the manager + * position: the skip-level, the manager two teams over, everyone. That is + * the failure this invariant exists to prevent, so it is not a stopgap and + * it is not authored here. + * + * 4. The platform's own suggested alternative does not reach either. The + * author-time rule `sharing-rule-runtime-variable-condition` says, when a + * condition reads `current_user.*`: "Express per-user access with the + * mechanism that runs per request instead — an RLS policy on a permission + * set (`rowLevelSecurity[].using`, where `current_user.*` IS resolved)." + * That advice does not hold for a `private` object. `plugin-security`'s + * `getReadFilter` composes + * + * andComposeLayers(andComposeLayers(rlsFilter, cbpFilter), sharingFilter) + * + * and `plugin-sharing`'s `buildReadFilter` returns, for a private object + * with an owner column, `ownerMatch` OR'd only with the caller's + * `sys_record_share` grants. Because that filter is AND-composed, an RLS + * policy can only ever NARROW a private object's readable set — it cannot + * add a row the sharing layer already excluded. Widening a private object + * has exactly two doors: the ADR-0057 depth scopes (which the invariant + * forbids here, and which would expose `visibility: 'private'` rows too), + * and a `sys_record_share` row, which only a criteria sharing rule writes. + * Both doors are shut for this rule, by different bolts. + * + * 5. The same wall stands in a second place in this app, which is why it is + * worth a platform issue rather than a shrug: `duly_assignment` should be + * readable by "the people it is addressed to" (`assignees`), and that is + * the same shape — a recipient derived from a field of the matched record. + * + * Filed upstream as **objectstack-ai/objectstack#14103** — record-relative + * sharing recipients (the owner's manager; the value of a user field on the + * matched record). Until it lands, `duly_log_entry.visibility = 'manager'` + * records nothing but an intention: the field is stored and honest, and no + * manager can read the row. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHY THAT IS THE RIGHT PLACE TO STOP + * ═══════════════════════════════════════════════════════════════════════════ + * + * Failing closed costs a feature. Failing open costs the module: the moment + * one person discovers their skip-level read a log entry they marked for their + * manager, the honest logs stop, and every number downstream becomes a report + * on a dataset nobody keeps. A missing grant is visible and fixable. A grant + * that reached the wrong people is neither. + * + * `duly_catalog_item` needs no rule at all — it is `public_read`, and a rule + * anchored there would be refused by `SharingService.assertNotInertGrant` and + * flagged by the `sharing-rule-object-not-shareable` author-time rule. + */ +export const dulySharingRuleDefinitions: SharingRuleParsed[] = []; diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 0000000..d026147 --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,440 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import type { ObjectAccessScope } from '@objectstack/spec/security'; + +import { + AdminPermissionSet, + AdminPosition, + DULY_CATALOG_APPLY, + DULY_CATALOG_SYNC, + DULY_TASK_UPDATE_STATUS, + HIERARCHY_SCOPES_DEFERRED, + ManagerPermissionSet, + ManagerPosition, + MemberPermissionSet, + MemberPosition, + dulyPermissionSets, + dulyPositions, + dulySharingRules, +} from '../src/security/index.js'; +import { dulyActions } from '../src/actions/index.js'; +import { CatalogItem, Duty, LogEntry, Task, Assignment } from '../src/objects/index.js'; + +/** + * These assert the AUTHORED metadata, never the resolved rows. + * + * That is not a convenience — it is the only thing that can be asserted here. + * This checkout runs the open edition, where `@objectstack/security-enterprise` + * is absent and the ADR-0057 depth scopes cannot resolve; a test that drove a + * kernel and counted rows would measure the edition, not the declaration, and + * would pass for the wrong reason on the day someone deleted a scope. + * + * What a permission set IS, is its declaration. So these walk it. + */ + +const ALL_SETS = [MemberPermissionSet, ManagerPermissionSet, AdminPermissionSet]; +const SCOPE_WIDTH: Record = { + own: 0, + own_and_reports: 1, + unit: 2, + unit_and_below: 3, + org: 4, +}; + +/** Depth is three-valued at the authoring layer; unset means the `own` baseline. */ +function scopeOf(value: string | undefined): ObjectAccessScope { + return (value ?? 'own') as ObjectAccessScope; +} + +describe('positions', () => { + it('ships exactly the three flat positions the model names', () => { + expect(dulyPositions.map((p) => p.name)).toEqual([ + 'duly_member', + 'duly_manager', + 'duly_admin', + ]); + }); + + it('is flat — ADR-0090 D3 admits no position tree', () => { + for (const position of dulyPositions) { + // The schema has no `parent` key at all, so this is a guard against a + // future author reaching for one and being told by a strict-parse error + // rather than by a reviewer. + expect(Object.keys(position)).not.toContain('parent'); + } + }); + + it('no position is self-service delegatable — ADR-0091 D3 for the admin one', () => { + for (const position of dulyPositions) { + expect(position.delegatable, `${position.name} must not be delegatable`).toBe(false); + } + // Stated separately because this one is the reason the rule exists: a + // delegatable position must never distribute an adminScope-carrying set, + // and the runtime refuses the delegation rather than the authoring. + expect(AdminPosition.delegatable).toBe(false); + expect([MemberPosition.name, ManagerPosition.name]).toEqual(['duly_member', 'duly_manager']); + }); +}); + +describe('permission sets — declared scopes, object by object', () => { + it('ships exactly the three sets, named for the positions that carry them', () => { + expect(dulyPermissionSets.map((s) => s.name)).toEqual([ + 'duly_member', + 'duly_manager', + 'duly_admin', + ]); + }); + + /** + * The acceptance table. Every object entry of every set, both axes, written + * out — so a change to any grant has to be made here too, deliberately, + * rather than passing because the assertion was vague. + */ + const EXPECTED: Record> = { + duly_member: { + duly_task: { read: 'own', write: 'own' }, + duly_duty: { read: 'own', write: 'own' }, + duly_log_entry: { read: 'own', write: 'own' }, + // public_read: depth is not consulted on either axis for reads, and the + // member set grants no write bit, so both fall to the baseline. + duly_catalog_item: { read: 'own', write: 'own' }, + duly_assignment: { read: 'own', write: 'own' }, + }, + duly_manager: { + // ⛔ `duly_task` / `duly_duty` read SHOULD be 'unit_and_below'. It is not + // authorable on protocol 17.2.0 — see HIERARCHY_SCOPES_DEFERRED and the + // dedicated block below, which pins the deferral itself. + duly_task: { read: 'own', write: 'own' }, + duly_duty: { read: 'own', write: 'own' }, + duly_log_entry: { read: 'own', write: 'own' }, + duly_catalog_item: { read: 'own', write: 'own' }, + duly_assignment: { read: 'own', write: 'own' }, + }, + duly_admin: { + duly_task: { read: 'own', write: 'own' }, + // Org-wide READ of duties; the write axis stays at the baseline. + duly_duty: { read: 'org', write: 'own' }, + duly_log_entry: { read: 'own', write: 'own' }, + // 'full control' of the catalog needs an org WRITE depth: public_read is + // read-open but write-owned, so without it an administrator could only + // edit catalog items they personally created. + duly_catalog_item: { read: 'own', write: 'org' }, + duly_assignment: { read: 'own', write: 'own' }, + }, + }; + + for (const set of ALL_SETS) { + const expected = EXPECTED[set.name]; + + it(`${set.name} grants exactly the five duly objects`, () => { + expect(Object.keys(set.objects).sort()).toEqual(Object.keys(expected).sort()); + // No wildcard grant anywhere. `'*'` plus a super-user bit is a + // package-authored superuser (ADR-0066), and `'*'` alone is a blank + // cheque on every object the platform ships. + expect(Object.keys(set.objects)).not.toContain('*'); + }); + + for (const [objectName, want] of Object.entries(expected)) { + it(`${set.name} · ${objectName} declares readScope=${want.read} writeScope=${want.write}`, () => { + const grant = set.objects[objectName]; + expect(grant, `${set.name} must grant ${objectName}`).toBeDefined(); + expect(scopeOf(grant.readScope)).toBe(want.read); + expect(scopeOf(grant.writeScope)).toBe(want.write); + }); + } + } + + it('no set carries a View All / Modify All bit on any object', () => { + for (const set of ALL_SETS) { + for (const [objectName, grant] of Object.entries(set.objects)) { + expect(grant.viewAllRecords, `${set.name}.${objectName}.viewAllRecords`).not.toBe(true); + expect(grant.modifyAllRecords, `${set.name}.${objectName}.modifyAllRecords`).not.toBe(true); + } + } + }); +}); + +/** + * ⛔ The invariant this whole card exists to hold. + * + * Not "the log entries are private today" — that is a consequence. What is + * pinned is that NOTHING in the authored security model can reach another + * person's work log: no depth scope, no super-user bit, and no sharing rule + * naming a group. A log people believe their skip-level can read is a log + * nobody keeps. + */ +describe('duly_log_entry must not leak', () => { + it('every set — admin included — reads own and only own', () => { + for (const set of ALL_SETS) { + const grant = set.objects.duly_log_entry; + expect(grant, `${set.name} must declare duly_log_entry`).toBeDefined(); + expect(scopeOf(grant.readScope), `${set.name} widened the work log`).toBe('own'); + expect(scopeOf(grant.writeScope), `${set.name} widened work-log writes`).toBe('own'); + expect(grant.viewAllRecords).not.toBe(true); + expect(grant.modifyAllRecords).not.toBe(true); + } + }); + + it('the object itself stays private, so the scope above is the whole story', () => { + // A widened OWD would make every assertion above decorative: depth only + // narrows within `private`. + expect(LogEntry.sharingModel).toBe('private'); + }); + + it('no sharing rule targets the work log', () => { + // Deliberately empty today. The one widening the product allows — a + // record's own `visibility: 'manager'`, reaching that person's manager and + // nobody else — needs a record-relative recipient the platform cannot + // name (objectstack#14103). The recipient it CAN name, + // `position: 'duly_manager'`, would share every marked entry with every + // manager in the tenant, which is the disclosure this suite exists to + // prevent. See src/security/sharing-rules.ts. + for (const rule of dulySharingRules) { + expect(rule.object, 'a work-log sharing rule needs re-reading, not extending').not.toBe( + 'duly_log_entry', + ); + } + }); + + it('the manager-visibility option still exists on the object, unenforced but honest', () => { + // If this ever disappears, the upstream gap stopped mattering and + // sharing-rules.ts is stale — or the field was removed, which is a + // product decision, not a cleanup. + const values = (LogEntry.fields.visibility.options ?? []).map((o) => o.value); + expect(values).toContain('manager'); + }); +}); + +/** + * A manager reads everything below them and writes none of it. Assigning is + * their only write — and this is where that stops being a design intention. + */ +describe('no write scope on duly_task / duly_duty is wider than own', () => { + for (const set of ALL_SETS) { + for (const objectName of ['duly_task', 'duly_duty']) { + it(`${set.name} · ${objectName}`, () => { + const grant = set.objects[objectName]; + expect(SCOPE_WIDTH[scopeOf(grant.writeScope)]).toBe(SCOPE_WIDTH.own); + }); + } + } + + it('a manager’s only write bit anywhere below them is on duly_assignment', () => { + // Everything the manager set can create or edit, over and above what the + // member set already could. + const memberWrites = new Set( + Object.entries(MemberPermissionSet.objects) + .filter(([, g]) => g.allowCreate === true || g.allowEdit === true || g.allowDelete === true) + .map(([name]) => name), + ); + const managerWrites = Object.entries(ManagerPermissionSet.objects) + .filter(([, g]) => g.allowCreate === true || g.allowEdit === true || g.allowDelete === true) + .map(([name]) => name) + .filter((name) => !memberWrites.has(name)); + expect(managerWrites).toEqual(['duly_assignment']); + }); + + it('both objects are private, so the write depth is the enforced boundary', () => { + expect(Task.sharingModel).toBe('private'); + expect(Duty.sharingModel).toBe('private'); + }); +}); + +/** + * "Inherit rather than restate" — asserted structurally, not by eyeballing the + * source. Every entry the model does not deliberately widen must be the SAME + * OBJECT the member set declared, which is only true if the manager and admin + * maps were built by spreading it. + */ +describe('manager and admin inherit rather than restate', () => { + const MANAGER_OVERRIDES = ['duly_assignment']; + const ADMIN_OVERRIDES = ['duly_catalog_item', 'duly_duty']; + + it('every non-overridden manager entry is the member entry itself', () => { + for (const [name, grant] of Object.entries(ManagerPermissionSet.objects)) { + if (MANAGER_OVERRIDES.includes(name)) continue; + expect(grant, `${name} was restated instead of inherited`).toEqual( + MemberPermissionSet.objects[name], + ); + } + }); + + it('every non-overridden admin entry is the manager entry itself', () => { + for (const [name, grant] of Object.entries(AdminPermissionSet.objects)) { + if (ADMIN_OVERRIDES.includes(name)) continue; + expect(grant, `${name} was restated instead of inherited`).toEqual( + ManagerPermissionSet.objects[name], + ); + } + }); + + it('an override keeps every base key it does not change', () => { + // The failure this catches: rewriting an entry from scratch and dropping + // `writeScope: 'own'` on the way, which widens by omission. + const base = ManagerPermissionSet.objects.duly_duty; + const override = AdminPermissionSet.objects.duly_duty; + const CHANGED = ['readScope', 'allowEdit']; // the two deliberate widenings + for (const key of Object.keys(base) as Array) { + if (CHANGED.includes(key)) continue; + expect(override[key], `admin.duly_duty dropped ${String(key)}`).toEqual(base[key]); + } + expect(override.allowEdit, 'admin edits duties for corrections').toBe(true); + expect(base.allowEdit, 'a manager does not edit duties').not.toBe(true); + // The one that must survive both widenings. + expect(override.writeScope, 'admin.duly_duty write depth must stay own').toBe('own'); + }); + + it('no set is narrower than the one it inherits from, on any object bit', () => { + const bits = ['allowCreate', 'allowRead', 'allowEdit', 'allowDelete'] as const; + const chain: Array<[typeof MemberPermissionSet, typeof ManagerPermissionSet]> = [ + [MemberPermissionSet, ManagerPermissionSet], + [ManagerPermissionSet, AdminPermissionSet], + ]; + for (const [base, derived] of chain) { + for (const [name, baseGrant] of Object.entries(base.objects)) { + for (const bit of bits) { + if (baseGrant[bit] !== true) continue; + expect( + derived.objects[name]?.[bit], + `${derived.name} lost ${bit} on ${name} that ${base.name} grants`, + ).toBe(true); + } + } + } + }); +}); + +/** + * #30 and #40 — the invoke-time capability gate. + * + * All five actions run their handlers against `ctx.engine`, the trusted facade + * that bypasses RLS and FLS by design, so object permissions never see those + * writes. `requiredPermissions` is the only boundary there is, and these + * assertions are what keep it from becoming a string that nothing grants. + */ +describe('action capability gates', () => { + const byName = new Map(dulyActions.map((a) => [a.name, a])); + const GATES: Record = { + duly_catalog_apply: DULY_CATALOG_APPLY, + duly_catalog_sync: DULY_CATALOG_SYNC, + duly_task_complete: DULY_TASK_UPDATE_STATUS, + duly_task_undo: DULY_TASK_UPDATE_STATUS, + duly_task_skip: DULY_TASK_UPDATE_STATUS, + }; + + it('every action this app ships declares a gate — none left open', () => { + // Written over `dulyActions` rather than over the five known names, so a + // SIXTH action added later fails here instead of shipping ungated. + for (const action of dulyActions) { + expect( + action.requiredPermissions?.length, + `${action.name} declares no requiredPermissions — its handler runs on the RLS-bypassing facade`, + ).toBeGreaterThan(0); + } + }); + + for (const [actionName, capability] of Object.entries(GATES)) { + it(`${actionName} requires ${capability}`, () => { + expect(byName.get(actionName)?.requiredPermissions).toEqual([capability]); + }); + } + + it('every capability an action requires is granted by a set in this package', () => { + // The link the action files cannot carry themselves: their file surface is + // the `requiredPermissions` key, so they hard-code the string rather than + // importing it. This is what turns a typo into a red test instead of a + // button nobody can press. + const granted = new Set(dulyPermissionSets.flatMap((s) => s.systemPermissions ?? [])); + for (const action of dulyActions) { + for (const capability of action.requiredPermissions ?? []) { + expect( + granted, + `${action.name} requires "${capability}", which no permission set grants`, + ).toContain(capability); + } + } + }); + + it('the catalog capabilities are held by duly_admin and by nobody weaker', () => { + for (const capability of [DULY_CATALOG_APPLY, DULY_CATALOG_SYNC]) { + expect(AdminPermissionSet.systemPermissions).toContain(capability); + expect(MemberPermissionSet.systemPermissions ?? []).not.toContain(capability); + expect(ManagerPermissionSet.systemPermissions ?? []).not.toContain(capability); + } + }); + + it('apply and sync are separable — syncing rewrites cadence org-wide', () => { + expect(DULY_CATALOG_APPLY).not.toBe(DULY_CATALOG_SYNC); + }); + + it('status entry is a member capability, so every set inherits it', () => { + for (const set of ALL_SETS) { + expect(set.systemPermissions).toContain(DULY_TASK_UPDATE_STATUS); + } + }); +}); + +/** + * ⛔ STOPGAP — delete this block together with `HIERARCHY_SCOPES_DEFERRED`. + * + * It pins the compromise in both directions so it cannot rot: widen a grant + * without deleting its row and the "authored" assertion fails; delete a row + * without widening the grant and the coverage assertion fails. See duly#46. + */ +describe('deferred hierarchy scopes (stopgap — see #46)', () => { + const setsByName = new Map(dulyPermissionSets.map((s) => [s.name, s])); + + for (const [key, record] of Object.entries(HIERARCHY_SCOPES_DEFERRED)) { + const [setName, objectName, axis] = key.split('.'); + + it(`${key} is authored '${record.authored}', not the intended '${record.intended}'`, () => { + const grant = setsByName.get(setName)?.objects[objectName]; + expect(grant, `${setName} must grant ${objectName}`).toBeDefined(); + expect(scopeOf(grant?.[axis as 'readScope' | 'writeScope'])).toBe(record.authored); + + // The intent must still be a scope `validateHierarchyScopeCapability` + // rejects. If it stops being one, the deferral has no reason to exist. + expect(['own_and_reports', 'unit', 'unit_and_below']).toContain(record.intended); + // And it must be a WIDENING — a deferral that narrows is a mistake. + expect(SCOPE_WIDTH[record.intended]).toBeGreaterThan(SCOPE_WIDTH[record.authored]); + }); + } + + it('records every grant the card asked to widen, and no others', () => { + expect(Object.keys(HIERARCHY_SCOPES_DEFERRED).sort()).toEqual([ + 'duly_admin.duly_task.readScope', + 'duly_manager.duly_duty.readScope', + 'duly_manager.duly_task.readScope', + ]); + }); + + it('never defers a WRITE axis — that one is an invariant, not a compromise', () => { + for (const key of Object.keys(HIERARCHY_SCOPES_DEFERRED)) { + expect(key.endsWith('.readScope'), `${key} must not defer a write scope`).toBe(true); + } + }); +}); + +describe('objects the sets grant are the objects this app ships', () => { + it('grants nothing outside the duly namespace', () => { + const shipped = new Set([Task, Duty, LogEntry, CatalogItem, Assignment].map((o) => o.name)); + for (const set of ALL_SETS) { + for (const objectName of Object.keys(set.objects)) { + expect(shipped, `${set.name} grants "${objectName}", which this app does not ship`).toContain( + objectName, + ); + } + } + }); + + it('every shipped object is reachable by someone', () => { + // A private object with no grant in any set is invisible to every + // non-admin caller — the object-level 403 that reads as "the tab is empty". + const granted = new Set(ALL_SETS.flatMap((s) => Object.keys(s.objects))); + for (const object of [Task, Duty, LogEntry, CatalogItem, Assignment]) { + expect(granted, `${object.name} is granted by no permission set`).toContain(object.name); + } + }); +});