Skip to content

Commit 28ba0c7

Browse files
os-zhuangclaude
andauthored
fix(security/sharing/rest/app-showcase): #2926 复测缺陷 + #2909 两存储审计 (#2983)
* fix(plugin-security): protect platform/package-stamped sys_position rows (#2926 ①) The system-row write gate (#2918/#2930) keyed sys_position provenance on the legacy managed_by values (system/config), but the A4 vocab unification (#2934) stamps and boot-normalizes rows to platform/package — so the gate silently stopped firing for positions, letting admins physically delete the everyone/ guest audience anchors once their bindings were removed. Guard both the canonical and legacy vocabularies, update the gate tests to the canonical values, add legacy-row regressions, and correct the stale 'no runtime path branches on legacy values' safety comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * fix(rest): pass through explicit 4xx status+code in mapDataError (#2926 ⑦) Record-scope authorization denials (plugin-sharing throws status=403, code=FORBIDDEN) degraded to a bare 400 with no code because the generic data routes call mapDataError directly, bypassing sendError's status passthrough. Add a guarded 4xx passthrough after the structured-code branches (409 envelopes keep their rich fields; 5xx still goes through the sanitizing heuristics; oversized messages fall back to generic text), and stop logging expected statuses as unhandled on the list route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * feat(metadata-protocol): reject unknown $-prefixed query params with 400 (#2926 ⑩) Unsupported $ parameters (e.g. $foo, $inlinecount) used to fall into the implicit-filter bucket and silently match zero rows — and before the $filter alias existed, were dropped entirely, returning the unfiltered first page to callers that believed they had a filtered result set. All supported aliases are consumed before the guard, so anything $-prefixed that remains is a hard 400 UNSUPPORTED_QUERY_PARAM naming the offending keys and the supported list. Bare-key implicit equality filters are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * feat(plugin-sharing): backfill rule grants at boot (#2926 ③) Sharing-rule grants are materialized by write hooks that deliberately skip isSystem writes, so seed-loader records never got sys_record_share rows — demo data with matching rules was broken on a fresh deploy until each record was touched at runtime. Reconcile every active rule once per boot (idempotent, best-effort per rule) right after the rule hooks bind. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1) sys_sharing_rule is record-authoritative (ADR-0094 addendum): declared rules are a boot seed, the row is the authority — but every boot re-ran a clobbering upsert, so an admin's active:false on an over-sharing rule was silently resurrected on redeploy. Add readonly managed_by (A4 tri-state) + customized provenance columns, put defineRule in seed mode when the bootstrap passes managedBy:'package' (pristine/legacy rows adopted and updated; admin-authored or customized rows untouched), and stamp customized via a beforeUpdate hook on any non-system edit of a seeded rule. No write gate on purpose — sharing rules stay a first-class admin authoring surface; edits are remembered, not blocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * test(plugin-security): lock bootstrap-declared-positions seed semantics (#2909 T2) sys_position is record-authoritative — the declared seeder refreshes only label/description and must never touch bindings, active, is_default, delegatable, or managed_by. That behavior predates these tests but was never locked; regressions would silently clobber admin state at every boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * fix(plugin-security): stop clobbering admin-edited capability scope (#2909 T3) scope is an admin-editable classification select on sys_capability, but the curated seeder refreshed it every boot — silently reverting admin reclassifications. Make it seed-once (insert only); label/description remain platform-owned and keep refreshing. Lock both sides with tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * docs(adr): resolve 0094 addendum audit items; record sharing-rule seed-not-clobber tradeoff (#2909 T4) The three 'Audit that…' per-type rows are now shipped decisions: sys_sharing_rule = record-authoritative with provenance + seed-not-clobber; sys_position = seed-only identity/display, locked by test; sys_capability = seed-not-clobber with scope seed-once. Record why sharing rules got no write gate (admin tuning surface), the customized-stamp boundaries, and the scope migration cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * feat(app-showcase): bind persona positions on kernel:listening + announcements; document isDefault dual-track (#2926 ②⑤) A fresh deploy booted with ZERO position↔permission-set bindings — every persona silently degraded to the everyone baseline. Bindings are record-authoritative (ADR-0090/0094) and cannot be a declarative seed (the seed loader runs before the security bootstrap creates sys_position / sys_permission_set, so name references can't resolve). They are ensured imperatively by registerShowcasePositionBindings on kernel:listening — the phase that fires only after every kernel:ready handler (incl. the security bootstrap) has settled, so the referenced rows exist. everyone→member_default is bound here too: the framework only auto-binds an app's isDefault set to everyone when it is application-owned; the showcase ships as a package, so its default lands in sys_audience_binding_suggestion (pending admin confirmation) and is not live until confirmed. Also seeds showcase_announcement demo rows and documents the isDefault dual-track (app-level auto-bind vs package-level suggestion) in the spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe * fix(plugin-sharing): defer rule backfill to kernel:listening so seed rows materialize (#2926 ③) The boot backfill ran inside a kernel:ready handler, but SeedLoader also seeds on kernel:ready (raced against a budget, in a different AppPlugin handler). Since kernel:ready handlers fire sequentially in registration order, the backfill could run before the seed records exist and materialize nothing. Move the reconcile to kernel:listening (Phase 4), which the kernel fires only after every kernel:ready handler has settled — so the seeded rows are present. Verified end-to-end: a seeded red project now yields a sys_record_share to the exec recipient at boot, no runtime touch required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bJWtKmZ2mFpRFAmmmoeqe --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a79e179 commit 28ba0c7

32 files changed

Lines changed: 1152 additions & 43 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): findData now rejects unknown `$`-prefixed query parameters with 400 `UNSUPPORTED_QUERY_PARAM` instead of silently treating them as implicit field-equality filters that match zero rows (#2926 ⑩). A `$`-prefixed key can never be a field name, so this is loud-failure only for the unsupported-alias class; bare-key implicit equality filtering is unchanged. The error message lists the supported aliases ($top, $skip, $orderby, $select, $count, $search, $searchFields, $filter, $expand).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): mapDataError now honors an explicit 4xx `error.status`/`error.code` carried by domain errors (#2926 ⑦). Record-scope authorization denials from plugin-sharing (status 403, code FORBIDDEN) previously degraded to a bare 400 with no machine-readable code because the generic data routes bypass sendError's status passthrough. Structured 409 envelopes (CONCURRENT_UPDATE, DELETE_RESTRICTED) keep their dedicated branches; 5xx statuses still go through the message-sanitizing heuristics.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): stop clobbering admin-edited capability `scope` on boot (#2909 T3). `scope` is an admin-editable classification select on sys_capability, but the curated seeder refreshed it on every boot alongside label/description — silently reverting admin reclassifications. It is now seed-once: written on insert, never refreshed (a curated scope change in a new platform version requires a data migration; recorded in the ADR-0094 addendum).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): re-arm the sys_position system-row write gate after the A4 managed_by rename (#2926 ①). The gate's provenance map still keyed on the legacy `system`/`config` values while rows are now stamped (and boot-normalized to) `platform`/`package`, so platform/package-managed positions — including the `everyone`/`guest` audience anchors — could be physically deleted through the data API once their bindings were removed. The map now guards both the canonical and legacy vocabularies, and the misleading "no runtime path branches on legacy values" safety notes were corrected.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
feat(plugin-sharing): sys_sharing_rule provenance + seed-not-clobber (#2909 P0/T1). The object gains readonly `managed_by` (unified A4 tri-state platform/package/admin) and `customized` columns; declared rules seed with `managed_by: 'package'`. defineRule in seed mode adopts pristine/legacy rows (package upgrades stay deliverable) but never overwrites admin-authored or customized rows — an admin's `active: false` on an over-sharing rule now survives redeploys instead of being resurrected at boot. A beforeUpdate hook stamps `customized` on any non-system edit of a seeded rule. Deliberately NO write gate: sharing rules remain a first-class admin authoring surface (ADR-0094 addendum tradeoff).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
fix(plugin-sharing): reconcile every active sharing rule once at boot (#2926 ③). Rule grants are materialized by write hooks, which deliberately skip `isSystem` writes — so seed-loader records never produced `sys_record_share` rows and demo data shipping with matching sharing rules was broken out of the box until each record was touched at runtime. The boot backfill runs on `kernel:listening` — the phase the kernel fires only after every `kernel:ready` handler has settled, including the AppPlugin seed loader — so the reconcile sees the seeded rows rather than racing them. It is idempotent (diff-based reconcile) and best-effort per rule so one broken rule cannot block startup.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): rewrite the `isDefault` permission-set docs to describe the actual dual-track behavior (#2926 ②): app-level `isDefault` sets are resolved as the SecurityPlugin's fallback and idempotently auto-bound to the `everyone` anchor at boot (guarded by the high-privilege-bits check), while package-level sets are never auto-bound and instead materialize a `sys_audience_binding_suggestion` an admin confirms. The previous "never auto-bound" wording contradicted the shipped app-level track.

docs/adr/0094-sys-permission-set-pure-projection.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -301,14 +301,38 @@ only a seed-not-clobber discipline.
301301
| Type | Enforcement reads | Class | Decision |
302302
| :-- | :-- | :-- | :-- |
303303
| `sys_permission_set` | metadata (`PermissionEvaluator.resolvePermissionSets``metadata.list('permission')`, DB row only as fallback) | metadata-authoritative | **Record is a projection — done** (this ADR). |
304-
| `sys_sharing_rule` | the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service` `engine.find`) | **record-authoritative** | Declared rules are a **boot seed**; the record is the authority. Do **not** project. Audit that `bootstrapDeclaredSharingRules` preserves env-edited rows (seed-not-clobber) and that the metadata overlay is not read as a live override. |
305-
| `sys_position` | mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position` **record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate` | **needs the seed-vs-authority audit** against the criterion above; likely record-authoritative for bindings with metadata seeding identity | Classify precisely, then either project (if the position *definition* is enforced from metadata) or make declared positions seed-only. |
306-
| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | Seed-only; low authoring surface. Audit seed-not-clobber. |
304+
| `sys_sharing_rule` | the record, live (`sharing-plugin.ts` "rule evaluation reads `sys_sharing_rule` live"; `sharing-rule-service` `engine.find`) | **record-authoritative** | **Resolved (#2909 P0/T1)**: declared rules are a **boot seed**; the record is the authority; not projected. Rows carry readonly `managed_by` (unified A4 tri-state) + `customized` provenance; the seeder (`defineRule` in seed mode, `managedBy:'package'`) adopts pristine/legacy rows and keeps updating them, but **never overwrites admin-authored or `customized` rows** — an admin's `active:false` on an over-sharing rule survives redeploys. A `beforeUpdate` hook stamps `customized` on any non-system edit of a seeded row. |
305+
| `sys_position` | mixed — position→permission-set resolution is metadata-first for the *sets*, but the `sys_position` **record** (incl. its `permissions` field, bindings, `delegatable`, `admin` gating) is read live by the anchor gate and `DelegatedAdminGate` | **record-authoritative** (bindings and lifecycle), with metadata seeding **identity + display only** | **Resolved (#2909 T2)**: declared positions are seed-only — `bootstrapDeclaredPositions` refreshes `label`/`description` and nothing else; bindings/`active`/`is_default`/`delegatable`/`managed_by` belong to the runtime/admin. Locked by `bootstrap-declared-positions.test.ts`. |
306+
| `sys_capability` | the record (curated registry read for capability existence) | record-authoritative (registry) | **Resolved (#2909 T3)**: seed-not-clobber holds — `label`/`description` are platform-owned and refresh each boot; `managed_by`/`active` were already preserved; `scope` is an admin-editable classification face and is now **seed-once** (insert only). Locked by `bootstrap-system-capabilities.test.ts`. |
307307

308308
Only `sys_permission_set` was both metadata-authoritative **and** carried a
309309
harmful, actively-drifting split-brain, which is why it was fixed first and in
310-
full. The others are recorded here with their class so the follow-up work is
311-
scoped, not rediscovered — tracked in framework#2909.
310+
full. The follow-up work above landed via framework#2909.
311+
312+
#### Recorded tradeoffs (#2909)
313+
314+
- **Why `sys_sharing_rule` chose seed-not-clobber over a write gate
315+
(projection-lite).** A self-consistent alternative existed: gate
316+
package-managed rows against admin writes (as #2918 does for
317+
`sys_position`/`sys_capability`), making the boot overwrite harmless. It was
318+
rejected because sharing rules are a first-class admin authoring *and
319+
tuning* surface in Setup — including narrowing or deactivating a seeded
320+
rule that turned out to over-share. Locking package rows would amputate
321+
that product capability, and the classification criterion (enforcement
322+
reads the record) already points at record authority. Admin edits are
323+
therefore *remembered* (`customized`), not blocked. Only the two provenance
324+
columns themselves are `readonly` (engine-stripped from non-system
325+
payloads), so provenance cannot be forged or cleared through the data door.
326+
- **Known boundaries of the `customized` stamp**: multi-row updates (no
327+
single `input.id`) are not stamped — every rule-editing UI path updates by
328+
id; an admin who *deletes* a package rule gets it back pristine on the next
329+
boot (delete ≠ customize; deactivation is the supported way to retire a
330+
seeded rule); a "reset to package default" affordance (system-context clear
331+
of `customized`) is future work.
332+
- **`sys_capability.scope` is seed-once**: a curated scope change shipped in
333+
a new platform version no longer propagates to existing rows and needs a
334+
data migration. Accepted — silently reverting an admin's reclassification
335+
every boot was the worse failure mode.
312336

313337
### Why an addendum, not a new ADR
314338

examples/app-showcase/objectstack.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex
1616
import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js';
1717
import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js';
1818
import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js';
19+
import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js';
1920
import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews } from './src/ui/views/index.js';
2021
import { ShowcaseApp } from './src/ui/apps/index.js';
2122
import { ChartGalleryDashboard, OpsDashboard } from './src/ui/dashboards/index.js';
@@ -218,4 +219,7 @@ export const onEnable = async (ctx: unknown): Promise<void> => {
218219
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
219220
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
220221
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[0]);
222+
// [#2926 ②] Ensure the persona position↔permission-set bindings exist after
223+
// the security bootstraps (cannot be a seed — see bind-position-sets.ts).
224+
registerShowcasePositionBindings(ctx as Parameters<typeof registerShowcasePositionBindings>[0]);
221225
};

examples/app-showcase/src/data/seed/index.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js';
1313
import { Contact } from '../objects/contact.object.js';
1414
import { Inquiry } from '../objects/inquiry.object.js';
1515
import { FieldZoo } from '../objects/field-zoo.object.js';
16+
import { Announcement } from '../objects/announcement.object.js';
1617

1718
/**
1819
* Seed data sized to "feed every view": every Kanban column is populated,
@@ -161,6 +162,13 @@ const orgUnits = SeedSchema.parse({
161162
],
162163
});
163164

165+
// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed
166+
// loader runs before the security bootstrap creates the sys_position /
167+
// sys_permission_set rows, so the required name references cannot resolve.
168+
// They are ensured imperatively on kernel:listening instead (after every
169+
// kernel:ready handler, incl. the security bootstrap, has settled) — see
170+
// `src/security/bind-position-sets.ts` (wired via `onEnable`).
171+
164172
const teams = defineSeed(Team, {
165173
mode: 'upsert',
166174
externalId: 'name',
@@ -286,4 +294,20 @@ const preferences = defineSeed(Preference, {
286294
],
287295
});
288296

289-
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences];
297+
/**
298+
* [#2926 ⑤] Announcements — the read-visibility demo object finally ships
299+
* with data, so its assertions stop dry-running on a fresh DB. The object has
300+
* NO `name` field (display name derives from `title`); `owner_id` stays
301+
* unset — users can't be seeded, and creation rights are deliberately narrow
302+
* (only `showcase_ops` may create; everyone else is read-only by design).
303+
*/
304+
const announcements = defineSeed(Announcement, {
305+
mode: 'upsert',
306+
externalId: 'title',
307+
records: [
308+
{ title: 'Welcome to the Showcase workspace', body: 'This demo org exercises the full permission model: positions, permission sets, sharing rules and field-level security. Log in as different personas to compare what each can see and edit.' },
309+
{ title: 'Q3 field-ops rollout', body: 'Field Operations onboards the new inquiry intake flow this quarter. New public inquiries are shared automatically with the Field Ops subtree.' },
310+
],
311+
});
312+
313+
export const ShowcaseSeedData = [accounts, contacts, inquiries, products, projects, tasks, categories, businessUnits, orgUnits, teams, memberships, fieldZoo, invoices, invoiceLines, preferences, announcements];

0 commit comments

Comments
 (0)