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
54 changes: 54 additions & 0 deletions .changeset/sharing-access-level-full-removed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
"@objectstack/plugin-security": patch
---

fix(sharing): remove the `full` access level — it promised delete/transfer/share and granted `edit` (#3865)

`sys_sharing_rule.access_level` / `sys_record_share.access_level` offered three
levels, the third documented as **Full Access (Transfer, Share, Delete)**. No
code path granted transfer, re-share, or delete because of it: both enforcement
sites matched `access_level in ('edit','full')`, so `full` was byte-equivalent
to `edit`. An admin picking "Full Access" in Setup was told they had granted
delete rights and had not — declared-but-unenforced metadata (ADR-0078,
ADR-0049), the same defect that retired the `queue` recipient before it.

Measured on showcase, a `full` recipient got `read: allowed`, `update: allowed`,
`delete: DENIED` — and the denial came from `decidedBy=object_crud`, i.e. the
object-level CRUD gate rejected the delete *before* sharing was consulted at
all. That is not an oversight to patch around; it is the model working. Record
sharing widens **which rows** a principal reaches, never **which verbs** they
may use — the same split Salesforce enforces (its sharing rules stop at
Read-Only / Read-Write; Full Access is owner / hierarchy / Modify All only,
never grantable by a rule) and Dataverse enforces by AND-ing every shared access
right against the security role's own privilege. Delete and transfer belong to
ownership, the ADR-0057 DEPTH scopes, and admin scope.

**What changed**

- `SharingLevel` (spec/security) and `ShareAccessLevel` (spec/contracts) are now
`read | edit`. The `Field.select` on both objects offers the same two, so the
Setup dropdown no longer shows the misleading option.
- `SharingService.grant()` and `SharingRuleService.defineRule()` gained the
access-level validation they never had: `full` normalises to `edit`, and an
unrecognised level is a `VALIDATION_FAILED` (HTTP 400) instead of being
persisted verbatim as a grant no gate would ever match.
- Enforcement stays deliberately wider than authoring — the read/write gates
still match `edit`/`full` — so a row written before this release keeps
working. Narrowing them would silently *revoke* access.
- A boot backfill normalises stored `full` rows on both tables, and the
`sharing-rule-access-level-full-to-edit` conversion rewrites declarative
stacks at load, so nothing needs consumer action.

**Migration.** None. `full` and `edit` were already behaviourally identical, so
rewriting one to the other cannot change an access decision — unlike the OWD
`sharingModel: 'full'` alias retired in ADR-0090 D4, which changed posture and
had to be delegated to the author. A stack that still authors `accessLevel:
'full'` converts at load with a deprecation notice; stored rows normalise at
next boot. Code that pinned the `ShareAccessLevel` type to `'full'` no longer
compiles — use `'edit'`.

Reviving a real per-record delete grant is a separate design (a capability mask
AND-ed with object CRUD, plus the share-administration model that would have to
authorise re-sharing), not a fourth enum member.
2 changes: 1 addition & 1 deletion content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export const AccountTeamSharingRule = defineSharingRule({
// Who to share with (a single recipient — see the recipient types below)
sharedWith: { type: 'position', value: 'sales_manager' },

// Access level granted: read | edit | full
// Access level granted: read | edit
accessLevel: 'edit',
});
```
Expand Down
4 changes: 2 additions & 2 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ Share records whose fields match a CEL predicate (`CriteriaSharingRuleSchema` in
name: share_enterprise_accounts
type: criteria
object: account
accessLevel: read # read | edit | full
accessLevel: read # read | edit
condition: 'record.account_type == "Enterprise"'
sharedWith:
type: position # user | group | position | unit_and_subordinates | guest
Expand Down Expand Up @@ -308,7 +308,7 @@ sharedWith:

> **Enforcement status.** Criteria rules with `user` / `position` / `unit_and_subordinates` recipients compile and enforce (the CEL condition lowers to a runtime filter that materializes `sys_record_share` grants, ADR-0058 D3). Owner-type rules and `group`/`guest` recipients are `[experimental — not enforced]`: the seed bootstrap skips them (logged) rather than seeding a permissive match-all (ADR-0049).

> `accessLevel` is one of `read`, `edit`, or `full`. `full` additionally grants transfer/share/delete.
> `accessLevel` is one of `read` or `edit`. Sharing widens **which rows** a principal reaches, never **which verbs** they may use — delete and transfer come from ownership, the ADR-0057 DEPTH scopes, or admin scope, and are checked by the object-level CRUD gate before sharing is consulted at all. A third level `full` ("Full Access — transfer/share/delete") was authorable through protocol 16 but never granted any of those verbs: both enforcement sites matched `edit`/`full` alike, so it was equivalent to `edit` while telling admins otherwise, and it was removed (#3865, ADR-0078). Stacks still authoring it are rewritten to `edit` at load by the `sharing-rule-access-level-full-to-edit` conversion.

### Public Share Links

Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
| :--- | :--- | :--- | :--- |
| **kind** | `Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| 'team' \| 'territory' \| 'rls_policy'>` | ✅ | The row-visibility source kind evaluated for this record at this layer. |
| **name** | `string` | ✅ | Stable identifier of the concrete rule, share, or policy that was evaluated. |
| **grants** | `Enum<'read' \| 'edit' \| 'full'>` | optional | Access level a sharing source grants on the record (mirrors SharingLevel). |
| **grants** | `Enum<'read' \| 'edit' \| 'full'>` | optional | Access level a sharing source grants on the record (authorable: read/edit; `full` appears only for legacy rows pending normalisation). |
| **via** | `string` | optional | How the rule reached the principal — recipient group/position, ownership, or the matching criteria. |
| **predicate** | `any` | optional | The row predicate this rule contributed, when it is filter-shaped (null = unrestricted). |
| **effect** | `Enum<'admits' \| 'excludes' \| 'neutral'>` | ✅ | The rule's effect on THIS record: admits, excludes, or neutral. |
Expand Down
5 changes: 2 additions & 3 deletions content/docs/references/security/sharing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const result = CriteriaSharingRule.parse(data);
| **description** | `string` | optional | Administrative notes |
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'criteria'` | ✅ | |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.department == "Sales"` |
Expand Down Expand Up @@ -75,7 +75,6 @@ const result = CriteriaSharingRule.parse(data);

* `read`
* `edit`
* `full`


---
Expand All @@ -91,7 +90,7 @@ const result = CriteriaSharingRule.parse(data);
| **description** | `string` | optional | Administrative notes |
| **object** | `string` | ✅ | Target Object Name |
| **active** | `boolean` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit' \| 'full'>` | optional | |
| **accessLevel** | `Enum<'read' \| 'edit'>` | optional | |
| **sharedWith** | `{ type: Enum<'user' \| 'team' \| 'position' \| 'unit_and_subordinates' \| 'business_unit'>; value: string }` | ✅ | The recipient of the shared access |
| **type** | `'criteria'` | ✅ | |
| **condition** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.department == "Sales"` |
Expand Down
2 changes: 1 addition & 1 deletion docs/notes/crm-development-standards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export const MySharingRule = defineSharingRule({
value: 'role_name',
},

accessLevel: 'edit', // 'read' | 'edit' | 'full'
accessLevel: 'edit', // 'read' | 'edit'
});
```

Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/plugin-security/src/explain-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,15 @@ export interface ExplainEngineDeps {
fetchRecord?: (object: string, recordId: string, engineOperation: string) => Promise<Record<string, unknown> | null>;
/** The sharing service's own read-filter contribution for the object (owner-match OR granted-ids), same as enforcement AND-s in. */
sharingReadFilter?: (object: string, context: any) => Promise<unknown | null>;
/** The concrete `sys_record_share` rows attached to the record (for `rules[]` attribution). */
/**
* The concrete `sys_record_share` rows attached to the record (for `rules[]`
* attribution).
*
* `access_level` stays wider than the authorable `read`/`edit`: explain
* REPORTS stored rows, and a `full` row written before #3865 retired that
* level (normalised to `edit` by the sharing plugin's boot backfill) must
* still be explainable rather than crash the panel.
*/
listRecordShares?: (
object: string,
recordId: string,
Expand Down
100 changes: 100 additions & 0 deletions packages/plugins/plugin-sharing/src/access-level.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#3865] The access-level vocabulary after `full` was retired.
*
* `full` was declared "Full Access (Transfer, Share, Delete)" but every
* enforcement site matched `access_level in ('edit','full')`, so it granted
* exactly what `edit` grants while telling admins otherwise — the
* declared-but-unenforced trap ADR-0078 bans. These tests pin the three-layer
* posture that replaced it: authoring rejects, enforcement tolerates, data
* normalises.
*/

import { describe, it, expect } from 'vitest';
import {
ACCESS_LEVELS,
WRITE_ACCESS_LEVELS,
isKnownAccessLevel,
normalizeAccessLevel,
normalizeStoredAccessLevel,
} from './access-level.js';

describe('access-level vocabulary', () => {
it('offers exactly read and edit for authoring', () => {
expect([...ACCESS_LEVELS]).toEqual(['read', 'edit']);
});

it('keeps the write gate wider than the authorable set', () => {
// The whole point of the tolerance layer: a row persisted before the boot
// backfill ran must keep working. If this ever narrows to ['edit'], every
// un-migrated `full` grant silently loses write access.
expect([...WRITE_ACCESS_LEVELS]).toEqual(['edit', 'full']);
});
});

describe('normalizeAccessLevel (authoring path)', () => {
it('passes the authorable levels through unchanged', () => {
expect(normalizeAccessLevel('read')).toBe('read');
expect(normalizeAccessLevel('edit')).toBe('edit');
});

it("normalises the retired 'full' to 'edit'", () => {
// Lossless precisely because `full` was inert — the two already behaved
// identically, so no principal gains or loses anything.
expect(normalizeAccessLevel('full')).toBe('edit');
});

it('throws VALIDATION_FAILED on an unrecognised level', () => {
// Coercing an unknown level would recreate the bug in a new spot: a grant
// that looks issued and that no gate matches.
expect(() => normalizeAccessLevel('admin')).toThrow(/VALIDATION_FAILED/);
expect(() => normalizeAccessLevel('owner')).toThrow(/VALIDATION_FAILED/);
expect(() => normalizeAccessLevel(7)).toThrow(/VALIDATION_FAILED/);
});

it('applies the fallback only for a missing value', () => {
expect(normalizeAccessLevel(undefined, 'read')).toBe('read');
expect(normalizeAccessLevel(null, 'edit')).toBe('edit');
expect(normalizeAccessLevel('', 'read')).toBe('read');
// A PRESENT but invalid value must never fall back — that would swallow
// the author's mistake.
expect(() => normalizeAccessLevel('nope', 'read')).toThrow(/VALIDATION_FAILED/);
});

it('requires a level when no fallback is supplied', () => {
expect(() => normalizeAccessLevel(undefined)).toThrow(/VALIDATION_FAILED/);
});
});

describe('normalizeStoredAccessLevel (read path)', () => {
it('normalises known levels without throwing', () => {
expect(normalizeStoredAccessLevel('read')).toBe('read');
expect(normalizeStoredAccessLevel('edit')).toBe('edit');
expect(normalizeStoredAccessLevel('full')).toBe('edit');
});

it('fails CLOSED to read on unrecognised or missing stored data', () => {
// Never throws: projecting an existing row must not take down the rule
// evaluator or an admin list view over one bad byte. Degrades to the
// NARROWEST level rather than trusting it.
expect(normalizeStoredAccessLevel('admin')).toBe('read');
expect(normalizeStoredAccessLevel(undefined)).toBe('read');
expect(normalizeStoredAccessLevel(null)).toBe('read');
expect(normalizeStoredAccessLevel({})).toBe('read');
});
});

describe('isKnownAccessLevel', () => {
it('accepts authorable and retired-but-stored levels', () => {
expect(isKnownAccessLevel('read')).toBe(true);
expect(isKnownAccessLevel('edit')).toBe(true);
expect(isKnownAccessLevel('full')).toBe(true);
});

it('rejects anything else', () => {
expect(isKnownAccessLevel('admin')).toBe(false);
expect(isKnownAccessLevel(undefined)).toBe(false);
expect(isKnownAccessLevel(1)).toBe(false);
});
});
126 changes: 126 additions & 0 deletions packages/plugins/plugin-sharing/src/access-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Share access-level vocabulary — the one place `read` / `edit` (and the
* retired `full`) are defined for this plugin.
*
* ## Why `full` is gone (#3865)
*
* `full` was declared as "Full Access (Transfer, Share, Delete)" but **no code
* path ever granted transfer, re-share, or delete because of it**: the read
* gate (`buildReadFilter`), the bulk-write gate (`buildWriteFilter`) and the
* per-record gate (`canEdit`) all matched `access_level in ('edit','full')`, so
* it was byte-equivalent to `edit`. An admin picking "Full Access" in Setup was
* told they had granted delete rights and had not — declared-but-unenforced
* metadata, the exact authoring trap ADR-0078 / ADR-0049 ban, and the reason
* `recipient_type: 'queue'` was removed before it.
*
* It is not coming back as an enum member. Record sharing widens **which rows**
* a principal reaches, never **which verbs** they may use — the same split
* Salesforce enforces (its sharing rules stop at Read-Only / Read-Write; Full
* Access is owner / hierarchy / Modify All only, never grantable by a rule) and
* Dataverse enforces by AND-ing every shared access right against the security
* role's own privilege. Delete and transfer belong to ownership, the ADR-0057
* DEPTH scopes, and admin scope. A future per-record delete grant would be a
* capability mask AND-ed with object CRUD, not a fourth level.
*
* ## The three-layer posture (ADR-0090 D4 idiom)
*
* 1. **Authoring rejects** — `SharingLevel` / the `Field.select` enums no longer
* offer `full`; {@link normalizeAccessLevel} turns an explicit `full` from an
* older client into `edit` and rejects anything unrecognised outright.
* 2. **Runtime tolerates** — the enforcement gates keep matching
* {@link WRITE_ACCESS_LEVELS}, so a row persisted before the backfill ran is
* still honoured (it means `edit`; refusing it would silently REVOKE access).
* 3. **Data normalises** — a boot backfill rewrites stored `full` rows to
* `edit`. Behaviour-preserving by construction, because the two were already
* equivalent.
*/

import type { ShareAccessLevel } from '@objectstack/spec/contracts';

/** The authorable levels, in widening order. */
export const ACCESS_LEVELS: readonly ShareAccessLevel[] = ['read', 'edit'];

/**
* Retired spellings that map losslessly onto an authorable level.
*
* `full` → `edit` is lossless *because* `full` was inert: the two already
* behaved identically, so rewriting one to the other cannot change any access
* decision. (Contrast the OWD `sharingModel: 'full'` alias retired in ADR-0090
* D4, which had no equivalent target and had to be delegated to the author.)
*/
const RETIRED_ACCESS_LEVELS: Readonly<Record<string, ShareAccessLevel>> = {
full: 'edit',
};

/**
* Levels that open the write gate, INCLUDING the retired `full`.
*
* Deliberately wider than {@link ACCESS_LEVELS}: enforcement reads persisted
* rows, which may predate normalisation. Dropping `full` here would silently
* revoke access from every not-yet-migrated grant — a fail-open→fail-closed
* flip nobody asked for. Authoring narrowness and enforcement tolerance are
* different jobs.
*/
export const WRITE_ACCESS_LEVELS: readonly string[] = ['edit', 'full'];

/** `true` when `value` is a stored level this plugin still honours. */
export function isKnownAccessLevel(value: unknown): boolean {
return typeof value === 'string'
&& (ACCESS_LEVELS.includes(value as ShareAccessLevel) || value in RETIRED_ACCESS_LEVELS);
}

/**
* Normalise an inbound access level to the authorable vocabulary.
*
* - `read` / `edit` pass through.
* - `full` normalises to `edit` — quietly, because it is what `full` already
* meant; failing an old client's request would be a regression with no
* security benefit.
* - anything else (including `null`/`undefined` when no `fallback` is given)
* throws `VALIDATION_FAILED`, which the REST layer maps to a 400. Silently
* coercing an unrecognised level would be the same silent-inertness bug in a
* new spot: a caller asking for `admin` must be told it does not exist, not
* handed a `read` grant they did not request.
*
* @param value the inbound level (typically from an HTTP body or a rule row)
* @param fallback level to use when `value` is null/undefined; omit to require one
*/
export function normalizeAccessLevel(
value: unknown,
fallback?: ShareAccessLevel,
): ShareAccessLevel {
if (value == null || value === '') {
if (fallback !== undefined) return fallback;
throw new Error('VALIDATION_FAILED: accessLevel is required');
}
if (typeof value === 'string') {
if (ACCESS_LEVELS.includes(value as ShareAccessLevel)) return value as ShareAccessLevel;
const retired = RETIRED_ACCESS_LEVELS[value];
if (retired) return retired;
}
throw new Error(
`VALIDATION_FAILED: accessLevel must be one of ${ACCESS_LEVELS.join(' | ')} `
+ `(received ${JSON.stringify(value)})`,
);
}

/**
* Normalise a level read back OUT of storage. Never throws.
*
* The write-path twin ({@link normalizeAccessLevel}) rejects an unrecognised
* level so the caller learns immediately. A read path cannot do that: the row
* already exists, and throwing while projecting it would take down the rule
* evaluator or the admin list view over one bad byte. So this one fails
* **closed** — an unrecognised stored level degrades to `read`, the narrowest
* level, rather than being trusted or crashing (the same fail-closed posture
* `effectiveSharingModel` takes for an unknown `sharingModel`).
*/
export function normalizeStoredAccessLevel(value: unknown): ShareAccessLevel {
try {
return normalizeAccessLevel(value, 'read');
} catch {
return 'read';
}
}
Loading
Loading