|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#21237] The identity object's `Admin` field group is served to an org peer |
| 4 | +// only when the reader holds an admin set — the shipped sets' half, pinned |
| 5 | +// against the identity object's DECLARATION rather than against a list. |
| 6 | +// |
| 7 | +// What the card measured: `member_default` opens every org peer's identity row |
| 8 | +// (`sys_user_org_members`) and declared no field-level security on it, so an |
| 9 | +// org member was served the whole `Admin` group of a colleague's row — and, |
| 10 | +// through the activity field redaction that reads the same served projection, |
| 11 | +// every colleague's history of those fields. The ruling (triage, direction A): |
| 12 | +// the permission set's existing `fields` → `readable: false`, on the shipped |
| 13 | +// non-admin sets, with the admin sets keeping the group. ⛔ No hand-kept list: |
| 14 | +// the withheld set is held EQUAL to the declared group here, so a field the |
| 15 | +// declaration adds to the group cannot slip through. |
| 16 | +// |
| 17 | +// Three pins, each read off a source the module under test does not own: |
| 18 | +// |
| 19 | +// 1. GROUP EQUALITY — every shipped set's identity-object field entries are |
| 20 | +// exactly the group the declaration names, read here from `SysUser` |
| 21 | +// itself (never from the module's own helper), with the posture each set |
| 22 | +// class must carry; |
| 23 | +// 2. COMPLETENESS — every shipped set that opens an org peer's identity row |
| 24 | +// is classified by an INDEPENDENT property (does it carry an org-peer |
| 25 | +// `sys_user` select policy?), so a new non-admin set opening that read |
| 26 | +// without withholding the group is red here, not silently served; |
| 27 | +// 3. COMPOSITION — the evaluator's REAL most-permissive merge, over the sets |
| 28 | +// each persona resolves. `member_default` is the additive `everyone` |
| 29 | +// baseline (ADR-0090 D5): an admin resolves it too, so the admin half is |
| 30 | +// only real if the merge keeps the group for them. |
| 31 | +// |
| 32 | +// The HTTP door (direct read, the activity metadata through the field |
| 33 | +// redaction, the member's own row, the field-level write gate and the user |
| 34 | +// picker's candidate query) is pinned on a real boot in |
| 35 | +// `packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts`. |
| 36 | + |
| 37 | +import { describe, it, expect } from 'vitest'; |
| 38 | +import type { PermissionSet } from '@objectstack/spec/security'; |
| 39 | +import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; |
| 40 | +import { SysUser } from '@objectstack/platform-objects/identity'; |
| 41 | +import { PermissionEvaluator } from './permission-evaluator.js'; |
| 42 | +import { defaultPermissionSets } from './objects/default-permission-sets.js'; |
| 43 | + |
| 44 | +const IDENTITY = 'sys_user'; |
| 45 | + |
| 46 | +/** The declared group, read off the declaration — the pin's independent side. */ |
| 47 | +const DECLARED_ADMIN_GROUP: string[] = Object.entries(SysUser.fields as Record<string, { group?: string }>) |
| 48 | + .filter(([, field]) => field.group === 'Admin') |
| 49 | + .map(([name]) => name) |
| 50 | + .sort(); |
| 51 | + |
| 52 | +/** |
| 53 | + * Fields a member already reads on a peer's row — the controls that must stay |
| 54 | + * served. `banned` is among them by ruling (triage on #21237, Q1 = B): the |
| 55 | + * deactivation flag is directory status, declared outside the `Admin` group, |
| 56 | + * because every user picker filters candidates on it and a filter on a withheld |
| 57 | + * field is refused. Moving it back into the group is red here. |
| 58 | + */ |
| 59 | +const PEER_DIRECTORY_FIELDS = ['name', 'email', 'image', 'banned']; |
| 60 | + |
| 61 | +const ADMIN_SETS = [ADMIN_FULL_ACCESS, ...ORGANIZATION_ADMIN_GRANTS]; |
| 62 | +const WITHHOLDING_SETS = ['member_default', 'viewer_readonly']; |
| 63 | + |
| 64 | +const setByName = (name: string): PermissionSet => { |
| 65 | + const set = defaultPermissionSets.find((s) => s.name === name); |
| 66 | + if (!set) throw new Error(`shipped set ${name} not found`); |
| 67 | + return set; |
| 68 | +}; |
| 69 | + |
| 70 | +/** One set's identity-object field entries, keyed by field name. */ |
| 71 | +const identityFieldEntries = (set: PermissionSet): Record<string, { readable?: boolean; editable?: boolean }> => |
| 72 | + Object.fromEntries( |
| 73 | + Object.entries(set.fields ?? {}) |
| 74 | + .filter(([key]) => key.startsWith(`${IDENTITY}.`)) |
| 75 | + .map(([key, perm]) => [key.slice(IDENTITY.length + 1), perm]), |
| 76 | + ); |
| 77 | + |
| 78 | +/** Does this set open an org peer's identity row? Read off its row-level security. */ |
| 79 | +const opensOrgPeerIdentityRead = (set: PermissionSet): boolean => |
| 80 | + (set.rowLevelSecurity ?? []).some( |
| 81 | + (p) => p.object === IDENTITY && (p.operation === 'select' || p.operation === 'all') && /org_user_ids/.test(p.using ?? ''), |
| 82 | + ); |
| 83 | + |
| 84 | +describe('[#21237] the identity object Admin group — withheld from org peers, kept by admin sets', () => { |
| 85 | + it('the declared group is non-empty and names the field the card measured (vacuity guard)', () => { |
| 86 | + // Without this, a renamed group would make BOTH sides of the equality |
| 87 | + // below empty, and every pin in this file green over nothing. |
| 88 | + expect(DECLARED_ADMIN_GROUP.length).toBeGreaterThan(0); |
| 89 | + expect(DECLARED_ADMIN_GROUP).toContain('last_login_ip'); |
| 90 | + for (const control of PEER_DIRECTORY_FIELDS) expect(DECLARED_ADMIN_GROUP).not.toContain(control); |
| 91 | + }); |
| 92 | + |
| 93 | + it.each(WITHHOLDING_SETS)('%s withholds exactly the declared group (readable: false), and nothing else on the identity object', (name) => { |
| 94 | + const entries = identityFieldEntries(setByName(name)); |
| 95 | + expect(Object.keys(entries).sort()).toEqual(DECLARED_ADMIN_GROUP); |
| 96 | + for (const field of DECLARED_ADMIN_GROUP) { |
| 97 | + expect(entries[field], field).toEqual({ readable: false, editable: false }); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + it.each(ADMIN_SETS)('%s keeps exactly the declared group (readable: true, editable: true — the no-entry state)', (name) => { |
| 102 | + const entries = identityFieldEntries(setByName(name)); |
| 103 | + expect(Object.keys(entries).sort()).toEqual(DECLARED_ADMIN_GROUP); |
| 104 | + for (const field of DECLARED_ADMIN_GROUP) { |
| 105 | + expect(entries[field], field).toEqual({ readable: true, editable: true }); |
| 106 | + } |
| 107 | + }); |
| 108 | + |
| 109 | + it('every other shipped set names no identity-object field at all', () => { |
| 110 | + const others = defaultPermissionSets |
| 111 | + .filter((s) => !ADMIN_SETS.includes(s.name) && !WITHHOLDING_SETS.includes(s.name)) |
| 112 | + .filter((s) => Object.keys(identityFieldEntries(s)).length > 0) |
| 113 | + .map((s) => s.name); |
| 114 | + expect(others).toEqual([]); |
| 115 | + }); |
| 116 | + |
| 117 | + it('completeness: every shipped set opening the identity row of an org peer is an admin set or withholds the group', () => { |
| 118 | + const openers = defaultPermissionSets.filter(opensOrgPeerIdentityRead).map((s) => s.name).sort(); |
| 119 | + // The classification itself must have found something — an empty opener |
| 120 | + // list would make the loop below vacuous. |
| 121 | + expect(openers).toEqual(expect.arrayContaining(['member_default', 'viewer_readonly'])); |
| 122 | + for (const name of openers) { |
| 123 | + const entries = identityFieldEntries(setByName(name)); |
| 124 | + const expected = ADMIN_SETS.includes(name); |
| 125 | + for (const field of DECLARED_ADMIN_GROUP) { |
| 126 | + expect(entries[field]?.readable, `${name} → ${field}`).toBe(expected); |
| 127 | + } |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + describe('composition — the real most-permissive merge of the evaluator over the sets each persona resolves', () => { |
| 132 | + const evaluator = new PermissionEvaluator(); |
| 133 | + const mask = (names: string[]) => evaluator.getFieldPermissions(IDENTITY, names.map(setByName)); |
| 134 | + |
| 135 | + it('an org member (the `everyone` baseline alone) is withheld every group field, and served the directory fields', () => { |
| 136 | + const perms = mask(['member_default']); |
| 137 | + for (const field of DECLARED_ADMIN_GROUP) expect(perms[field]?.readable, field).toBe(false); |
| 138 | + // The controls: no entry at all means the field masker serves them. |
| 139 | + for (const field of PEER_DIRECTORY_FIELDS) expect(perms[field], field).toBeUndefined(); |
| 140 | + }); |
| 141 | + |
| 142 | + it('a read-only viewer (baseline + viewer_readonly) is withheld every group field', () => { |
| 143 | + const perms = mask(['member_default', 'viewer_readonly']); |
| 144 | + for (const field of DECLARED_ADMIN_GROUP) expect(perms[field]?.readable, field).toBe(false); |
| 145 | + }); |
| 146 | + |
| 147 | + it.each(ADMIN_SETS)('an admin holding %s on top of the baseline keeps every group field, readable and editable', (name) => { |
| 148 | + const perms = mask(['member_default', name]); |
| 149 | + for (const field of DECLARED_ADMIN_GROUP) { |
| 150 | + expect(perms[field], field).toEqual({ readable: true, editable: true }); |
| 151 | + } |
| 152 | + }); |
| 153 | + }); |
| 154 | +}); |
0 commit comments