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
42 changes: 42 additions & 0 deletions .changeset/23268-external-sharing-model-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/plugin-security': patch
---

feat(spec)!: retire the object's `externalSharingModel` — the external org-wide default no principal ever read (#23268)

**BREAKING** — `externalSharingModel` on an object (`ObjectSchema`, the `object` metadata type) is now refused at parse. ADR-0090 D11 declared it as a second org-wide default for portal and partner principals, and nothing ever evaluated it: no request resolves an external principal, and the authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) admits explicitly shared rows only, never an org-wide default. Setting `externalSharingModel: 'private'` hid nothing from anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share (a manual share or a sharing rule), and an anonymous reader through a share link (`publicSharing`, unchanged).

Clause-②: no (narrowing)

Measured before removal, at `e4ec6ec1a`: no row-pipeline reader in any package. The key's only readers were authoring checks of its own ordering against `sharingModel` — the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison — plus the Studio console's object settings select (a separate console change). The only authored instances in this repository were two showcase objects.

## FROM → TO

| you wrote (17.7 and earlier) | write instead |
| --- | --- |
| `externalSharingModel: 'private'` (or `'public_read'`, `'public_read_write'`, `'controlled_by_parent'`) on an object | nothing — delete the key; share rows explicitly where an outside principal needs them |
| `import { SECURITY_EXTERNAL_WIDER } from '@objectstack/lint'` | nothing — the rule `security-external-wider-than-internal` is removed with the key it judged |

**The one-line fix:** delete `externalSharingModel` from every object.

**What an author who still writes it sees.** `tsc` fails at the authoring site (`ObjectSchema` types the key as retired), and the parse — `ObjectSchema.create()`, `defineStack({ objects })`, `PUT /api/v1/meta/object/:name` (draft saves included) — refuses it at its own path with the prescription:

> `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.

## The retirement kit

- **A `retiredKey()` tombstone** on `ObjectSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Object:externalSharingModel`.
- **The D2 conversion `object-external-sharing-model-removed`** (protocol 18, retired from the load path) deletes the key from every `objects[]` entry, one notice per object, as a lossless delete. Studio's object settings offered the select and `PUT /api/v1/meta/object` persisted it until this release, so a stored `object` row can carry it; the rehydration seams replay this conversion over stored rows, and `os migrate meta --stored` lists the rows that carry it before the upgrade.
- **The D3 entry `object-external-sharing-model-retired`** asks the author whether anything they built assumed the key kept outside users away from an object's rows. Nothing ever enforced that.
- **Ledger:** the row goes `planned` → `dead`, kept as a tombstone row (a tombstone keeps the key in the walked shape).
- **No deprecation window**, per the project's startup-stage posture.

**`@objectstack/lint`:** the `security-external-wider-than-internal` rule and its exported `SECURITY_EXTERNAL_WIDER` id are removed, and `security-owd-alias` no longer reads `externalSharingModel`; the parse refuses the key on every parsed door. On an unparsed intake (`os lint` on a raw config) the ledger-driven advisory reports it as `liveness-dead-property`.

**`@objectstack/plugin-security`:** the object posture gate's env-tighten-only rule (R1) compares `sharingModel` alone; the external half left with the key.

⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.

<!-- adr-0087: registered object-external-sharing-model-removed, object-external-sharing-model-retired -->
15 changes: 8 additions & 7 deletions content/docs/permissions/authorization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ site — the file you read when behavior surprises you.
| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data` and the metadata endpoints (`/meta`) — the raw-hono standard `/data` routes left the matrix when that duplicate surface was deleted in v17 (#4073), and the dispatcher GraphQL endpoint left when the GraphQL surface was removed (`/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Unconditional** (#3963, closing out ADR-0056 D2): the `api.requireAuth: false` opt-out was retired in v17 — the key is tombstoned, so authoring it is a parse error, and anonymous callers are denied on every data surface with no deployment-level escape hatch. Narrower public surfaces do **not** need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (`/auth`, `/health`, `/discovery`) is allow-listed; public form submission carries a `publicFormGrant` (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous **GET** of the book/doc read surface is admitted so `book.audience: 'public'` works under the secure default, with the ADR-0046 §6.7 audience gate — `'public'` only, fail-closed — doing the authorizing (#3963). | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth` and the dispatcher `handleMetadata`/`handleAI` (default in `packages/spec/src/api/rest-server.zod.ts`); a ratchet in `authz-conformance.test.ts` enumerates these entry points from source across a curated file list, and fails CI if a new surface ships ungated in one of those files | fail-closed |
| 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow |
| 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 |
| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |
| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |
| 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If applicable policies exist but **none of them compiles**, the result is a deny-all sentinel (fail-closed); an object with **no** applicable policy is simply unfiltered at this layer — RLS narrows what the earlier gates already allowed, it never denies on its own. An uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed |
| 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. Caller queries that **filter, sort, group, or aggregate by** a non-readable field are rejected outright (HTTP 403, `field_predicate_denied`) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like `owner_id`). | `packages/plugins/plugin-security/src/field-masker.ts` + `predicate-guard.ts` | fail-closed on predicates; see posture note below |

