|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Share access-level vocabulary — the one place `read` / `edit` (and the |
| 5 | + * retired `full`) are defined for this plugin. |
| 6 | + * |
| 7 | + * ## Why `full` is gone (#3865) |
| 8 | + * |
| 9 | + * `full` was declared as "Full Access (Transfer, Share, Delete)" but **no code |
| 10 | + * path ever granted transfer, re-share, or delete because of it**: the read |
| 11 | + * gate (`buildReadFilter`), the bulk-write gate (`buildWriteFilter`) and the |
| 12 | + * per-record gate (`canEdit`) all matched `access_level in ('edit','full')`, so |
| 13 | + * it was byte-equivalent to `edit`. An admin picking "Full Access" in Setup was |
| 14 | + * told they had granted delete rights and had not — declared-but-unenforced |
| 15 | + * metadata, the exact authoring trap ADR-0078 / ADR-0049 ban, and the reason |
| 16 | + * `recipient_type: 'queue'` was removed before it. |
| 17 | + * |
| 18 | + * It is not coming back as an enum member. Record sharing widens **which rows** |
| 19 | + * a principal reaches, never **which verbs** they may use — the same split |
| 20 | + * Salesforce enforces (its sharing rules stop at Read-Only / Read-Write; Full |
| 21 | + * Access is owner / hierarchy / Modify All only, never grantable by a rule) and |
| 22 | + * Dataverse enforces by AND-ing every shared access right against the security |
| 23 | + * role's own privilege. Delete and transfer belong to ownership, the ADR-0057 |
| 24 | + * DEPTH scopes, and admin scope. A future per-record delete grant would be a |
| 25 | + * capability mask AND-ed with object CRUD, not a fourth level. |
| 26 | + * |
| 27 | + * ## The three-layer posture (ADR-0090 D4 idiom) |
| 28 | + * |
| 29 | + * 1. **Authoring rejects** — `SharingLevel` / the `Field.select` enums no longer |
| 30 | + * offer `full`; {@link normalizeAccessLevel} turns an explicit `full` from an |
| 31 | + * older client into `edit` and rejects anything unrecognised outright. |
| 32 | + * 2. **Runtime tolerates** — the enforcement gates keep matching |
| 33 | + * {@link WRITE_ACCESS_LEVELS}, so a row persisted before the backfill ran is |
| 34 | + * still honoured (it means `edit`; refusing it would silently REVOKE access). |
| 35 | + * 3. **Data normalises** — a boot backfill rewrites stored `full` rows to |
| 36 | + * `edit`. Behaviour-preserving by construction, because the two were already |
| 37 | + * equivalent. |
| 38 | + */ |
| 39 | + |
| 40 | +import type { ShareAccessLevel } from '@objectstack/spec/contracts'; |
| 41 | + |
| 42 | +/** The authorable levels, in widening order. */ |
| 43 | +export const ACCESS_LEVELS: readonly ShareAccessLevel[] = ['read', 'edit']; |
| 44 | + |
| 45 | +/** |
| 46 | + * Retired spellings that map losslessly onto an authorable level. |
| 47 | + * |
| 48 | + * `full` → `edit` is lossless *because* `full` was inert: the two already |
| 49 | + * behaved identically, so rewriting one to the other cannot change any access |
| 50 | + * decision. (Contrast the OWD `sharingModel: 'full'` alias retired in ADR-0090 |
| 51 | + * D4, which had no equivalent target and had to be delegated to the author.) |
| 52 | + */ |
| 53 | +const RETIRED_ACCESS_LEVELS: Readonly<Record<string, ShareAccessLevel>> = { |
| 54 | + full: 'edit', |
| 55 | +}; |
| 56 | + |
| 57 | +/** |
| 58 | + * Levels that open the write gate, INCLUDING the retired `full`. |
| 59 | + * |
| 60 | + * Deliberately wider than {@link ACCESS_LEVELS}: enforcement reads persisted |
| 61 | + * rows, which may predate normalisation. Dropping `full` here would silently |
| 62 | + * revoke access from every not-yet-migrated grant — a fail-open→fail-closed |
| 63 | + * flip nobody asked for. Authoring narrowness and enforcement tolerance are |
| 64 | + * different jobs. |
| 65 | + */ |
| 66 | +export const WRITE_ACCESS_LEVELS: readonly string[] = ['edit', 'full']; |
| 67 | + |
| 68 | +/** `true` when `value` is a stored level this plugin still honours. */ |
| 69 | +export function isKnownAccessLevel(value: unknown): boolean { |
| 70 | + return typeof value === 'string' |
| 71 | + && (ACCESS_LEVELS.includes(value as ShareAccessLevel) || value in RETIRED_ACCESS_LEVELS); |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Normalise an inbound access level to the authorable vocabulary. |
| 76 | + * |
| 77 | + * - `read` / `edit` pass through. |
| 78 | + * - `full` normalises to `edit` — quietly, because it is what `full` already |
| 79 | + * meant; failing an old client's request would be a regression with no |
| 80 | + * security benefit. |
| 81 | + * - anything else (including `null`/`undefined` when no `fallback` is given) |
| 82 | + * throws `VALIDATION_FAILED`, which the REST layer maps to a 400. Silently |
| 83 | + * coercing an unrecognised level would be the same silent-inertness bug in a |
| 84 | + * new spot: a caller asking for `admin` must be told it does not exist, not |
| 85 | + * handed a `read` grant they did not request. |
| 86 | + * |
| 87 | + * @param value the inbound level (typically from an HTTP body or a rule row) |
| 88 | + * @param fallback level to use when `value` is null/undefined; omit to require one |
| 89 | + */ |
| 90 | +export function normalizeAccessLevel( |
| 91 | + value: unknown, |
| 92 | + fallback?: ShareAccessLevel, |
| 93 | +): ShareAccessLevel { |
| 94 | + if (value == null || value === '') { |
| 95 | + if (fallback !== undefined) return fallback; |
| 96 | + throw new Error('VALIDATION_FAILED: accessLevel is required'); |
| 97 | + } |
| 98 | + if (typeof value === 'string') { |
| 99 | + if (ACCESS_LEVELS.includes(value as ShareAccessLevel)) return value as ShareAccessLevel; |
| 100 | + const retired = RETIRED_ACCESS_LEVELS[value]; |
| 101 | + if (retired) return retired; |
| 102 | + } |
| 103 | + throw new Error( |
| 104 | + `VALIDATION_FAILED: accessLevel must be one of ${ACCESS_LEVELS.join(' | ')} ` |
| 105 | + + `(received ${JSON.stringify(value)})`, |
| 106 | + ); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Normalise a level read back OUT of storage. Never throws. |
| 111 | + * |
| 112 | + * The write-path twin ({@link normalizeAccessLevel}) rejects an unrecognised |
| 113 | + * level so the caller learns immediately. A read path cannot do that: the row |
| 114 | + * already exists, and throwing while projecting it would take down the rule |
| 115 | + * evaluator or the admin list view over one bad byte. So this one fails |
| 116 | + * **closed** — an unrecognised stored level degrades to `read`, the narrowest |
| 117 | + * level, rather than being trusted or crashing (the same fail-closed posture |
| 118 | + * `effectiveSharingModel` takes for an unknown `sharingModel`). |
| 119 | + */ |
| 120 | +export function normalizeStoredAccessLevel(value: unknown): ShareAccessLevel { |
| 121 | + try { |
| 122 | + return normalizeAccessLevel(value, 'read'); |
| 123 | + } catch { |
| 124 | + return 'read'; |
| 125 | + } |
| 126 | +} |
0 commit comments