diff --git a/.changeset/29443-personal-views-server.md b/.changeset/29443-personal-views-server.md new file mode 100644 index 00000000000..589dbd84249 --- /dev/null +++ b/.changeset/29443-personal-views-server.md @@ -0,0 +1,48 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/plugin-auth': patch +'@objectstack/plugin-security': patch +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +feat(platform-objects): `sys_personal_view`, a reader's own named list views (server step of the first version) + +Clause-②: yes (widening) +Metadata-protocol: none + +A reader can now keep named list views of their own, as user data rather than view metadata. The new platform object `sys_personal_view` holds one row per saved list: + +- its owner (`user_id`); +- the object it lists (`object_name`); +- the name the owner gave it (`label`); +- the list configuration, in spec's `ListView` shape (`config`). + +It is registered beside `sys_user_preference`, on the same template, and the generic data API serves it at `/api/v1/data/sys_personal_view`. Which view opens by default is not stored here: the reader's existing per-object pin holds a view id, and a personal view's id is one more value it can hold. + +- **Owner only.** + - The object declares the owner-only default row scope `user_id == current_user.id` for every operation. The table carries the organization column, so the organization wall bounds it too. + - Another member gets nothing: no read by id (`404`), nothing listed, and no update (`404 RECORD_NOT_FOUND`). + - A create naming another owner is refused `403 PERMISSION_DENIED`. + - A view's owner is fixed when it is saved: an update that changes `user_id` is refused `403 PERMISSION_DENIED` for every caller but the system, platform administrators included. A save rule holds this, because for an organization member the `member_default` update floor (`created_by == current_user.id`) applies to an update in place of the object's own row scope. +- **No back door for organization administrators.** `organization_admin`, and the derived `organization_admin_no_bypass`, name the object without the wildcard's super-user bits. An organization administrator reads, writes and deletes only their own views. +- **Platform administrators are not limited.** The bare `admin_full_access` wildcard carries `viewAllRecords` and `modifyAllRecords`, so through the generic data API a platform administrator reads, edits and deletes any member's view. This is the same as `sys_user_preference` today. Whether that should change is an open question. +- **The body is a `ListView` for the row's own object, judged on every save.** + - A create, and an update that sends `config` or `object_name`, parse the resulting body with spec's `ListViewSchema`. + - A body that does not parse is refused `400 VALIDATION_FAILED`, with one `fields[]` entry per issue, each path under `config`. + - A body whose `data.object` names a different object than `object_name` is refused the same way, on `config.data.object`. + - No new error code. +- **Field security is not re-judged on save.** A view whose filter names a field the reader cannot read is still saved. The list query that applies the filter is refused `403 PERMISSION_DENIED` by the existing predicate guard. +- **Not view metadata.** `/meta` never serves a personal view, the metadata write door never writes one, and a package export never carries one. Its id is the engine's record id, so it never takes the `object.key` view syntax. +- **The owner's delete door.** `DELETE /api/v1/personal-views/:id` removes one personal view, and only the caller's own. + - The row is read as the caller, so another member or an organization administrator gets the unknown-id `404 RECORD_NOT_FOUND`. + - A row the caller can read but does not own is refused `403 PERMISSION_DENIED`. + - Only then is the row removed, as the system, attributed to the caller and inside the caller's organization. Success is `200` with `{ id, deleted: true }`. + - The door takes no query parameter (`400 VALIDATION_ERROR`) and serves signed-in callers only (`401 UNAUTHENTICATED`). + - `AuthPlugin` mounts it on the kernel's HTTP server, in the shape of the comment-reaction unreact door. +- **Grants.** + - `member_default` grants read, create and edit on the object, and no delete. That set is bound to the `everyone` anchor, which carries no delete bit (ADR-0090 D5). + - A member's generic data `DELETE` on a personal view is still refused `403 PERMISSION_DENIED`. The door above grants nothing. + - External members (`external_default`) get no grant in this version. Whether they can save views is an open question for the Console half. + +`PLATFORM_OBJECTS_BY_PACKAGE['platform-objects']` (`@objectstack/spec/system`) now lists `sys_personal_view`, so lint and cross-reference checks know the name is real. The CLI's organization-ownership inventory carries the new object's row. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 76afa79903f..aad4fa7dae8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -143,6 +143,7 @@ bugs — it is the protection or the side effect that silently does not happen. | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `packages/plugins/plugin-security/src/system-write-guard.ts#assertEngineOwnedWriteAllowed` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | | 26b | API key restore guard does not refuse | plugin-auth | Get: a write setting `revoked: false` on a `sys_api_key` row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set `revoked: false` | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | +| 26c | **Personal-view owner rule does not refuse** — a `sys_personal_view` update that changes `user_id` is admitted | plugin-auth | Get: a system writer can move a personal view to another owner (no shipped system writer does). Lose: nothing a caller reaches. A non-system update whose `user_id` differs from the stored owner is refused `403 PERMISSION_DENIED`, for every caller, a platform administrator included; an echo of the stored owner is no change. The rule exists because, for an organization member, `member_default`'s `'*'` update floor (`created_by == current_user.id`) applies to an update in place of the object's own row scope | `packages/plugins/plugin-auth/src/personal-view-hooks.ts#registerPersonalViewHooks` | | 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `stripSearchCompanionFromRead` | | 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `dependentCountIsDisclosable` | | 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `recordReferenceCheckElevation` | @@ -574,6 +575,7 @@ and CI refuses one that differs from what the tree produces. | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` | 1 | 26b | | `packages/plugins/plugin-auth/src/auth-plugin.ts#start` | 1 | 12 | | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` | 1 | 26 | +| `packages/plugins/plugin-auth/src/personal-view-hooks.ts#registerPersonalViewHooks` | 1 | 26c | | `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | 1 | 61b | | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite` | 1 | 59 | | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` | 1 | 1b | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index df56c813730..c80c91b6a78 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 255 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 256 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **255** | +| 175 write call sites | quoted in the merged changeset | **256** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 255** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -217,11 +217,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -145 of 255 (57%) as decidably elevated, with 102 more whose elevation is a +146 of 256 (57%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 255`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 256`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,23 +233,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **255** | +| write call sites on the application surface | **256** | | …whose object name is statically decidable | 177 | -| …whose object name is chosen at run time | 78 | +| …whose object name is chosen at run time | 79 | | …against an object with tenancy ENABLED | 176 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 196 | +| threading a tenant context | 197 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 145 | +| threading a decidably ELEVATED (`isSystem`) context | 146 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 193 | +| receiver carried a readable engine type | 194 | | receiver erased, placed by the object NAME | 52 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -257,7 +257,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 116 | | object name spelled through a `const` | 61 | | object name is an `object: string` parameter | 7 | -| object name is some other run-time expression | 71 | +| object name is some other run-time expression | 72 | ### Subtractions the census could NOT defend — enforced @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-31 at `d06a02507`. +Measured on 2026-11-02 at `ac273cf07`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 830 | -| engine-shaped types recognised | 96 | -| declared objects in the registry | 124 | -| same-named calls subtracted as non-engine | 214 | +| tracked non-test sources scanned | 852 | +| engine-shaped types recognised | 99 | +| declared objects in the registry | 125 | +| same-named calls subtracted as non-engine | 227 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 20c7b3dcb8d..d20a85bc20c 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 255 | +| Write call sites | 256 | | Object name statically decidable | 177 | -| Object name chosen at run time | 78 | +| Object name chosen at run time | 79 | | Against a tenancy-enabled object | 176 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 196 | +| Threading a tenant context | 197 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 145 | +| Threading a decidably elevated context | 146 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-31 at `d06a02507`. +Measured on 2026-11-02 at `ac273cf07`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 830 | -| engine-shaped types recognised | 96 | -| declared objects in the registry | 124 | -| same-named calls subtracted as non-engine | 214 | +| tracked non-test sources scanned | 852 | +| engine-shaped types recognised | 99 | +| declared objects in the registry | 125 | +| same-named calls subtracted as non-engine | 227 | ## Every site @@ -166,6 +166,7 @@ Measured on 2026-10-31 at `d06a02507`. | `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_team_member` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/organization-delete.ts` | `delete` | `sys_user_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-auth/src/personal-view-routes.ts` | `delete` | `PERSONAL_VIEW_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 | | `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index beeb0d73342..bc85c039d4e 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -795,6 +795,10 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ // Exchange rates (`@objectstack/platform-objects`, #27185): an organization's // administrators author its own rate table; no parent row anchors a rate. { object: 'sys_exchange_rate', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, + // A reader's own named list views (`@objectstack/platform-objects`, ruling + // 6570568131 on #28971): user data on the preference template, each row in + // its owner's organization; no parent row anchors a view. + { object: 'sys_personal_view', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, anchors: [] }, { object: 'sys_import_job', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, { object: 'sys_user_preference', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; ${D10_SINGLE}`, anchors: [] }, diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 755bb7ce502..1231574da5d 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -127,6 +127,7 @@ import { SysTwoFactor, SysDeviceCode, SysUserPreference, + SysPersonalView, SysOauthApplication, SysOauthAccessToken, SysOauthRefreshToken, @@ -268,6 +269,7 @@ const config: ObjectStackDefinition = defineStack({ SysTwoFactor, SysDeviceCode, SysUserPreference, + SysPersonalView, SysOauthApplication, SysOauthAccessToken, SysOauthRefreshToken, diff --git a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts index 54ab6875527..b47a3b7c23b 100644 --- a/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts +++ b/packages/platform-objects/src/apps/translations/bundle-ownership.test.ts @@ -21,7 +21,7 @@ const OWNED_OBJECTS = new Set([ 'sys_user', 'sys_session', 'sys_account', 'sys_verification', 'sys_organization', 'sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_business_unit', 'sys_business_unit_member', 'sys_api_key', 'sys_two_factor', 'sys_device_code', - 'sys_user_preference', 'sys_oauth_application', 'sys_oauth_access_token', + 'sys_user_preference', 'sys_personal_view', 'sys_oauth_application', 'sys_oauth_access_token', 'sys_oauth_refresh_token', 'sys_oauth_consent', 'sys_oauth_resource', 'sys_oauth_client_resource', 'sys_oauth_client_assertion', 'sys_jwks', // identity — external SSO provider (admin-facing, better-auth-managed) diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 859c77f2122..5596154128c 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1414,6 +1414,38 @@ export const enObjects: NonNullable = { } } }, + sys_personal_view: { + label: "Personal View", + pluralLabel: "Personal Views", + description: "A reader's own named list views (My Views). Visible only to the owner.", + fields: { + id: { + label: "Personal View ID" + }, + created_at: { + label: "Created At" + }, + updated_at: { + label: "Updated At" + }, + user_id: { + label: "Owner", + help: "The user this view belongs to. Only this user reads or changes it." + }, + object_name: { + label: "Object", + help: "The object this view lists (its machine name)." + }, + label: { + label: "Name", + help: "The name the owner gave this view." + }, + config: { + label: "View Configuration", + help: "The list configuration, in spec's ListView shape. Validated on every save." + } + } + }, sys_oauth_application: { label: "OAuth Application", pluralLabel: "OAuth Applications", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index b2a57aa4f67..f531b04608d 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1414,6 +1414,38 @@ export const esESObjects: NonNullable = { } } }, + sys_personal_view: { + label: "Vista personal", + pluralLabel: "Vistas personales", + description: "Las vistas de lista con nombre propio de un lector (Mis vistas). Solo las ve su propietario.", + fields: { + id: { + label: "ID de vista personal" + }, + created_at: { + label: "Fecha de creación" + }, + updated_at: { + label: "Fecha de actualización" + }, + user_id: { + label: "Propietario", + help: "El usuario al que pertenece esta vista. Solo este usuario puede leerla o cambiarla." + }, + object_name: { + label: "Objeto", + help: "El objeto que lista esta vista (su nombre técnico)." + }, + label: { + label: "Nombre", + help: "El nombre que el propietario dio a esta vista." + }, + config: { + label: "Configuración de la vista", + help: "La configuración de la lista, en la forma ListView de spec. Se valida en cada guardado." + } + } + }, sys_oauth_application: { label: "Aplicación OAuth", pluralLabel: "Aplicaciones OAuth", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 505f062268b..90082bb4056 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1414,6 +1414,38 @@ export const jaJPObjects: NonNullable = { } } }, + sys_personal_view: { + label: "個人ビュー", + pluralLabel: "個人ビュー", + description: "閲覧者自身が名前を付けたリストビュー(マイビュー)。所有者だけに表示されます。", + fields: { + id: { + label: "個人ビュー ID" + }, + created_at: { + label: "作成日時" + }, + updated_at: { + label: "更新日時" + }, + user_id: { + label: "所有者", + help: "このビューが属するユーザー。このユーザーだけが参照・変更できます。" + }, + object_name: { + label: "オブジェクト", + help: "このビューが一覧表示するオブジェクト(マシン名)。" + }, + label: { + label: "名前", + help: "所有者がこのビューに付けた名前。" + }, + config: { + label: "ビュー設定", + help: "spec の ListView 形式のリスト設定。保存のたびに検証されます。" + } + } + }, sys_oauth_application: { label: "OAuth アプリケーション", pluralLabel: "OAuth アプリケーション", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index a6e28053e80..3b6a0e28e11 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1414,6 +1414,38 @@ export const zhCNObjects: NonNullable = { } } }, + sys_personal_view: { + label: "个人视图", + pluralLabel: "个人视图", + description: "读者自己命名的列表视图(我的视图),仅本人可见。", + fields: { + id: { + label: "个人视图 ID" + }, + created_at: { + label: "创建时间" + }, + updated_at: { + label: "更新时间" + }, + user_id: { + label: "所有者", + help: "此视图所属的用户。只有该用户可以读取或修改它。" + }, + object_name: { + label: "对象", + help: "此视图列出的对象(其机器名)。" + }, + label: { + label: "名称", + help: "所有者为此视图起的名字。" + }, + config: { + label: "视图配置", + help: "列表配置,采用 spec 的 ListView 结构。每次保存都会校验。" + } + } + }, sys_oauth_application: { label: "OAuth 应用", pluralLabel: "OAuth 应用", diff --git a/packages/platform-objects/src/identity/index.ts b/packages/platform-objects/src/identity/index.ts index f3e189de697..11f8dfd26d4 100644 --- a/packages/platform-objects/src/identity/index.ts +++ b/packages/platform-objects/src/identity/index.ts @@ -28,6 +28,7 @@ export { SysApiKey } from './sys-api-key.object.js'; export { SysTwoFactor } from './sys-two-factor.object.js'; export { SysDeviceCode } from './sys-device-code.object.js'; export { SysUserPreference } from './sys-user-preference.object.js'; +export { SysPersonalView } from './sys-personal-view.object.js'; // ── OIDC Provider Objects ────────────────────────────────────────────────── export { SysOauthApplication } from './sys-oauth-application.object.js'; diff --git a/packages/platform-objects/src/identity/sys-personal-view.object.ts b/packages/platform-objects/src/identity/sys-personal-view.object.ts new file mode 100644 index 00000000000..04bb5649d1f --- /dev/null +++ b/packages/platform-objects/src/identity/sys-personal-view.object.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_personal_view — a reader's own named list view (「我的视图」). + * + * A named personal view is a row of USER DATA, not view metadata (ruling + * 6570568131 on #28971, letter C; ADR-0131 D13: "if pulled, it returns as a user-owned D3 object"). + * One row is one saved list: its owner, the object it lists, the name the owner + * gave it, and the list configuration in spec's `ListView` shape. Which view + * opens by default is NOT stored here: the reader's existing per-object pin + * (the Console's `list.view.pinned` workspace preference, #27594) holds a view + * id, and a personal view's id is one more value it can hold (seat ruling + * 6587007982 on #29443: one "my default" store, not two). + * + * What it is NOT, each on purpose: + * - not a view item: `/meta` never serves it and the metadata write door + * never writes it, so a package export never carries it and a walled + * environment can still save one (it is data, not metadata); + * - not `ViewScopeSchema`'s `personal` value or `sharing.type: 'personal'`, + * which stay as the spec lane leaves them; + * - not addressed by the `object.key` view syntax: its id is the engine's + * record id, which never carries a `.`, so it cannot collide with a package + * view's name; + * - not shared: no sharing, no team view, no admin management in this + * version. + * + * Modelled on `sys_user_preference` (the template the ruling names): the same + * owner column, the same owner-only default row scope, the same organization + * scoping under ADR-0131 (the table carries the organization column, so the + * Layer 0 wall bounds it and the row scope applies inside that wall), and the + * same registrar (`plugin-auth`'s identity manifest). + * + * One behaviour lives in the registrar's hooks, not here + * (`plugin-auth/src/personal-view-hooks.ts`): a save whose `config` is not a + * valid `ListView` for the row's own object is refused with + * `400 VALIDATION_FAILED`. The owner removes a view through + * the registrar's own-row door, `DELETE /api/v1/personal-views/:id` + * (`plugin-auth/src/personal-view-routes.ts`): no shipped set grants delete on + * this object, and the door's authorization is structural (owner == caller). + * + * @namespace sys + */ +export const SysPersonalView = ObjectSchema.create({ + name: 'sys_personal_view', + label: 'Personal View', + pluralLabel: 'Personal Views', + icon: 'list', + isSystem: true, + // [ADR-0103] User-writable DATA on a platform-defined schema, like + // `sys_user_preference`: a reader saves and renames their own + // views from the list page; no admin authors them. The bucket default is + // full CRUD, so no `userActions` block is needed; the row scope below is the + // authz. + managedBy: 'system-data', + description: "A reader's own named list views (My Views). Visible only to the owner.", + nameField: 'label', // [ADR-0079] canonical primary-title pointer + highlightFields: ['object_name'], + + // [ADR-0066 D2] `private`: a plain (non-superuser) `'*'` wildcard grant + // does not cover this object, so a member reaches it only through the + // explicit grant the platform baseline (`member_default`) names. A wildcard + // that carries `viewAllRecords` / `modifyAllRecords` still reaches a + // `private` object (ADR-0066 D2): `organization_admin`'s is closed for this + // object by an explicit entry in that set, and `admin_full_access`'s is not + // (the platform administrator reads, edits and deletes through the data + // door, as on `sys_user_preference`; an open question on #29443). + access: { default: 'private' }, + + // [ADR-0095 D1] Owner-only, for every operation: the same literal + // `sys_user_preference` declares. The organization column puts the table + // behind the Layer 0 wall, and this scope applies inside it. The + // organization administrator's wildcard super-user bypass is closed by an + // explicit entry for this object in that set (`default-permission-sets.ts`). + defaultRowScope: { + name: 'sys_personal_view_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + + fields: { + id: Field.text({ + label: 'Personal View ID', + required: true, + readonly: true, + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + + user_id: Field.lookup('sys_user', { + label: 'Owner', + required: true, + description: 'The user this view belongs to. Only this user reads or changes it.', + }), + + object_name: Field.text({ + label: 'Object', + required: true, + maxLength: 255, + description: 'The object this view lists (its machine name).', + }), + + label: Field.text({ + label: 'Name', + required: true, + maxLength: 255, + description: 'The name the owner gave this view.', + }), + + config: Field.json({ + label: 'View Configuration', + required: true, + description: "The list configuration, in spec's ListView shape. Validated on every save.", + }), + }, + + indexes: [ + // A list page reads one owner's views of one object. + { fields: ['user_id', 'object_name'], unique: false }, + ], + + enable: { + trackHistory: false, + searchable: false, + apiEnabled: true, + // No `apiMethods` — default-open, like `sys_user_preference`. No shipped + // set grants delete on this object; the owner removes their own view + // through plugin-auth's own-row door (`DELETE /api/v1/personal-views/:id`). + }, +}); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9fc03efe571..5f2df8bd3e1 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -68,6 +68,8 @@ import { } from './identity-write-guard.js'; import { registerLastAdminGuard } from './last-admin-guard.js'; import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js'; +import { registerPersonalViewHooks } from './personal-view-hooks.js'; +import { personalViewCallerResolver, registerPersonalViewRoutes, PERSONAL_VIEW_REMOVE_ROUTE, type PersonalViewEngine } from './personal-view-routes.js'; import { registerMembershipEndedSessionTrigger } from './membership-ended-session.js'; import { registerMemberRoleCanonicalization } from './member-role-canonical.js'; import { registerSessionFirstActivity } from './session-first-activity.js'; @@ -1383,6 +1385,11 @@ export class AuthPlugin implements Plugin { // Route registration errors should propagate (server misconfiguration) this.registerAuthRoutes(httpServer, ctx); ctx.logger.info(`Auth routes registered at ${this.options.basePath}`); + // The owner's delete door for a personal view: a member removes their + // own `sys_personal_view` row, and only theirs, with no object-level + // `delete` grant (seat ruling 6583289013 on #29443; the #26161 shape). + // See personal-view-routes.ts. + this.mountPersonalViewDoor(httpServer, ctx); } else { ctx.logger.warn( 'No HTTP server available — auth routes not registered. ' + @@ -1859,6 +1866,12 @@ export class AuthPlugin implements Plugin { packageId: 'com.objectstack.plugin-auth.api-key-restore-guard', logger: ctx.logger, }); + // A reader's own named list views: the body is judged on every save as + // a ListView for the row's own object. See personal-view-hooks.ts. + registerPersonalViewHooks(engine, { + packageId: 'com.objectstack.plugin-auth.personal-view', + logger: ctx.logger, + }); // [cloud ADR-0024 D5.2] Break-glass — the same identity write // chokepoints, guarding a different question: not "may this caller // write identity tables" (above, and system writes bypass it by @@ -3022,6 +3035,33 @@ export class AuthPlugin implements Plugin { * - Email verification * - 2FA, passkeys, magic links (if enabled) */ + /** + * Mount the owner's delete door for a personal view + * (`personal-view-routes.ts`). The engine, the auth service and the tenancy + * posture are read per request, so a provider that settles after this hook + * is still seen. + */ + private mountPersonalViewDoor(httpServer: IHttpServer, ctx: PluginContext): void { + // A server that serves the raw-app auth routes but registers no DELETE + // verb (a partial embedding) mounts nothing here, and says so by name. + if (typeof (httpServer as Partial).delete !== 'function') { + ctx.logger.warn( + `The HTTP server registers no DELETE routes, so the personal view delete door (DELETE ${PERSONAL_VIEW_REMOVE_ROUTE}) ` + + 'is not mounted, and a member cannot remove their own personal view.', + ); + return; + } + registerPersonalViewRoutes(httpServer, { + getEngine: () => this.authManager?.getDataEngine() as unknown as PersonalViewEngine | undefined, + resolveCaller: personalViewCallerResolver({ + getEngine: () => this.authManager?.getDataEngine(), + getAuth: () => (this.authManager ?? undefined) as unknown as RequestAuthService | undefined, + tenancy: () => this.tenancy ?? undefined, + }), + }); + ctx.logger.info(`Personal view delete door mounted at DELETE ${PERSONAL_VIEW_REMOVE_ROUTE}`); + } + private registerAuthRoutes(httpServer: IHttpServer, ctx: PluginContext): void { if (!this.authManager) return; diff --git a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts index e69cabba00f..fc219f2622e 100644 --- a/packages/plugins/plugin-auth/src/identity-objects-plugin.ts +++ b/packages/plugins/plugin-auth/src/identity-objects-plugin.ts @@ -63,6 +63,7 @@ import type { TenancyPosture } from '@objectstack/spec/security'; import { resolveTenancyPosture } from '@objectstack/types'; import { ensureDefaultOrganizationExists } from './default-organization-invariant.js'; import { authIdentityManifest } from './manifest.js'; +import { registerPersonalViewHooks } from './personal-view-hooks.js'; /** The kernel plugin name of {@link IdentityObjectsPlugin}. */ export const IDENTITY_OBJECTS_PLUGIN_NAME = 'com.objectstack.auth.identity-objects'; @@ -122,6 +123,12 @@ export class IdentityObjectsPlugin implements Plugin { * plugin keeps it. A failure throws and fails the boot, never warn-only. */ async start(ctx: PluginContext): Promise { + // The manifest above registers `sys_personal_view`; its two write rules + // travel with it, exactly as `AuthPlugin` registers them. + const engine = ctx.getService<{ registerHook?: unknown }>('objectql'); + if (engine && typeof engine.registerHook === 'function') { + registerPersonalViewHooks(engine, { packageId: `${IDENTITY_OBJECTS_PLUGIN_NAME}.personal-view` }); + } if (postureInForce(ctx) !== 'single') return; const invariant = await ensureDefaultOrganizationExists(ctx.getService('objectql'), { logger: ctx.logger, diff --git a/packages/plugins/plugin-auth/src/manifest.ts b/packages/plugins/plugin-auth/src/manifest.ts index 850a8457b35..24736e0759c 100644 --- a/packages/plugins/plugin-auth/src/manifest.ts +++ b/packages/plugins/plugin-auth/src/manifest.ts @@ -38,6 +38,7 @@ import { SysTwoFactor, SysUser, SysUserPreference, + SysPersonalView, SysVerification, } from '@objectstack/platform-objects/identity'; import { Field } from '@objectstack/spec/data'; @@ -59,6 +60,10 @@ export const authIdentityObjects: any[] = [ SysApiKey, SysTwoFactor, SysUserPreference, + // A reader's own named list views: user data on the preference template + // (ruling 6570568131 on #28971). Its save validation and default flag live + // in `personal-view-hooks.ts`. + SysPersonalView, SysOauthApplication, SysOauthAccessToken, SysOauthRefreshToken, diff --git a/packages/plugins/plugin-auth/src/personal-view-hooks.test.ts b/packages/plugins/plugin-auth/src/personal-view-hooks.test.ts new file mode 100644 index 00000000000..f25b90fcbf8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-hooks.test.ts @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_personal_view` — a reader's own named list views (ruling 6570568131 on + * #28971, letter C) — driven through the real write path: a real `ObjectQL` + * engine over SQLite, the real `SecurityPlugin` with the shipped permission + * sets (a member resolves `member_default`, which names the object; the object + * declares its owner-only default row scope), and the hooks `AuthPlugin` + * registers for it (`personal-view-hooks.ts`). + * + * What each block pins: + * - the owner saves, reads and renames their own views; + * - another member reads, lists and updates none of them; + * - an organization administrator, under either shipped variant, reads, + * lists and updates none of them either (the wildcard's super-user bits + * are closed for this object); + * - a body that is not spec's `ListView` is refused `400 VALIDATION_FAILED` + * with its paths under `config`, and nothing is stored; + * - a view lists the object it is saved for: `config.data.object` and + * `object_name` must agree, on create and on update; + * - the id is the engine's record id, never the `object.key` view syntax; + * - the owner cannot re-parent a view (`user_id`) to someone else; + * - the recorded platform-administrator boundary: `admin_full_access`'s bare + * wildcard reads, edits and deletes a member's view through the data door; + * - the owner's delete door (`removeOwnPersonalView`, the core of + * `DELETE /api/v1/personal-views/:id`): the owner's own row is removed; + * another member's and an organization administrator's attempt reads + * nothing and answers 404 with the row kept; a platform administrator, who + * CAN read the row, is refused loudly with 403 and the row kept; and the + * generic engine delete is still refused for the owner (no grant moved). + * + * Refusals are asserted on the ADR-0112 pair (`code` + `status`) and on the + * row left unchanged. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SecurityPlugin } from '@objectstack/plugin-security'; + +import { authIdentityObjects } from './manifest.js'; +import { PERSONAL_VIEW_OBJECT, registerPersonalViewHooks } from './personal-view-hooks.js'; +import { removeOwnPersonalView } from './personal-view-routes.js'; + +const SYS = { context: { isSystem: true } } as any; +const ORG = 'org_default'; +const ALICE = 'usr_alice'; +const BOB = 'usr_bob'; +const CAROL = 'usr_carol'; +const OBJ = PERSONAL_VIEW_OBJECT; + +/** A member as the transports hand one to the engine: no grants, the baseline resolves from the user id. */ +const as = (userId: string, permissions: string[] = []) => + ({ context: { userId, tenantId: ORG, positions: [], permissions } }) as any; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** Engine + shipped permission sets + the hooks `auth-plugin.ts` registers for the object. */ +async function rig(): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engines.push(engine); + let manifest: { objects?: unknown[]; permissions?: unknown[] } = {}; + const services: Record = { + manifest: { register: (m: any) => { manifest = m; } }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? ((manifest.permissions as any[]) ?? []).find((p) => p?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async (type?: string) => + type === undefined || type === 'permission' ? [...((manifest.permissions as unknown[]) ?? [])] : [], + }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const security = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await security.init(ctx); + engine.registerApp({ + id: 'com.objectstack.qa.personal-view', + name: 'Personal views', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + ...authIdentityObjects, + ...((manifest.objects ?? []) as any[]).filter((o) => + ['sys_position', 'sys_user_position', 'sys_position_permission_set', 'sys_user_permission_set'].includes(o?.name)), + ], + } as any); + await engine.syncSchemas(); + await engine.insert('sys_organization', { id: ORG, name: 'Default Organization', slug: 'default' }, SYS); + await security.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + registerPersonalViewHooks(engine, { packageId: 'test.personal-view' }); + for (const id of [ALICE, BOB, CAROL]) { + await engine.insert('sys_user', { id, email: `${id}@views.example`, name: id, email_verified: true, banned: false }, SYS); + } + return engine; +} + +/** A minimal valid `ListView` body for `object`. */ +const listView = (object: string, extra: Record = {}) => ({ + type: 'grid', + data: { provider: 'object', object }, + columns: ['name'], + ...extra, +}); + +const refusalOf = async (write: Promise): Promise => write.then(() => undefined, (e: unknown) => e); + +/** Every stored row, read as the system (what is actually on disk). */ +const stored = async (engine: ObjectQL): Promise => + (await engine.find(OBJ, { ...SYS, orderBy: [{ field: 'label', order: 'asc' }] })) as any[]; + +async function save(engine: ObjectQL, owner: string, label: string, extra: Record = {}): Promise { + return engine.insert( + OBJ, + { user_id: owner, object_name: 'crm_lead', label, config: listView('crm_lead'), ...extra }, + as(owner), + ); +} + +describe('the owner keeps their own named views', () => { + it('saves, reads back, lists and renames their own view', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'This week'); + expect(created?.id).toBeTruthy(); + + const mine = (await engine.find(OBJ, as(ALICE))) as any[]; + expect(mine.map((r) => r.label)).toEqual(['This week']); + + await engine.update(OBJ, { id: created.id, label: 'This week, hot' }, as(ALICE)); + const row = await engine.findOne(OBJ, { where: { id: created.id }, ...as(ALICE) }); + expect(row?.label).toBe('This week, hot'); + }); + + it('the id is the engine record id, never the `object.key` view syntax', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Mine'); + expect(String(created.id)).not.toContain('.'); + for (const r of await stored(engine)) expect(String(r.id)).not.toContain('.'); + }); +}); + +describe('another member gets nothing of the owner\'s views', () => { + it('reads none by id, lists none, and cannot update one', async () => { + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + + expect(await engine.findOne(OBJ, { where: { id: alices.id }, ...as(BOB) })).toBeNull(); + expect((await engine.find(OBJ, as(BOB))) as any[]).toEqual([]); + expect(await engine.count(OBJ, as(BOB))).toBe(0); + + const err = await refusalOf(engine.update(OBJ, { id: alices.id, label: 'hijacked' }, as(BOB))); + // The row is unreadable to the writer, so the by-id write answers as if it did not exist. + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }); + + it('cannot plant a view in the owner\'s name', async () => { + const engine = await rig(); + const err = await refusalOf(engine.insert( + OBJ, + { user_id: ALICE, object_name: 'crm_lead', label: 'planted', config: listView('crm_lead') }, + as(BOB), + )); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await stored(engine)).toEqual([]); + }); +}); + +describe('an organization administrator gets no back door (no admin management in the first version)', () => { + it.each(['organization_admin', 'organization_admin_no_bypass'])( + '%s reads none of a member\'s views and cannot update one', + async (adminSet) => { + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const admin = as(CAROL, [adminSet]); + + expect(await engine.findOne(OBJ, { where: { id: alices.id }, ...admin })).toBeNull(); + expect((await engine.find(OBJ, admin)) as any[]).toEqual([]); + + const err = await refusalOf(engine.update(OBJ, { id: alices.id, label: 'admin edit' }, admin)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }, + ); + + it('RECORDED BOUNDARY: through the data door the platform administrator also edits and deletes a member\'s view', async () => { + // `admin_full_access` spreads spec's ADMIN_FULL_ACCESS_CAPABILITIES: + // `'*'` with allowEdit, allowDelete and modifyAllRecords, and the owner + // floors bind members only. So the boundary today is read, edit AND delete + // through the generic data door (exactly as on `sys_user_preference`); + // only the own-row door refuses its delete. Pinned so the boundary the + // card's open question asks about is stated by a test, not by prose. + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const platformAdmin = as(CAROL, ['admin_full_access']); + await engine.update(OBJ, { id: alices.id, label: 'edited by the platform admin' }, platformAdmin); + expect((await stored(engine)).map((r) => r.label)).toEqual(['edited by the platform admin']); + await engine.delete(OBJ, { where: { id: alices.id }, ...platformAdmin } as any); + expect(await stored(engine)).toEqual([]); + }); + + it('RECORDED BOUNDARY: the platform administrator\'s bare wildcard still reads a member\'s view', async () => { + // `admin_full_access` keeps its bare `'*'` super-user wildcard by design + // (the admin rescue path; its objects are spec's + // ADMIN_FULL_ACCESS_CAPABILITIES, which this card does not touch), as it + // does on `sys_user_preference`. Closing it is an open question on the + // card; this pin makes the answer visible when it moves. + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const row = await engine.findOne(OBJ, { where: { id: alices.id }, ...as(CAROL, ['admin_full_access']) }); + expect(row?.label).toBe('Alice only'); + }); + + it('CONTROL: the organization administrator keeps their own views', async () => { + const engine = await rig(); + await save(engine, CAROL, 'Carol own'); + const own = (await engine.find(OBJ, as(CAROL, ['organization_admin']))) as any[]; + expect(own.map((r) => r.label)).toEqual(['Carol own']); + }); +}); + +describe('the owner cannot hand a view to someone else', () => { + it('an update re-parenting user_id to another user is refused, and the row keeps its owner', async () => { + const engine = await rig(); + const own = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(engine.update(OBJ, { id: own.id, user_id: BOB }, as(ALICE))); + expect(err?.status).toBe(403); + const [row] = await stored(engine); + expect(row.user_id).toBe(ALICE); + }); + + it('the write rule holds it on its own: a platform administrator, whom no row scope binds, cannot re-parent a view either', async () => { + const engine = await rig(); + const own = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(engine.update(OBJ, { id: own.id, user_id: BOB }, as(CAROL, ['admin_full_access']))); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + const [row] = await stored(engine); + expect(row.user_id).toBe(ALICE); + }); + + it('control: an update that echoes the stored owner is not a re-parent, and is accepted', async () => { + const engine = await rig(); + const own = await save(engine, ALICE, 'Alice only'); + await engine.update(OBJ, { id: own.id, user_id: ALICE, label: 'Alice, renamed' }, as(ALICE)); + const [row] = await stored(engine); + expect({ user_id: row.user_id, label: row.label }).toEqual({ user_id: ALICE, label: 'Alice, renamed' }); + }); +}); + +describe('the body is spec\'s ListView, judged on every save', () => { + it('a create whose config is not a ListView is refused 400 VALIDATION_FAILED, paths under config, nothing stored', async () => { + const engine = await rig(); + const err = await refusalOf(save(engine, ALICE, 'Broken', { config: { type: 'not_a_view_type', columns: 'name' } })); + expect({ code: err?.code, name: err?.name }).toEqual({ code: 'VALIDATION_FAILED', name: 'ValidationError' }); + const paths = (err.fields as Array<{ field: string }>).map((f) => f.field); + expect(paths.length).toBeGreaterThan(0); + for (const p of paths) expect(p.startsWith('config')).toBe(true); + expect(await stored(engine)).toEqual([]); + }); + + it('an update whose config is not a ListView is refused, and the stored body is kept', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Good'); + const err = await refusalOf(engine.update(OBJ, { id: created.id, config: { columns: 42 } }, as(ALICE))); + expect(err?.code).toBe('VALIDATION_FAILED'); + const [row] = await stored(engine); + const config = typeof row.config === 'string' ? JSON.parse(row.config) : row.config; + expect(config).toMatchObject({ type: 'grid', columns: ['name'] }); + }); + + it('a config that is not JSON at all is refused on the config field', async () => { + const engine = await rig(); + const err = await refusalOf(save(engine, ALICE, 'Text', { config: '{ not json' })); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect((err.fields as Array<{ field: string }>).map((f) => f.field)).toEqual(['config']); + }); + + it('CONTROL: an update that does not send config is not re-judged', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Rename me'); + await engine.update(OBJ, { id: created.id, label: 'Renamed' }, as(ALICE)); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Renamed']); + }); +}); + +describe('a view lists the object it is saved for', () => { + it('a create whose config.data.object is another object is refused on config.data.object, nothing stored', async () => { + const engine = await rig(); + const err = await refusalOf(save(engine, ALICE, 'Wrong object', { config: listView('crm_account') })); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect((err.fields as Array<{ field: string; code?: string }>).map((f) => [f.field, f.code])).toEqual([ + ['config.data.object', 'object_mismatch'], + ]); + expect(await stored(engine)).toEqual([]); + }); + + it('an update that moves only object_name away from the stored body is refused, and the row is kept', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Leads'); + const err = await refusalOf(engine.update(OBJ, { id: created.id, object_name: 'crm_account' }, as(ALICE))); + expect(err?.code).toBe('VALIDATION_FAILED'); + expect((await stored(engine))[0].object_name).toBe('crm_lead'); + }); + + it('an update that sends only a body for another object is refused, and the stored body is kept', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Leads'); + const err = await refusalOf(engine.update(OBJ, { id: created.id, config: listView('crm_account') }, as(ALICE))); + expect(err?.code).toBe('VALIDATION_FAILED'); + const [row] = await stored(engine); + const config = typeof row.config === 'string' ? JSON.parse(row.config) : row.config; + expect(config.data.object).toBe('crm_lead'); + }); + + it('CONTROL: moving both object_name and the body together is accepted', async () => { + const engine = await rig(); + const created = await save(engine, ALICE, 'Moving'); + await engine.update(OBJ, { id: created.id, object_name: 'crm_account', config: listView('crm_account') }, as(ALICE)); + expect((await stored(engine))[0].object_name).toBe('crm_account'); + }); +}); + +describe('the owner\'s delete door removes the owner\'s own view, and nobody else\'s', () => { + /** The context the door resolves for a caller, as the transports hand one to the engine. */ + const caller = (userId: string, permissions: string[] = []) => as(userId, permissions).context; + + it('own row: removed, and the owner\'s other views stay', async () => { + const engine = await rig(); + const gone = await save(engine, ALICE, 'A gone'); + await save(engine, ALICE, 'B kept'); + await removeOwnPersonalView(engine as any, gone.id, caller(ALICE)); + expect((await stored(engine)).map((r) => r.label)).toEqual(['B kept']); + }); + + it('another member\'s row: 404 RECORD_NOT_FOUND (unreadable, the unknown-id answer), and the row stays', async () => { + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(removeOwnPersonalView(engine as any, alices.id, caller(BOB))); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }); + + it.each(['organization_admin', 'organization_admin_no_bypass'])( + '%s deleting a member\'s row: 404 RECORD_NOT_FOUND, and the row stays', + async (adminSet) => { + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(removeOwnPersonalView(engine as any, alices.id, caller(CAROL, [adminSet]))); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }, + ); + + it('a platform administrator, who reads the row, is refused loudly: 403 PERMISSION_DENIED, and the row stays', async () => { + const engine = await rig(); + const alices = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(removeOwnPersonalView(engine as any, alices.id, caller(CAROL, ['admin_full_access']))); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }); + + it('an unknown id: 404 RECORD_NOT_FOUND', async () => { + const engine = await rig(); + const err = await refusalOf(removeOwnPersonalView(engine as any, 'no_such_view', caller(ALICE))); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'RECORD_NOT_FOUND', status: 404 }); + }); + + it('the grant model does not move: the generic engine delete of the owner\'s own row is still refused', async () => { + const engine = await rig(); + const own = await save(engine, ALICE, 'Alice only'); + const err = await refusalOf(engine.delete(OBJ, { where: { id: own.id }, ...as(ALICE) } as any)); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect((await stored(engine)).map((r) => r.label)).toEqual(['Alice only']); + }); +}); diff --git a/packages/plugins/plugin-auth/src/personal-view-hooks.ts b/packages/plugins/plugin-auth/src/personal-view-hooks.ts new file mode 100644 index 00000000000..42e3039fef0 --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-hooks.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The write rule of `sys_personal_view`, a reader's own named list views + * (ruling 6570568131 on #28971, letter C; the object's own docblock in + * `@objectstack/platform-objects` says what the row is and is not). + * + * **The body is spec's `ListView` for the row's own object, judged on every + * save.** A create, and an update that sends `config` or `object_name`, parse + * the resulting body with `ListViewSchema` — the same schema a package's list + * view is authored against — and a body that does not parse is refused with + * `400 VALIDATION_FAILED` and one `fields[]` entry per issue, each path under + * `config`. A body whose data source names an object (`data.object`) must name + * the row's `object_name`: a view saved for one object never lists another + * (`config.data.object`, code `object_mismatch`). Nothing is coerced or kept + * half: a stored view is always one the list renderer can read for its own + * object. A field the reader cannot read is NOT judged here: the existing + * predicate guard refuses that filter when the list is queried, so this rule + * does not grow a second copy of field security. + * + * **The owner is fixed when the view is saved.** An update that sends a + * `user_id` other than the stored one is refused `403 PERMISSION_DENIED`, for + * every caller but the system. The row scope alone does not hold this: for an + * organization member, `member_default`'s `'*'` update floor + * (`created_by == current_user.id`) is a held update policy, so the object's + * declared default does not apply to an update, and the post-image of a + * re-parent still carries the member's own `created_by`. + * + * Which view opens by default is not this object's: the reader's existing + * per-object pin holds a view id (seat ruling 6587007982 on #29443). + * + * Registered by every plugin that registers the object (`AuthPlugin`, and + * `IdentityObjectsPlugin` on a kernel without it), so the rule travels with the + * table. + */ + +import { ListViewSchema } from '@objectstack/spec/ui'; +import { zodIssuesToFields } from '@objectstack/spec/api'; +import { validationFailure } from '@objectstack/types'; + +/** The object these hooks guard. */ +export const PERSONAL_VIEW_OBJECT = 'sys_personal_view'; + +/** The column holding the `ListView` body. */ +const CONFIG_FIELD = 'config'; + +export interface PersonalViewHooksOptions { + packageId: string; + logger?: { info(msg: string): void }; +} + +/** A JSON column can arrive as text (a driver round trip, a form post): read it as JSON. */ +function asJsonValue(value: unknown): { ok: true; value: unknown } | { ok: false } { + if (typeof value !== 'string') return { ok: true, value }; + try { + return { ok: true, value: JSON.parse(value) }; + } catch { + return { ok: false }; + } +} + +/** + * Refuse a `config` that is not a `ListView`, or whose data source names an + * object other than `objectName` (when one is known); return silently when it + * is a `ListView` for that object. + */ +export function assertPersonalViewConfig(config: unknown, objectName?: unknown): void { + const json = asJsonValue(config); + if (!json.ok) { + throw validationFailure( + 'The personal view\'s configuration is not valid JSON. Send the list configuration as a JSON object in spec\'s ListView shape.', + [{ field: CONFIG_FIELD, code: 'invalid_json', message: 'Expected a JSON object in the ListView shape.' }], + ); + } + const parsed = ListViewSchema.safeParse(json.value); + if (parsed.success) { + const data = (parsed.data as { data?: { object?: unknown } }).data; + const named = data && typeof data === 'object' ? data.object : undefined; + if (typeof named === 'string' && typeof objectName === 'string' && objectName !== '' && named !== objectName) { + throw validationFailure( + `The personal view's configuration lists '${named}', but the view is saved for '${objectName}'. ` + + 'A personal view lists the object it is saved for: make `config.data.object` and `object_name` the same.', + [{ field: `${CONFIG_FIELD}.data.object`, code: 'object_mismatch', message: `Expected '${objectName}'.` }], + ); + } + return; + } + const fields = zodIssuesToFields(parsed.error.issues, json.value).map((f) => ({ + ...f, + field: f.field === '(body)' ? CONFIG_FIELD : `${CONFIG_FIELD}.${f.field}`, + })); + throw validationFailure( + 'The personal view\'s configuration is not a valid ListView. Fix the fields listed in `fields` and save again.', + fields, + ); +} + +/** The string form two stored ids are compared by (an echo of the stored owner is no change). */ +function sameId(a: unknown, b: unknown): boolean { + const norm = (v: unknown) => (v === undefined || v === null || v === '' ? null : String(v)); + return norm(a) === norm(b); +} + +/** The refusal for an update that would hand a view to another user: `PERMISSION_DENIED` / `403`. */ +export function ownerChangeError(id: unknown): Error { + const err = new Error( + `Personal view ${String(id ?? '')} was not changed: a view's owner (user_id) is fixed when the view is saved, ` + + 'and nobody can hand a view to another user. Save a new view in that user\'s name instead. The view is unchanged.', + ) as Error & { code?: string; status?: number }; + err.code = 'PERMISSION_DENIED'; + err.status = 403; + return err; +} + +const sets = (data: Record | null | undefined, key: string): boolean => + !!data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, key); + +/** + * Register the rule on an ObjectQL engine. Re-binding after hot reload + * follows the engine's standard contract: `engine.unregisterHooksByPackage(packageId)` first. + */ +export function registerPersonalViewHooks(engine: any, opts: PersonalViewHooksOptions): void { + const validateOnInsert = async (ctx: any): Promise => { + if (ctx?.object !== PERSONAL_VIEW_OBJECT) return; + const data = ctx.input?.data as Record | undefined; + // An absent body is the required-field rule's to refuse, with its own envelope. + if (!sets(data, CONFIG_FIELD) || data![CONFIG_FIELD] === null || data![CONFIG_FIELD] === undefined) return; + assertPersonalViewConfig(data![CONFIG_FIELD], data!.object_name); + }; + const validateOnUpdate = async (ctx: any): Promise => { + if (ctx?.object !== PERSONAL_VIEW_OBJECT) return; + const data = ctx.input?.data as Record | undefined; + // The row as it will be: what the write sends, over what is stored + // (`ctx.previous`, present on the by-id path and per row on a multi-row update). + const previous = (ctx.previous ?? undefined) as Record | undefined; + if ( + ctx?.session?.isSystem !== true + && sets(data, 'user_id') + && previous + && !sameId(data!.user_id, previous.user_id) + ) { + throw ownerChangeError(previous.id ?? ctx.input?.id); + } + const sendsConfig = sets(data, CONFIG_FIELD); + if (!sendsConfig && !sets(data, 'object_name')) return; + const config = sendsConfig ? data![CONFIG_FIELD] : previous?.[CONFIG_FIELD]; + const objectName = sets(data, 'object_name') ? data!.object_name : previous?.object_name; + if (sendsConfig || (config !== null && config !== undefined)) assertPersonalViewConfig(config, objectName); + }; + + engine.registerHook('beforeInsert', validateOnInsert, { object: PERSONAL_VIEW_OBJECT, packageId: opts.packageId }); + engine.registerHook('beforeUpdate', validateOnUpdate, { object: PERSONAL_VIEW_OBJECT, packageId: opts.packageId }); + opts.logger?.info( + '[PersonalViewHooks] sys_personal_view hooks registered: a save is validated as a ListView ' + + 'for the row\'s own object, and a view\'s owner never changes', + ); +} diff --git a/packages/plugins/plugin-auth/src/personal-view-route-ledger.conformance.test.ts b/packages/plugins/plugin-auth/src/personal-view-route-ledger.conformance.test.ts new file mode 100644 index 00000000000..5d6973337fa --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-route-ledger.conformance.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Personal-view route-ledger conformance — the package-local guard every + * `*-route-ledger.ts` pairs with. `registerPersonalViewRoutes` is a standalone, + * synchronous function that calls `http.delete` and touches neither argument + * before a request arrives, so a capturing mock `IHttpServer` enumerates its + * real surface (the seam the comment-reaction, print, storage and settings + * ledgers use), and the two directions fail here by name on every `pnpm test` + * of this package. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { registerPersonalViewRoutes } from './personal-view-routes.js'; +import { PERSONAL_VIEW_ROUTE_LEDGER } from './personal-view-route-ledger.js'; + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function enumerateRoutes(): Set { + const server = createMockServer(); + registerPersonalViewRoutes(server as any, {} as any); + const keys = new Set(); + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const call of server[verb].mock.calls) keys.add(`${verb.toUpperCase()} ${call[0]}`); + } + return keys; +} + +const ledgerKeys = (): Set => new Set(PERSONAL_VIEW_ROUTE_LEDGER.map((e) => e.route)); + +describe('personal-view route ledger ↔ registerPersonalViewRoutes enumeration', () => { + it('every mounted route has a ledger entry', () => { + const ledger = ledgerKeys(); + const missing = [...enumerateRoutes()].filter((k) => !ledger.has(k)); + expect(missing, `personal-view routes with no ledger entry: ${missing.join(', ')}`).toEqual([]); + }); + + it('every ledger entry is really mounted by the registrar', () => { + const live = enumerateRoutes(); + const stale = [...ledgerKeys()].filter((k) => !live.has(k)); + expect(stale, `personal-view ledger entries the registrar no longer mounts: ${stale.join(', ')}`).toEqual([]); + }); + + it('the ledger is compared against a real enumeration, not an empty one', () => { + const live = enumerateRoutes(); + expect(live.size).toBeGreaterThan(0); + expect(live.size).toBe(ledgerKeys().size); + expect(PERSONAL_VIEW_ROUTE_LEDGER.every((e) => e.disposition === 'sdk' || (e.note ?? '').length > 0)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/personal-view-route-ledger.ts b/packages/plugins/plugin-auth/src/personal-view-route-ledger.ts new file mode 100644 index 00000000000..85012b9e9c4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-route-ledger.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Personal-view route ledger — the audited disposition of every HTTP route + * `registerPersonalViewRoutes` (`personal-view-routes.ts`) mounts. + * + * WHY THIS EXISTS. `AuthPlugin` registers the owner's delete door straight on + * `IHttpServer`, so neither the dispatcher ledger nor the REST ledger sees its + * route, the same shape as the comment-reaction, print, storage and settings + * families. `personal-view-route-ledger.conformance.test.ts` fails when the + * registrar mounts a route with no row here, and when a row names a route it no + * longer mounts. + * + * A GAP, NOT SDK SURFACE YET. The consumer is the Console's "My views" list + * (the second half of #29443's ruling), which does not exist yet. + * `@objectstack/client` has no method for it, so the row is an acknowledged + * `gap`. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It stays import-free, so a guard in another + * package can read it as a relative SOURCE file. + */ + +/** Disposition of a single route. Same vocabulary as the REST ledger. */ +export type PersonalViewRouteDisposition = + /** Expressed by the SDK — `client` names the method (dotted path). */ + | 'sdk' + /** Should be in the SDK and is not — an open, acknowledged gap. */ + | 'gap' + /** Deliberately not SDK surface. */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface PersonalViewRouteLedgerEntry { + /** `VERB /api/v1/...` — the full wire path. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + disposition: PersonalViewRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const PERSONAL_VIEW_ROUTE_LEDGER: readonly PersonalViewRouteLedgerEntry[] = [ + { route: 'DELETE /api/v1/personal-views/:id', family: 'personal-views', disposition: 'gap', + note: "remove the caller's own sys_personal_view row, needing no object delete grant; the Console's My views list is the consumer and the SDK has no method yet" }, +]; diff --git a/packages/plugins/plugin-auth/src/personal-view-routes.test.ts b/packages/plugins/plugin-auth/src/personal-view-routes.test.ts new file mode 100644 index 00000000000..5ee1da42ed4 --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-routes.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The owner's delete door's own shell (`personal-view-routes.ts`): the answers + * it gives before the engine is ever asked. The authorization itself (own row + * removed, another member's and an organization administrator's attempt + * answered like an unknown id, a platform administrator refused loudly, the + * generic delete still refused) is pinned on the real engine and security + * plugin in `personal-view-hooks.test.ts`, and through the real HTTP stack in + * the dogfood file `personal-view-row-scope.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AuthzStoreUnavailableError } from '@objectstack/core'; + +import { + PERSONAL_VIEW_REMOVE_ROUTE, + registerPersonalViewRoutes, + type PersonalViewEngine, + type PersonalViewRoutesDeps, +} from './personal-view-routes.js'; + +type Handler = (req: unknown, res: unknown) => unknown; + +function capturingServer() { + const routes = new Map(); + const record = (verb: string) => (path: string, handler: Handler) => { + routes.set(`${verb} ${path}`, handler); + }; + return { + routes, + server: { + get: record('GET'), + post: record('POST'), + put: record('PUT'), + patch: record('PATCH'), + delete: record('DELETE'), + use: () => undefined, + listen: async () => undefined, + close: async () => undefined, + }, + }; +} + +async function call(handler: Handler, req: Partial<{ params: any; query: any; headers: any }>) { + const answer: { status: number; body: any } = { status: 200, body: undefined }; + const res = { + status(code: number) { + answer.status = code; + return res; + }, + json(body: unknown) { + answer.body = body; + }, + }; + await handler({ method: 'DELETE', path: '/api/v1/personal-views/v1', params: {}, query: {}, headers: {}, ...req }, res); + return answer; +} + +/** An engine that records whether it was asked anything. */ +function untouchedEngine() { + const engine = { findOne: vi.fn(), delete: vi.fn() }; + return engine as typeof engine & PersonalViewEngine; +} + +function door(deps: PersonalViewRoutesDeps): Handler { + const { routes, server } = capturingServer(); + registerPersonalViewRoutes(server as any, deps); + const handler = routes.get(`DELETE ${PERSONAL_VIEW_REMOVE_ROUTE}`); + if (!handler) throw new Error('the registrar mounted no DELETE door'); + return handler; +} + +const MEMBER = { userId: 'u_member', positions: [], permissions: [] } as any; + +describe('the personal-view delete door — answers given before the engine is asked', () => { + it('mounts exactly DELETE /api/v1/personal-views/:id', () => { + const { routes, server } = capturingServer(); + registerPersonalViewRoutes(server as any, {} as any); + expect([...routes.keys()]).toEqual(['DELETE /api/v1/personal-views/:id']); + }); + + it('a permission store that cannot be read answers its own 503, not a denial', async () => { + const engine = untouchedEngine(); + const answer = await call( + door({ + getEngine: () => engine, + resolveCaller: async () => { + throw new AuthzStoreUnavailableError('sys_user_permission_set', new Error('connection refused')); + }, + }), + { params: { id: 'v1' } }, + ); + expect(answer.status).toBe(503); + expect(answer.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + expect(engine.findOne).not.toHaveBeenCalled(); + expect(engine.delete).not.toHaveBeenCalled(); + }); + + it('a request naming nobody is 401 UNAUTHENTICATED', async () => { + const engine = untouchedEngine(); + const answer = await call(door({ getEngine: () => engine, resolveCaller: async () => undefined }), { + params: { id: 'v1' }, + }); + expect(answer.status).toBe(401); + expect(answer.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('a held sign-in answers the authentication policy’s own 403', async () => { + const engine = untouchedEngine(); + const held = { ...MEMBER, authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }; + const answer = await call(door({ getEngine: () => engine, resolveCaller: async () => held }), { + params: { id: 'v1' }, + }); + expect(answer.status).toBe(403); + expect(answer.body?.error?.code).toBe('PASSWORD_EXPIRED'); + expect(engine.findOne).not.toHaveBeenCalled(); + }); + + it('a query parameter is refused, not ignored: 400 VALIDATION_ERROR', async () => { + const engine = untouchedEngine(); + const answer = await call(door({ getEngine: () => engine, resolveCaller: async () => MEMBER }), { + params: { id: 'v1' }, + query: { user_id: 'someone_else' }, + }); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(engine.findOne).not.toHaveBeenCalled(); + expect(engine.delete).not.toHaveBeenCalled(); + }); + + it('no data engine registered is 503 SERVICE_UNAVAILABLE', async () => { + const answer = await call(door({ getEngine: () => undefined, resolveCaller: async () => MEMBER }), { + params: { id: 'v1' }, + }); + expect(answer.status).toBe(503); + expect(answer.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + }); + + it('an engine fault is a 500 INTERNAL_ERROR, never a refusal', async () => { + const engine = { + findOne: vi.fn().mockRejectedValue(new Error('disk on fire')), + delete: vi.fn(), + }; + const answer = await call(door({ getEngine: () => engine, resolveCaller: async () => MEMBER }), { + params: { id: 'v1' }, + }); + expect(answer.status).toBe(500); + expect(answer.body?.error?.code).toBe('INTERNAL_ERROR'); + expect(engine.delete).not.toHaveBeenCalled(); + }); + + it('the removal runs as the system, attributed to the caller, inside the caller\'s organization', async () => { + const engine = { + findOne: vi.fn().mockResolvedValue({ id: 'v1', user_id: 'u_member' }), + delete: vi.fn().mockResolvedValue(true), + }; + const answer = await call( + door({ getEngine: () => engine, resolveCaller: async () => ({ ...MEMBER, tenantId: 'org_a' }) }), + { params: { id: 'v1' } }, + ); + expect(answer.status).toBe(200); + expect(answer.body?.data).toEqual({ id: 'v1', deleted: true }); + expect(engine.findOne.mock.calls[0][1].context.userId).toBe('u_member'); + expect(engine.delete.mock.calls[0][1]).toEqual({ + where: { id: 'v1' }, + context: { isSystem: true, attributedUserId: 'u_member', tenantId: 'org_a' }, + }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/personal-view-routes.ts b/packages/plugins/plugin-auth/src/personal-view-routes.ts new file mode 100644 index 00000000000..cd63d6c2730 --- /dev/null +++ b/packages/plugins/plugin-auth/src/personal-view-routes.ts @@ -0,0 +1,286 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + evaluateAuthGate, + isApiKeyLifecycleRefusalError, + isAuthzStoreUnavailableError, + recordNotFoundError, + resolveRequestExecutionContext, + shouldDenyAnonymous, + type RequestAuthService, + type TenancyServiceResolver, +} from '@objectstack/core'; +import type { ErrorCode } from '@objectstack/spec/api'; +import type { IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { resolveThrownHttpError, sendError, sendOk, sendServerFault } from '@objectstack/types'; + +import { PERSONAL_VIEW_OBJECT } from './personal-view-hooks.js'; + +/** + * The owner's delete door for a personal view: + * `DELETE /api/v1/personal-views/:id` removes ONE `sys_personal_view` row, and + * only the caller's own (seat ruling 6583289013 on #29443, following the shape + * maintainer ruling 6422861164, letter A, set for the unreact door on #26161). + * + * ## Why a door of its own, and not the data door's `DELETE` + * + * A personal view is its owner's own record (`sys-personal-view.object.ts`). + * On the generic data door a removal is a `delete`, and `delete` needs an + * object-level grant. No every-member set may carry one: ADR-0090 D5 keeps + * delete, purge and transfer off the `everyone` anchor + * (`security-anchor-high-privilege`), and the platform baseline + * `member_default`, the only set that names this object for a member, carries + * no delete bit. So a member could save a view and never remove it. + * + * This door does not widen that grant model. The generic `DELETE` keeps + * answering exactly what it answered before, and nothing here grants `delete` + * on any row. What it adds is one verb whose authorization is STRUCTURAL: the + * row's owner (`user_id`) is the caller. That is narrower than any grant, + * because it reaches one row and only one person's. + * + * ## The order — every refusal before anything is removed + * + * 1. **The caller**, resolved by the platform's one authorization resolver + * through the one request-to-`ExecutionContext` composition + * (`resolveRequestExecutionContext`), from the request's session or API + * key. A permission store that cannot be read answers its own + * `SERVICE_UNAVAILABLE` / `503`, and an API key its lifecycle refuses + * answers its own `403` and code: both relayed as declared. + * 2. **Anonymous**: `UNAUTHENTICATED` / `401`, the platform's anonymous floor. + * 3. **A held sign-in** (an expired password, enforced MFA not yet enrolled): + * `403` with the authentication policy's own code. + * 4. **The request**: the door takes no query parameter, and one sent is + * refused (`VALIDATION_ERROR` / `400`) rather than ignored. + * 5. **The row, read AS THE CALLER.** The read runs every gate a read of a + * personal view runs: the object grant, the organization wall, and the + * owner-only row scope. A row the caller cannot read and an id that names + * no row get the same answer, `RECORD_NOT_FOUND` / `404`, the answer the + * data door gives both. Another member and an organization administrator + * (whose wildcard bypass is closed for this object) land here. + * 6. **The owner.** A row the caller CAN read whose `user_id` is anyone else + * (today only a platform administrator's bare wildcard reads one) is + * refused loudly, `PERMISSION_DENIED` / `403`, and stays. + * + * ## The removal + * + * Only after all six does the door remove the row, by id, under a system + * context. That elevation is what lets the door reach a row the caller holds + * no `delete` grant on, and step 6 is the only thing that admits it: the + * elevated write is reached by no other path. The context keeps the caller as + * `attributedUserId` and keeps the caller's organization, so the write is + * attributed to the person who asked and stays inside their organization. A + * row that vanished between the read and the removal answers `404`, as the + * data door answers a delete that matched nothing. + * + * ## What the door answers + * + * - **Success:** `200` with `{ id, deleted: true }`. + * - **A refusal** a step above names, with its `code` and `status`. + * - **Anything else** is a fault: `500` / `INTERNAL_ERROR`, the door's own + * sentence, and the error in the fault log. + */ + +/** The route pattern the door is mounted at. */ +export const PERSONAL_VIEW_REMOVE_ROUTE = '/api/v1/personal-views/:id'; + +/** The slice of the data engine the door reads and writes through. */ +export interface PersonalViewEngine { + findOne(object: string, query: { where: Record; context: ExecutionContext }): Promise; + delete(object: string, options: { where: Record; context: ExecutionContext }): Promise; +} + +/** What the door is composed over — every service read when a request arrives. */ +export interface PersonalViewRoutesDeps { + /** The data engine; `undefined` when none is registered. */ + getEngine(): PersonalViewEngine | undefined; + /** + * Resolve the caller of a request. `AuthPlugin` passes + * {@link personalViewCallerResolver}; the result is `undefined` for a + * request that names nobody. + */ + resolveCaller(req: IHttpRequest): Promise; +} + +/** Mount the owner's delete door on `http`. Nothing on `deps` is called until a request arrives. */ +export function registerPersonalViewRoutes(http: IHttpServer, deps: PersonalViewRoutesDeps): void { + http.delete(PERSONAL_VIEW_REMOVE_ROUTE, (req, res) => handleRemovePersonalView(req, res, deps)); +} + +/** The services the platform's caller resolution reads, per request. */ +export interface PersonalViewCallerSources { + /** The data engine the caller's grants are read through; `undefined` when none is registered. */ + getEngine(): unknown; + /** The auth service the caller's session is read through; `undefined` when none is available. */ + getAuth(): RequestAuthService | undefined; + /** + * How this door reaches the `tenancy` service, for the resolver's + * posture-conditional API-key refusals; `undefined` when no posture is read. + */ + tenancy: TenancyServiceResolver | undefined; +} + +/** + * The platform's caller resolution for this door: the one composition + * (`resolveRequestExecutionContext`), with the choices the unreact door makes. + * A request that names nobody resolves to `undefined`, which step 2 refuses. + */ +export function personalViewCallerResolver(sources: PersonalViewCallerSources): PersonalViewRoutesDeps['resolveCaller'] { + return (req) => { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers ?? {})) { + if (value == null) continue; + headers.set(name, Array.isArray(value) ? value.join(',') : String(value)); + } + // - `anonymous: 'fail-closed'`: the door serves signed-in members only. + // - `authGate: 'resolved'`: the posture rides the envelope, and step 3 enforces it. + // - `sessionApi: 'optional'`: with no session read, an API key still admits its owner. + // - `localization`, `requestLocale`, `accessToken`, `oauth`: withheld. The door + // answers no translated text, carries no bearer, and speaks no OAuth scope. + return resolveRequestExecutionContext({ + headers, + ql: sources.getEngine(), + authService: sources.getAuth(), + tenancy: sources.tenancy, + anonymous: 'fail-closed', + authGate: 'resolved', + credentials: 'api-key-and-session', + sessionApi: 'optional', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }); + }; +} + +/** + * The refusal {@link removeOwnPersonalView} throws for a row whose owner is + * not the caller: `PERMISSION_DENIED` / `403`, the ADR-0112 shape. + */ +function notYourViewError(id: string): Error { + const err = new Error( + `Personal view ${id} was not removed: it is another user's view, and a user removes only their own. ` + + 'The view is unchanged.', + ) as Error & { code?: string; status?: number }; + err.code = 'PERMISSION_DENIED'; + err.status = 403; + return err; +} + +/** + * Remove the caller's own personal view `id` — steps 5 and 6 of the door, and + * the removal. Throws `RECORD_NOT_FOUND` / `404` for a row the caller cannot + * read or that does not exist, and `PERMISSION_DENIED` / `403` for a row whose + * owner is someone else. `context` must name a user. + */ +export async function removeOwnPersonalView( + engine: PersonalViewEngine, + id: string, + context: ExecutionContext, +): Promise { + const callerId = context.userId; + if (!callerId) throw new Error('removeOwnPersonalView: the caller context names no user'); + + // Step 5: read the row as the caller, so every read gate decides. + const row = (await engine.findOne(PERSONAL_VIEW_OBJECT, { where: { id }, context })) as + | { user_id?: unknown } + | null + | undefined; + if (!row) throw recordNotFoundError(PERSONAL_VIEW_OBJECT, id); + + // Step 6: the structural check, and the only thing that admits the write below. + if (row.user_id !== callerId) throw notYourViewError(id); + + const removed = await engine.delete(PERSONAL_VIEW_OBJECT, { + where: { id }, + context: { + isSystem: true, + attributedUserId: callerId, + ...(context.tenantId ? { tenantId: context.tenantId } : {}), + } as ExecutionContext, + }); + // The driver's own "nothing matched": the row went between the read and here. + if (removed === false || removed === 0) throw recordNotFoundError(PERSONAL_VIEW_OBJECT, id); +} + +async function handleRemovePersonalView( + req: IHttpRequest, + res: IHttpResponse, + deps: PersonalViewRoutesDeps, +): Promise { + // ① The caller. + let context: ExecutionContext | undefined; + try { + context = await deps.resolveCaller(req); + } catch (err) { + if (isAuthzStoreUnavailableError(err) || isApiKeyLifecycleRefusalError(err)) { + sendError(res, err.status, err.code, err.message); + return; + } + sendServerFault(res, 500, 'INTERNAL_ERROR', 'The caller of this request could not be resolved.', err, { + method: 'DELETE', + path: PERSONAL_VIEW_REMOVE_ROUTE, + }); + return; + } + + // ② Anonymous. + if (!context || shouldDenyAnonymous({ userId: context.userId, method: req.method })) { + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); + return; + } + + // ③ A held sign-in. + const authGate = evaluateAuthGate(context, ''); + if (authGate) { + sendError(res, 403, authGate.code as ErrorCode, authGate.message); + return; + } + + // ④ The request. + const query = Object.keys(req.query ?? {}); + if (query.length > 0) { + sendError( + res, + 400, + 'VALIDATION_ERROR', + `Removing a personal view takes no query parameter, and this request carries ${query.map((q) => `'${q}'`).join(', ')}. ` + + 'Name the view by its id in the path and send nothing else.', + ); + return; + } + const id = typeof req.params?.id === 'string' ? req.params.id.trim() : ''; + if (id === '') { + sendError(res, 400, 'VALIDATION_ERROR', 'Name the personal view to remove by its id in the path.'); + return; + } + + const engine = deps.getEngine(); + if (!engine) { + sendError(res, 503, 'SERVICE_UNAVAILABLE', 'Personal views cannot be removed right now: no data engine is registered.'); + return; + } + + // ⑤ ⑥ and the removal. + try { + await removeOwnPersonalView(engine, id, context); + } catch (err) { + const thrown = resolveThrownHttpError(err, 500); + if (thrown.status < 500) { + sendError(res, thrown.status, thrown.code, thrown.message); + return; + } + sendServerFault(res, thrown.status, thrown.code, 'The personal view could not be removed because of an error on the server.', err, { + method: 'DELETE', + path: PERSONAL_VIEW_REMOVE_ROUTE, + }); + return; + } + sendOk(res, { id, deleted: true }); +} diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index ff8c86b04b5..9d257f01074 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -433,6 +433,19 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // `auditLedgerRowScope`) and read every organization's rows. Read only: // the ledger is append-only and written under system context. sys_audit_log: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + // A reader's own named list views, WITHOUT the superuser bits (ruling + // 6570568131 on #28971, letter C): the first version has no admin + // management, so an organization administrator reads and writes only + // their OWN views, exactly as a member does. The wildcard's + // `viewAllRecords`/`modifyAllRecords` would skip the object's owner-only + // default row scope and hand the admin every member's saved lists. The + // same grant as `member_default`'s, so the admin loses nothing of their + // own; the derived no-bypass variant inherits this line. ⛔ No + // `allowDelete`: an owner removes their own view through plugin-auth's + // own-row door (`DELETE /api/v1/personal-views/:id`), which grants + // nothing, and an admin-only delete would be the admin management the + // ruling leaves out. + sys_personal_view: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, // `manage_mailboxes` (ruling 6116489638, Q4 → A): an organization // administrator reads its organization's mailboxes and their unassociated @@ -759,6 +772,18 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // implicit; making it explicit is the migration, not a widening — the // effective access for a member is byte-identical. sys_user_preference: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + // A reader's own named list views (ruling 6570568131 on #28971, letter + // C): save and rename their own views, on the preference template. + // The object's own owner-only default row scope (`sys_personal_view_self`, + // declared on the object, `operation: 'all'`) decides WHICH rows, so the + // grant reaches the holder's own views only. The object is `private` + // posture, so this explicit line is the only way a member reaches it. + // ⛔ No `allowDelete`, for the anchor reason above: the owner removes + // their own view through plugin-auth's own-row door + // (`DELETE /api/v1/personal-views/:id`, seat ruling 6583289013 on + // #29443), whose authorization is structural (owner == caller), not a + // bit this set carries. + sys_personal_view: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, // [#7344] The personal inbox, READ-ONLY. Same reasoning as the line above, // applied to the other pair of platform objects a platform app points every // authenticated member at: the Account app's `Inbox` group declares a diff --git a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts index 928bb7c4e86..5da74618ffb 100644 --- a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -9,10 +9,12 @@ * here off `defaultPermissionSets`, never retyped. * * Four pins: - * 1. The declarers are exactly the 23 objects the ruling names: every object - * `@objectstack/platform-objects` and `@objectstack/plugin-audit` register - * that declares a default is on the list, every listed object declares - * one, and the platform-global objects declare none (item 5). + * 1. The declarers are exactly the 23 objects the ruling names, plus the + * objects added since that declare their scope from birth + * (`DECLARED_AT_BIRTH`): every object `@objectstack/platform-objects` and + * `@objectstack/plugin-audit` register that declares a default is on one + * of the two lists, every listed object declares one, and the + * platform-global objects declare none (item 5). * 2. Each declaration is the shipped literal with `object` dropped: same * name, operation and predicate as the baseline set's policy for the * object, and every shipped set that carries a policy for it carries the @@ -112,6 +114,18 @@ const DROPPED_COPIES: Record !(n in DROPPED_COPIES)); +/** + * Objects added AFTER the ruling that declare their scope from birth: the + * declaration is their only row scope, so no shipped set ever carried a copy + * and there is no undeclared "before" tree to compare against. Outside the + * ruling's 23 (and outside the byte-for-byte baseline below) on purpose. + * - `sys_personal_view`: a reader's own named list views, on the + * `sys_user_preference` template (ruling 6570568131 on #28971, letter C). + */ +const DECLARED_AT_BIRTH: Record = { + sys_personal_view: { name: 'sys_personal_view_self', operation: 'all', using: 'user_id == current_user.id' }, +}; + /** Ruling E′ item 5: the platform-global objects declare no default. */ const PLATFORM_GLOBAL = /^(sys_metadata|sys_job|sys_migration)|^(sys_platform_setting|sys_presence|sys_flow_dispatch)$/; @@ -159,7 +173,7 @@ describe('the platform identity objects declare their default row scope (ruling const objects = await registeredObjects(); const declaring = [...objects.values()].filter((o) => o.defaultRowScope !== undefined).map((o) => o.name); - expect([...declaring].sort()).toEqual([...DECLARERS].sort()); + expect([...declaring].sort()).toEqual([...DECLARERS, ...Object.keys(DECLARED_AT_BIRTH)].sort()); expect(DECLARERS).toHaveLength(23); const platformGlobal = [...objects.keys()].filter((n) => PLATFORM_GLOBAL.test(n)); @@ -202,6 +216,16 @@ describe('the platform identity objects declare their default row scope (ruling }, ); + it.each(Object.keys(DECLARED_AT_BIRTH))( + '%s declares its scope from birth, and no shipped set carries a copy of it', + async (object) => { + const declared = (await registeredObjects()).get(object)!.defaultRowScope!; + expect(asLiteral(object, declared)).toEqual({ ...DECLARED_AT_BIRTH[object], object, enabled: true }); + expect('object' in declared).toBe(false); + for (const set of defaultPermissionSets) expect(policiesFor(set.name, object), set.name).toEqual([]); + }, + ); + it('sys_user declares its two shipped literals as one `select` policy, the predicates joined with `||`', async () => { const declared = (await registeredObjects()).get('sys_user')!.defaultRowScope!; for (const setName of ['organization_admin', 'member_default', 'viewer_readonly']) { diff --git a/packages/qa/dogfood/test/personal-view-row-scope.dogfood.test.ts b/packages/qa/dogfood/test/personal-view-row-scope.dogfood.test.ts new file mode 100644 index 00000000000..e5d6dbb5abe --- /dev/null +++ b/packages/qa/dogfood/test/personal-view-row-scope.dogfood.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_personal_view` over REST on the real showcase stack — a reader's own + * named list views, the server half of ruling 6570568131 on #28971 (letter C). + * + * The engine-level pins live beside the hooks + * (`plugin-auth/src/personal-view-hooks.test.ts`); this file is the HTTP face a + * console reader actually reaches, with the real composition, the real + * authorization resolver and the real error mapping: + * + * - `[owner]` a plain member saves, lists, reads and renames their own view + * through `/data/sys_personal_view`; + * - `[member]` another member reads none of them by id (404), lists none and + * updates none (404), and cannot delete one (403, no grant reaches it); + * - `[org-admin]` an organization administrator gets no back door: the same + * 404s, because this object is named without the wildcard's super-user + * bits in the organization administrator's set; + * - `[body]` a body that is not spec's `ListView` answers `400 + * VALIDATION_FAILED` with every `fields[]` path under `config`; + * - `[predicate]` a view that filters on a field the reader cannot read is + * saved (a save does not judge field security), and the list query that + * applies its filter is refused `403 PERMISSION_DENIED` by the existing + * predicate guard, naming the field; + * - `[meta]` the metadata API never serves a personal view as a view item; + * - `[door]` the owner's delete door, `DELETE /api/v1/personal-views/:id` + * (seat ruling 6583289013 on #29443, the #26161 shape): the owner removes + * their own view; another member and an organization administrator get + * the unknown-id 404 and the row stays; a platform administrator, whose + * bare wildcard reads the row, is refused loudly (403) and the row stays; + * anonymous is 401; and the generic data DELETE of the owner's own row is + * still 403, because the door grants nothing (`member_default` is bound to + * the `everyone` anchor and carries no delete bit, ADR-0090 D5). + * + * - `[platform-admin]` the recorded boundary: `admin_full_access`'s bare + * wildcard reads, edits and deletes a member's view through the generic + * data door, as it does on `sys_user_preference` (the admin rescue path); + * only the own-row door refuses its delete. + * + * Refusals assert `code` AND `status` (ADR-0112). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; +import { catalogPermissionSet } from './catalog-permission-set.js'; + +const OBJ = 'sys_personal_view'; +const TARGET = 'showcase_project'; +/** The field the scratch set hides from the predicate persona. */ +const DENIED = 'budget'; +const SCRATCH_SET = 'personal_view_scratch_fls_read_deny'; +const SYS = { isSystem: true } as const; + +const ALICE = 'pv.alice@verify.test'; +const BOB = 'pv.bob@verify.test'; +const CAROL = 'pv.carol.orgadmin@verify.test'; + +const recordOf = (b: any) => b?.record ?? b?.data ?? b; +const rowsOf = (b: any) => b?.records ?? b?.data ?? (Array.isArray(b) ? b : []); +const errorCodeOf = (b: any) => b?.error?.code ?? b?.code; + +const listView = (extra: Record = {}) => ({ + type: 'grid', + data: { provider: 'object', object: TARGET }, + columns: ['name'], + ...extra, +}); + +describe('sys_personal_view over REST: a reader\'s own named list views (ruling 6570568131, letter C)', () => { + let stack: VerifyStack; + let ql: any; + let aliceTok: string; + let bobTok: string; + let carolTok: string; + let adminTok: string; + let aliceId: string; + + const userIdOf = async (email: string): Promise => { + const id = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + expect(id, `${email} provisioned`).toBeTruthy(); + return String(id); + }; + + /** Save a view as `token`'s owner and return the created row. */ + const save = async (token: string, ownerId: string, label: string, extra: Record = {}) => { + const r = await stack.apiAs(token, 'POST', `/data/${OBJ}`, { + user_id: ownerId, object_name: TARGET, label, config: listView(), ...extra, + }); + expect(r.status, await r.clone().text()).toBe(201); + const row = recordOf(await r.json()); + expect(row?.id).toBeTruthy(); + return row; + }; + + beforeAll(async () => { + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); + adminTok = await stack.signIn(); + aliceTok = await stack.signUp(ALICE); + bobTok = await stack.signUp(BOB); + carolTok = await stack.signUp(CAROL); + ql = await stack.kernel.getServiceAsync('objectql'); + aliceId = await userIdOf(ALICE); + + // CAROL holds the organization administrator's set, granted the way an + // administrator's grant is stored. + await ql.insert('sys_user_permission_set', { user_id: await userIdOf(CAROL), permission_set: 'organization_admin' }, { context: SYS }); + + // BOB is the predicate persona: a scratch set hides `showcase_project.budget` + // from him, authored through the metadata door like + // `showcase-fls-read-mask-strip.dogfood.test.ts`'s. + const authored = await stack.apiAs(adminTok, 'PUT', `/meta/permission/${SCRATCH_SET}`, { + name: SCRATCH_SET, + label: 'Scratch: FLS read deny on showcase_project.budget (personal views)', + description: 'Test-authored: a field the predicate persona cannot read.', + objects: {}, + fields: { [`${TARGET}.${DENIED}`]: { readable: false, editable: false } }, + }); + expect(authored.status, 'scratch set authored through the metadata door').toBeLessThan(300); + expect(await catalogPermissionSet(ql, SCRATCH_SET), 'scratch permission set served').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: await userIdOf(BOB), permission_set: SCRATCH_SET }, { context: SYS }); + }, 180_000); + + afterAll(async () => { await stack?.stop?.(); }); + + it('[owner] saves, lists, reads and renames their own view', async () => { + const row = await save(aliceTok, aliceId, 'Alice: this week'); + expect(String(row.id), 'the id is the record id, never the object.key view syntax').not.toContain('.'); + + const list = await stack.apiAs(aliceTok, 'GET', `/data/${OBJ}`); + expect(list.status).toBe(200); + expect(rowsOf(await list.json()).map((r: any) => r.label)).toContain('Alice: this week'); + + const renamed = await stack.apiAs(aliceTok, 'PATCH', `/data/${OBJ}/${row.id}`, { label: 'Alice: this week, hot' }); + expect(renamed.status, await renamed.clone().text()).toBe(200); + const read = await stack.apiAs(aliceTok, 'GET', `/data/${OBJ}/${row.id}`); + expect(read.status).toBe(200); + expect(recordOf(await read.json()).label).toBe('Alice: this week, hot'); + }); + + it('[member] another member reads none by id, lists none, updates none and deletes none', async () => { + const row = await save(aliceTok, aliceId, 'Alice: private'); + + const byId = await stack.apiAs(bobTok, 'GET', `/data/${OBJ}/${row.id}`); + expect(byId.status).toBe(404); + + const list = await stack.apiAs(bobTok, 'GET', `/data/${OBJ}`); + expect(list.status).toBe(200); + expect(rowsOf(await list.json())).toEqual([]); + + const patch = await stack.apiAs(bobTok, 'PATCH', `/data/${OBJ}/${row.id}`, { label: 'hijacked' }); + expect(patch.status).toBe(404); + expect(errorCodeOf(await patch.json())).toBe('RECORD_NOT_FOUND'); + + const del = await stack.apiAs(bobTok, 'DELETE', `/data/${OBJ}/${row.id}`); + expect(del.status).toBe(403); + expect(errorCodeOf(await del.json())).toBe('PERMISSION_DENIED'); + + const after = await ql.findOne(OBJ, { where: { id: row.id }, context: SYS }); + expect(after?.label, 'the row is untouched').toBe('Alice: private'); + }); + + it('[member] cannot plant a view in another member\'s name', async () => { + const r = await stack.apiAs(bobTok, 'POST', `/data/${OBJ}`, { + user_id: aliceId, object_name: TARGET, label: 'planted', config: listView(), + }); + expect(r.status).toBe(403); + expect(errorCodeOf(await r.json())).toBe('PERMISSION_DENIED'); + expect(await ql.findOne(OBJ, { where: { label: 'planted' }, context: SYS })).toBeFalsy(); + }); + + it('[org-admin] an organization administrator reads none of a member\'s views and updates none', async () => { + const row = await save(aliceTok, aliceId, 'Alice: not for the admin'); + + const byId = await stack.apiAs(carolTok, 'GET', `/data/${OBJ}/${row.id}`); + expect(byId.status).toBe(404); + + const list = await stack.apiAs(carolTok, 'GET', `/data/${OBJ}`); + expect(list.status).toBe(200); + expect(rowsOf(await list.json()).map((r: any) => r.id)).not.toContain(row.id); + + const patch = await stack.apiAs(carolTok, 'PATCH', `/data/${OBJ}/${row.id}`, { label: 'admin edit' }); + expect(patch.status).toBe(404); + expect(errorCodeOf(await patch.json())).toBe('RECORD_NOT_FOUND'); + expect((await ql.findOne(OBJ, { where: { id: row.id }, context: SYS }))?.label).toBe('Alice: not for the admin'); + }); + + it('[body] a body that is not a ListView answers 400 VALIDATION_FAILED, paths under config', async () => { + const r = await stack.apiAs(aliceTok, 'POST', `/data/${OBJ}`, { + user_id: aliceId, object_name: TARGET, label: 'Broken', config: { type: 'not_a_view_type', columns: 'name' }, + }); + expect(r.status, await r.clone().text()).toBe(400); + const body: any = await r.json(); + expect(errorCodeOf(body)).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string }> = body?.error?.details?.fields ?? body?.error?.fields ?? body?.fields ?? []; + expect(fields.length).toBeGreaterThan(0); + for (const f of fields) expect(f.field.startsWith('config')).toBe(true); + expect(await ql.findOne(OBJ, { where: { label: 'Broken' }, context: SYS })).toBeFalsy(); + }); + + it('[body] a body listing another object answers 400 VALIDATION_FAILED on config.data.object', async () => { + const r = await stack.apiAs(aliceTok, 'POST', `/data/${OBJ}`, { + user_id: aliceId, object_name: TARGET, label: 'Wrong object', + config: { ...listView(), data: { provider: 'object', object: 'showcase_task' } }, + }); + expect(r.status, await r.clone().text()).toBe(400); + const body: any = await r.json(); + expect(errorCodeOf(body)).toBe('VALIDATION_FAILED'); + const fields: Array<{ field: string }> = body?.error?.details?.fields ?? body?.error?.fields ?? body?.fields ?? []; + expect(fields.map((f) => f.field)).toEqual(['config.data.object']); + }); + + it('[owner] cannot hand their view to another member (user_id re-parent refused)', async () => { + const row = await save(aliceTok, aliceId, 'Alice: keep mine'); + const r = await stack.apiAs(aliceTok, 'PATCH', `/data/${OBJ}/${row.id}`, { user_id: await userIdOf(BOB) }); + expect(r.status, await r.clone().text()).toBe(403); + expect((await ql.findOne(OBJ, { where: { id: row.id }, context: SYS }))?.user_id).toBe(aliceId); + }); + + it('[predicate] a view filtering on an unreadable field is saved, and its list query is refused by the predicate guard', async () => { + const bobId = await userIdOf(BOB); + const row = await save(bobTok, bobId, 'Bob: big budgets', { + config: listView({ filter: [{ field: DENIED, operator: 'greater_than', value: 0 }] }), + }); + + // Read the stored view back through its own REST list, and apply its filter. + const mine = await stack.apiAs(bobTok, 'GET', `/data/${OBJ}`); + const stored = rowsOf(await mine.json()).find((r: any) => r.id === row.id); + expect(stored, 'the owner reads the view back').toBeTruthy(); + const config = typeof stored.config === 'string' ? JSON.parse(stored.config) : stored.config; + const [rule] = config.filter; + expect(rule.field).toBe(DENIED); + + const filter = encodeURIComponent(JSON.stringify({ [rule.field]: { $gt: rule.value } })); + const q = await stack.apiAs(bobTok, 'GET', `/data/${TARGET}?$filter=${filter}`); + expect(q.status, 'the stored filter does not reveal the hidden field').toBe(403); + const body: any = await q.json(); + expect(errorCodeOf(body)).toBe('PERMISSION_DENIED'); + expect(JSON.stringify(body), 'the refusal names the field').toContain(DENIED); + }); + + it('[meta] the metadata API never serves a personal view as a view item', async () => { + const row = await save(aliceTok, aliceId, 'pv_meta_probe_label'); + const list = await stack.apiAs(aliceTok, 'GET', '/meta/view'); + expect(list.status).toBe(200); + const text = await list.text(); + expect(text).not.toContain(String(row.id)); + expect(text).not.toContain('pv_meta_probe_label'); + + const byId = await stack.apiAs(aliceTok, 'GET', `/meta/view/${row.id}`); + expect(byId.status).toBe(404); + }); + + describe('[door] DELETE /personal-views/:id removes the owner\'s own view, and nobody else\'s', () => { + const door = (id: string) => `/personal-views/${id}`; + const stillThere = async (id: string) => ql.findOne(OBJ, { where: { id }, context: SYS }); + + it('[door] the owner removes their own view: 200 { id, deleted: true }, and the row is gone', async () => { + const row = await save(aliceTok, aliceId, 'Alice: to remove'); + const r = await stack.apiAs(aliceTok, 'DELETE', door(row.id)); + expect(r.status, await r.clone().text()).toBe(200); + const body: any = await r.json(); + expect(body?.data ?? body).toEqual({ id: row.id, deleted: true }); + expect(await stillThere(row.id)).toBeFalsy(); + }); + + it('[door] another member: 404 RECORD_NOT_FOUND, and the row stays', async () => { + const row = await save(aliceTok, aliceId, 'Alice: not Bob\'s to remove'); + const r = await stack.apiAs(bobTok, 'DELETE', door(row.id)); + expect(r.status, await r.clone().text()).toBe(404); + expect(errorCodeOf(await r.json())).toBe('RECORD_NOT_FOUND'); + expect((await stillThere(row.id))?.label).toBe('Alice: not Bob\'s to remove'); + }); + + it('[door] an organization administrator: 404 RECORD_NOT_FOUND, and the row stays', async () => { + const row = await save(aliceTok, aliceId, 'Alice: not the org admin\'s to remove'); + const r = await stack.apiAs(carolTok, 'DELETE', door(row.id)); + expect(r.status, await r.clone().text()).toBe(404); + expect(errorCodeOf(await r.json())).toBe('RECORD_NOT_FOUND'); + expect((await stillThere(row.id))?.label).toBe('Alice: not the org admin\'s to remove'); + }); + + it('[door] a platform administrator, who reads the row, is refused loudly: 403 PERMISSION_DENIED, and the row stays', async () => { + const row = await save(aliceTok, aliceId, 'Alice: not the platform admin\'s to remove'); + const read = await stack.apiAs(adminTok, 'GET', `/data/${OBJ}/${row.id}`); + expect(read.status, 'the platform administrator\'s wildcard reads the row (the recorded boundary)').toBe(200); + const r = await stack.apiAs(adminTok, 'DELETE', door(row.id)); + expect(r.status, await r.clone().text()).toBe(403); + expect(errorCodeOf(await r.json())).toBe('PERMISSION_DENIED'); + expect((await stillThere(row.id))?.label).toBe('Alice: not the platform admin\'s to remove'); + }); + + it('[platform-admin] RECORDED BOUNDARY: the generic data door reads, edits and deletes a member\'s view', async () => { + const row = await save(aliceTok, aliceId, 'Alice: within the platform admin\'s reach'); + const read = await stack.apiAs(adminTok, 'GET', `/data/${OBJ}/${row.id}`); + expect(read.status, await read.clone().text()).toBe(200); + const patch = await stack.apiAs(adminTok, 'PATCH', `/data/${OBJ}/${row.id}`, { label: 'edited by the platform admin' }); + expect(patch.status, await patch.clone().text()).toBe(200); + expect((await stillThere(row.id))?.label).toBe('edited by the platform admin'); + const del = await stack.apiAs(adminTok, 'DELETE', `/data/${OBJ}/${row.id}`); + expect(del.status, await del.clone().text()).toBeLessThan(300); + expect(await stillThere(row.id)).toBeFalsy(); + }); + + it('[door] anonymous: 401 UNAUTHENTICATED, and the row stays', async () => { + const row = await save(aliceTok, aliceId, 'Alice: not anonymous\'s to remove'); + const r = await stack.api(door(row.id), { method: 'DELETE' }); + expect(r.status, await r.clone().text()).toBe(401); + expect(errorCodeOf(await r.json())).toBe('UNAUTHENTICATED'); + expect(await stillThere(row.id)).toBeTruthy(); + }); + + it('[door] the grant model does not move: the generic data DELETE of the owner\'s own row is still 403', async () => { + const row = await save(aliceTok, aliceId, 'Alice: generic delete'); + const r = await stack.apiAs(aliceTok, 'DELETE', `/data/${OBJ}/${row.id}`); + expect(r.status, await r.clone().text()).toBe(403); + expect(errorCodeOf(await r.json())).toBe('PERMISSION_DENIED'); + expect(await stillThere(row.id)).toBeTruthy(); + }); + }); +}); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 43b809da4de..50de8777065 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -72,6 +72,7 @@ import { STORAGE_ROUTE_LEDGER } from '../../../services/service-storage/src/stor import { I18N_ROUTE_LEDGER } from '../../../services/service-i18n/src/i18n-route-ledger.js'; import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/settings-route-ledger.js'; import { PRINT_ROUTE_LEDGER } from '../../../services/service-print/src/print-route-ledger.js'; +import { PERSONAL_VIEW_ROUTE_LEDGER } from '../../../plugins/plugin-auth/src/personal-view-route-ledger.js'; /** One ledger row, normalized to a wire pattern this gate can probe. */ interface LedgerRow { @@ -108,6 +109,9 @@ function collectLedgerRows(): LedgerRow[] { for (const r of I18N_ROUTE_LEDGER) add('service-i18n/i18n-route-ledger.ts', r.route, ''); for (const r of SETTINGS_ROUTE_LEDGER) add('service-settings/settings-route-ledger.ts', r.route, ''); for (const r of PRINT_ROUTE_LEDGER) add('service-print/print-route-ledger.ts', r.route, ''); + // [#29443] AuthPlugin's own-row delete door for a personal view, mounted on + // the IHttpServer port (unlike its raw-app `/auth/*` catch-all above). + for (const r of PERSONAL_VIEW_ROUTE_LEDGER) add('plugin-auth/personal-view-route-ledger.ts', r.route, ''); return rows; } diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 65545643f93..45229789bcb 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -73,6 +73,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly