You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 7ffc578
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol): an object's references list a permission set named only by its fields keys (#23539) (#23554)
Fixes#23539
Clause-②: no
## Summary
`findReferencesToMeta` serves `GET
/api/v1/meta/object/:name/references`, the admin "Used by" panel. It now
lists a permission set that names an object only in its `fields` keys.
`PermissionSetSchema.fields` is keyed `OBJECT.FIELD`, and the security
plugin applies a set's field rules to an object by that key prefix
(`PermissionEvaluator.getFieldPermissions`). Before this change, such a
set was missing from the object's answer. The panel could then read
"Safe to delete." while the set still masked the object's fields.
## What changed
- **`packages/metadata-protocol/src/reference-sites.ts`.** A
composite-key seed, `COMPOSITE_KEY_REFERENCE_SITES`: `permission.fields`
names an `object` through the text before a key's first `.`. It is
exposed as a new index member, `compositeKeyByTarget`, and kept OUT of
`byTarget`.
- Both premises are derived from the schemas, in the
`nameVocabularyRejectsKey` style, and fail closed. `permission` must
still declare `fields` as a root record, and `ObjectSchema.name` must
still reject `.`. If either stops holding, the seed drops out and the
derivation pin goes red.
- The seed's target is typed `'object'` alone.
- `nameVocabularyRejectsKey`'s schema read is factored into
`nameVocabularyVerdict`. Its answers are unchanged: the existing
`unanswerableTargetTypes` pins still pass.
- **`packages/metadata-protocol/src/protocol.ts`, two regions only.**
- A new `collectCompositeKeyHeadHits`, placed beside
`collectReferenceHits`. `referencedNamesAt` and `collectReferenceHits`
are byte-unchanged.
- It reads only the seeded root record's keys, and matches the text
before the FIRST `.` (dot index above 0).
- A key's field part is never read. A key with no object part
(`.full_name`) names no object.
- One row per site, at path `fields{key}`.
- `findReferencesToMeta` folds the composite sites into the same
one-read-per-source-type matchers. The new row's kind is `permission
fields`. Every existing row, the response shape and the `field` target's
501 are unchanged.
- **`duplicatePackage` is not touched,** and its
`byTarget.get('object')` reading is unchanged.
### Why the seed is not a `byTarget` row
This was measured in round 1 (report comment 6179812710, experiment E1)
with a plain `{ permission, fields, object }` row in `byTarget`. The
answer did not change, because the walk compares a WHOLE value with the
object's name. And `duplicatePackage` broke. It reads
`byTarget.get('object')` as a flat set of object-naming property names,
so a copied object's own `fields` key was renamed.
`protocol-duplicate-package-unprefixed-object.test.ts` went red, with
`noteCopy.fields.ticket` undefined. The derivation pin now asserts that
`byTarget('object')` has no `permission.fields` site.
### Why the split is the first `.`
`ObjectSchema.name` and `FieldSchema.name` are both
`^[a-z_][a-z0-9_]*$`, so neither can contain `.`. The evaluator
(`key.startsWith(object + '.')`), lint `security-fls-unknown-field` and
`duplicatePackage`'s field-rule rewrite all make the same split.
Save-door readings (`saveMetaItem`, real kernel):
- A deeper key on an in-stack object (`customer.full_name.extra`) is
refused with 422 `security-fls-unknown-field`.
- A bare key is refused with 422 `security-fls-unqualified-key`.
- A key for an out-of-stack object (`no_such_object.x`) and an
empty-object-part key (`.full_name`) are stored.
## Evidence (final head `8ca891b1d5`)
- **Real-kernel pin**,
`packages/objectql/src/protocol-references-permission-field-keys.test.ts`:
ObjectKernel + ObjectQLPlugin + memory driver; every item passes the
real save door.
- On `main` (round 1, `9e67e008e1` over `7b64a3d73d`): `Tests 2 failed |
2 passed (4)`. The two failures were `expected [ Array(1) ] to include
'refsrc_customer_fls@fields{key}'` and `expected [] to deeply equal [
'refsrc_invoice_fls@fields{key}' ]`.
- After the fix: `Tests 4 passed (4)`.
- The pin covers four cases. (1) The set is listed at `fields{key}`. (2)
Negative control: a set whose keys name only another object is not
listed, even where its FIELD part spells the asked object; it is listed
once for the object it does name. (3) The `objects{key}` row is
unchanged. (4) The `field` target still refuses with `NOT_IMPLEMENTED` /
501.
- **`field` target byte-identity.** A real-kernel probe captured
`findReferencesToMeta({ type: 'field', name: 'refsrc_customer.full_name'
})` before and after the fix. Both runs gave `NOT_IMPLEMENTED 501
refusal:true`, message sha256
`7b4aee8698a564f69599ba38e1b3f54ec2623cd5336a8e57e267b093ea4b2457`. The
probe was a throwaway file, deleted afterwards.
- Before, `refsrc_customer` answered the grant row only. After, it
answers that same row plus the new `fields{key}` row.
- **Ablation.** The walk branch was deleted: the line calling
`collectCompositeKeyHeadHits` in `findReferencesToMeta`.
- Mutation: `ablation-replace.mjs`, anchor 1→0, blob
`5be7b1c1adc7`→`09028903ed91`.
- Positive control on the pristine build: the call marker is present in
`dist/index.js` and `dist/index.cjs`.
- The rebuild's ESM/CJS emitted. DTS refused with TS6133 (the helper was
now unused), which does not affect the JS artifacts the suite loads.
- `ablation-dist-preflight --absent`: marker absent from dist.
- Pin: `Tests 2 failed | 2 passed (4)`, the same two listing assertions.
- Restore: blob == HEAD with `git diff HEAD` empty. The rebuild
re-emitted both declaration files (`check-dts-emitted 2/2`). Preflight:
marker present in both JS files, and the tree is clean. Pin: `Tests 4
passed (4)`.
- **Suites.**
- `pnpm --filter @objectstack/metadata-protocol exec vitest run`: `Test
Files 236 passed | 3 skipped (239)`, `Tests 28412 passed | 19 skipped`.
- `pnpm --filter @objectstack/objectql exec vitest run --project local`:
`Test Files 419 passed (419)`, `Tests 8112 passed`. This includes
`protocol-references`, `protocol-duplicate-package-unprefixed-object`
and `protocol-duplicate-package-permission-field-keys`.
- `reference-sites.derivation.test.ts`: `Tests 25 passed (25)`.
- **Typecheck.**
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0.
`--listFiles` includes `reference-sites.ts`, `protocol.ts` and
`reference-sites.derivation.test.ts`.
- `pnpm --filter @objectstack/objectql typecheck`: exit 0, with
`check:test-typecheck: OK — 40 file(s) / 234 error(s) / 65 pinned
signature(s) held`.
- **Derived gates** at `8ca891b1d5`: `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` derived 67 commands from
the 5 changed paths. All 67 were run, each exit captured before any
pipe. 66 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 with
PREREQUISITE NOT MET: it needs about 70 packages built, which this
worktree does not hold, so it is NOT MEASURED here and CI builds them.
`pnpm check:lean-entry-closure` first exited 3 on a missing
`packages/objectql/dist`; after an objectql build it was re-measured at
exit 0. `--ran` reconciliation: `67 derived, 66 run, 1 NOT-MEASURED, 0
UNRUN`. The 11 wide-population families and the 6 workflow-valued
families are CI's.
## Acceptance notes
- The new row's path spelling `fields{key}` follows the existing
`objects{key}` grammar. A set naming an object in both `objects` and
`fields` answers two rows, one per path. That is the walk's existing
one-row-per-path behaviour.
- Noted, not filed: the `field` target's refusal text says every
property that names a field holds the bare field name.
`permission.fields` is the one derived field site whose keys are the
composite form, so the sentence is inaccurate for that one site. The
refusal itself stays honest, and the card keeps the `field` answer
unmoved.
- Noted, not filed: `duplicatePackage` reads `byTarget('object')` as a
source-type-blind property-name set. This is why the seed lives in its
own map, and the derivation pin guards it.
- A save-door finding about the empty-object-part FLS key (`.full_name`)
went to the seat for filing. It is out of this PR's scope.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol): an object's "Used by" answer lists a permission set that names the object only in its `fields` keys (#23539)
6
+
7
+
**What was wrong.**`findReferencesToMeta` serves `GET /api/v1/meta/object/:name/references`, the admin "Used by" panel, which reads an empty answer as "Safe to delete." `PermissionSetSchema.fields` is keyed `<object>.<field>`, and the security plugin applies a set's field rules to an object by that key prefix. The reference index read `permission.fields` only as a site naming `field` items, never an object. So a set whose only rule for an object was a field rule (`objects: {}`, `fields: { 'customer.full_name': … }`) was missing from that object's answer, and the object could be cleared for deletion while the set still masked its fields.
8
+
9
+
**What the answer reads now.** Such a set is listed for the object that each `fields` key starts with, at path `fields{key}` with kind `permission fields`. The object part is the text before the key's first `.`, the same split the security plugin makes. A key whose field part spells another object's name is not a reference to that object. A key with no object part (`.full_name`) names no object.
10
+
11
+
Unchanged: every existing row of the answer, the response shape, the `field` target's `501 NOT_IMPLEMENTED` refusal, and `duplicatePackage`.
0 commit comments