Expand Down Expand Up @@ -506,8 +506,7 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
save with `422 INVALID_METADATA` and the rule id in `issues`): unset OWD
on custom objects (`security-owd-unset` — an object publish with no
authored `sharingModel` is refused; absence is not a decision, at the CLI
and at the runtime door alike), retired OWD aliases, an external dial
wider than internal (`security-external-wider-than-internal`), `'*'`
and at the runtime door alike), retired OWD aliases, `'*'`
wildcards carrying View/Modify All outside the platform admin set,
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
"role" in security identifiers, a `controlled_by_parent` object without
Expand All @@ -526,7 +525,7 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
no lint rule can judge, enforced on every runtime-authored object body —
Studio drafts, REST saves, AI builders. An environment overlay of a
**packaged** object is refused outright, whether it tightens or widens
`sharingModel` / `externalSharingModel`, and with `OS_METADATA_WRITABLE`
`sharingModel`, and with `OS_METADATA_WRITABLE`
set or not (`403 NOT_OVERRIDABLE` — managed content is sealed, ADR-0131
D6): change the posture in the package source and publish instead. The
package door answers ahead of this gate, so its packaged-baseline rule
Expand All @@ -535,9 +534,11 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
stored metadata keeps loading unchanged. The gate's former second rule
(`403 owd_external_wider`, external ≤ internal) was **retired as a
duplicate** when the lint block crossed to the runtime door (#8310
maintainer ruling): the 422 lint door answers first for external-wider
and for unauthored-OWD bodies, and the 403 gate remains for
packaged-baseline widening only.
maintainer ruling): the 422 lint door answers first for unauthored-OWD
bodies, and the 403 gate remains for packaged-baseline widening only. The
key that rule ordered, `externalSharingModel`, has since been removed
(ADR-0049): the object schema refuses it with the prescription "delete the
key".
- **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` /
`diffAccessMatrix`): with `access-matrix.json` committed next to the config,
`os compile` fails on any capability drift with semantic lines
Expand Down
35 changes: 13 additions & 22 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,28 +122,19 @@ only narrows, so it cannot make a `private` row visible. On `public_read` and
> a matching read grant — the rows they admit stay unwritable until the caller
> can also read them. See [RLS — write targets](/docs/permissions/rls#anatomy-of-a-policy).

## The external dial — `externalSharingModel` (ADR-0090 D11)

Portal/partner scenarios get a second, independent dial with the same enum:

```typescript
sharingModel: 'public_read', // internal baseline
externalSharingModel: 'private', // external principals: own + shares only
```

- Defaults to `private`; **external must never be wider than internal**
(`private < public_read < public_read_write`) — the linter errors on a
wider external dial (`security-external-wider-than-internal`).
- "External" is a property of the **principal** (`audience: 'external'`),
never of the object. The BU depth axis does not apply to externals; their
visibility = own records + explicit shares + the external OWD.
- **Status: declared, not yet evaluated at runtime.** Today the dial is
authoring-validated (the linter above) and surfaced in Studio (the `Ext`
badge per object row); the evaluator branch that substitutes it for
external principals lands with the principal-taxonomy semantics phase
(tracked on #2696, liveness: `planned`). Until then no request is
evaluated as external — authoring the dial prepares your model without
changing behavior.
## External principals — no external org-wide default

There is one org-wide default, `sharingModel`. An external (portal or
partner) principal reaches **explicitly shared rows only** — the authorization
kernel's `EXTERNAL` rung (ADR-0095), never derived from an org-wide default.
Grant it a row with a manual share or a sharing rule, or publish a record
through a share link (`publicSharing`).

`externalSharingModel`, the external dial ADR-0090 D11 declared, was removed
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): no request ever
resolved an external principal, so the value changed no answer. The schema
refuses it with the prescription "delete the key";
`os migrate meta --from 17` lists the mechanical edits for existing sources.

## Criteria-Based Sharing Rules

Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ the only trace is a server-side `WARN` naming the object, the field, and both re

## 5. Sharing Rules & Organization-Wide Defaults

The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). An optional `externalSharingModel` (same enum, default `private`, never wider than the internal value) is the stricter dial for external portal/partner principals (ADR-0090 D11). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.
The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). There is no separate external default: an external portal/partner principal reaches explicitly shared rows only (ADR-0095). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.

```yaml
# account.object.yml
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,7 @@ Metadata query with filtering, sorting, and pagination
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. |
Expand Down
Loading
Loading