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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
202 changes: 202 additions & 0 deletions docs/deployment/security.md
Original file line numberDiff line numberDiff line change
@@ -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.
13 changes: 13 additions & 0 deletions src/actions/catalog.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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',
Expand Down
15 changes: 15 additions & 0 deletions src/actions/task.actions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.',
});
Expand DownExpand Up@@ -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',
Expand Down
47 changes: 44 additions & 3 deletions src/security/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Loading
Loading