|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#22203] Every package-declared item of a `security`-domain type that the |
| 5 | + * type registry declares `allowOrgOverride: false` is refused at the metadata |
| 6 | + * save door — and the refusal is the type-level one, fed by the provenance |
| 7 | + * seam, not a lock written for one type. |
| 8 | + * |
| 9 | + * ## What was wrong, measured |
| 10 | + * |
| 11 | + * The save door's packaged-base check is already type-level: it refuses a write |
| 12 | + * onto an item a code package ships when the type has no overlay channel |
| 13 | + * (`refusePackagedBaseOverride` in `saveMetaItem` on an environment-scoped |
| 14 | + * kernel, `SysMetadataRepository.assertAllowed`'s `override-artifact` intent on |
| 15 | + * a host-config one), and both read `allowOrgOverride` off |
| 16 | + * `DEFAULT_METADATA_TYPE_REGISTRY`. What decides "a code package ships it" is |
| 17 | + * `isArtifactBacked`, which asks this engine's SchemaRegistry for an entry a |
| 18 | + * package registered (`getArtifactItem`, `_packageId` provenance). |
| 19 | + * |
| 20 | + * The only seam that puts a stack collection into that registry under its |
| 21 | + * package is `registerMetadataCollections` over `METADATA_ARRAY_KEYS`. That list |
| 22 | + * carried `permissions` and `capabilities`, and the retired `roles` instead of |
| 23 | + * `positions` (ADR-0090 D3 renamed the collection; the rename reached the |
| 24 | + * artifact door's map and never this one). So a stack-declared position had no |
| 25 | + * registry entry, `isArtifactBacked('position', name)` answered false, the save |
| 26 | + * took the `runtime-only` intent, and `allowRuntimeCreate: true` let it through: |
| 27 | + * `PUT /api/v1/meta/position/NAME` over a package's position answered 200 and |
| 28 | + * the saved row then won the by-name read. A permission set was refused on the |
| 29 | + * same door because its collection was on the list. |
| 30 | + * |
| 31 | + * ## What this file pins |
| 32 | + * |
| 33 | + * The set under test is ENUMERATED FROM THE REGISTRY, not written out: every |
| 34 | + * `domain: 'security'` row with `allowOrgOverride: false`. For each, a real |
| 35 | + * `ObjectQL` registers a manifest that declares one item through the type's |
| 36 | + * stack collection, and a real `ObjectStackProtocolImplementation` over that |
| 37 | + * engine is asked to save over it — on both topologies, because the two answer |
| 38 | + * at different layers. The rejection is asserted on the envelope (`code` + |
| 39 | + * `status`), and nothing may be stored. |
| 40 | + * |
| 41 | + * No security plugin is composed here, so the permission-set refusal below is |
| 42 | + * the protocol's own type-level door, not plugin-security's packaged |
| 43 | + * permission-set lock (which stays a stricter layer on top of it). |
| 44 | + * |
| 45 | + * Controls, so the refusals cannot pass for the wrong reason: a position no |
| 46 | + * package declares still saves (the `allowRuntimeCreate` tier is untouched); an |
| 47 | + * `allowOrgOverride: true` type still saves over its packaged item; and the |
| 48 | + * by-name read after a refusal still serves the package's declaration. |
| 49 | + */ |
| 50 | + |
| 51 | +import { describe, expect, it } from 'vitest'; |
| 52 | +import type { ServiceObject } from '@objectstack/spec/data'; |
| 53 | +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; |
| 54 | +import { singularToPlural } from '@objectstack/spec/shared'; |
| 55 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 56 | +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; |
| 57 | +import { ObjectQL } from './engine.js'; |
| 58 | + |
| 59 | +const PKG = 'com.acme.security_catalog'; |
| 60 | +const NAME = 'probe_item'; |
| 61 | + |
| 62 | +/** The pin's population, read off the registry: every non-overridable `security` type. */ |
| 63 | +const SECURITY_NON_OVERRIDABLE = DEFAULT_METADATA_TYPE_REGISTRY |
| 64 | + .filter((entry) => entry.domain === 'security' && entry.allowOrgOverride === false) |
| 65 | + .map((entry) => entry.type); |
| 66 | + |
| 67 | +/** |
| 68 | + * A schema-valid body per type. A type that joins the population without one |
| 69 | + * fails the first case below by name, rather than dropping out of the pin. |
| 70 | + */ |
| 71 | +const BODIES: Record<string, Record<string, unknown>> = { |
| 72 | + permission: { name: NAME, label: 'Probe', objects: {} }, |
| 73 | + position: { name: NAME, label: 'Probe' }, |
| 74 | + capability: { name: NAME, label: 'Probe' }, |
| 75 | +}; |
| 76 | + |
| 77 | +/** The overlayable control: `allowOrgOverride: true`, declared through a stack collection. */ |
| 78 | +const OVERLAYABLE_TYPE = 'email_template'; |
| 79 | +const OVERLAYABLE_BODY = { name: NAME, label: 'Probe', subject: 'Probe', bodyHtml: '<p>Probe</p>' }; |
| 80 | + |
| 81 | +const sysMetadataObject: ServiceObject = { |
| 82 | + name: 'sys_metadata', |
| 83 | + label: 'System Metadata', |
| 84 | + fields: { |
| 85 | + id: { name: 'id', label: 'ID', type: 'text' as const }, |
| 86 | + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, |
| 87 | + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, |
| 88 | + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, |
| 89 | + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, |
| 90 | + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, |
| 91 | + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, |
| 92 | + state: { name: 'state', label: 'State', type: 'text' as const }, |
| 93 | + version: { name: 'version', label: 'Version', type: 'number' as const }, |
| 94 | + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, |
| 95 | + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, |
| 96 | + }, |
| 97 | +}; |
| 98 | + |
| 99 | +/** In-memory driver, copied from `save-meta-response-conformance.test.ts`; equality-only WHERE. */ |
| 100 | +function makeMemoryDriver() { |
| 101 | + const stores = new Map<string, Map<string, Record<string, unknown>>>(); |
| 102 | + const storeFor = (obj: string) => { |
| 103 | + let s = stores.get(obj); |
| 104 | + if (!s) { s = new Map(); stores.set(obj, s); } |
| 105 | + return s; |
| 106 | + }; |
| 107 | + let nextId = 0; |
| 108 | + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real |
| 109 | + // driver ANDs them (#7620). |
| 110 | + const matchesWhere = (row: Record<string, unknown>, where: any): boolean => { |
| 111 | + if (!where || typeof where !== 'object') return true; |
| 112 | + for (const [k, v] of Object.entries(where)) { |
| 113 | + if (k === '$and' && Array.isArray(v)) { |
| 114 | + if (!v.every((w: any) => matchesWhere(row, w))) return false; |
| 115 | + continue; |
| 116 | + } |
| 117 | + if (k === '$or' && Array.isArray(v)) { |
| 118 | + if (!v.some((w: any) => matchesWhere(row, w))) return false; |
| 119 | + continue; |
| 120 | + } |
| 121 | + if (k.startsWith('$')) continue; |
| 122 | + const rowVal = row[k]; |
| 123 | + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; |
| 124 | + const a = rowVal === undefined ? null : rowVal; |
| 125 | + const b = expected === undefined ? null : expected; |
| 126 | + if (a !== b) return false; |
| 127 | + } |
| 128 | + return true; |
| 129 | + }; |
| 130 | + const driver: any = { |
| 131 | + name: 'memory', version: '0.0.0', supports: {} as any, |
| 132 | + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, |
| 133 | + async execute() { return null; }, |
| 134 | + async find(object: string, ast: any) { |
| 135 | + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); |
| 136 | + // The caller's bound, after the filter, by presence (`check:objectql-double-limit`). |
| 137 | + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; |
| 138 | + }, |
| 139 | + async findOne(object: string, ast: any) { |
| 140 | + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; |
| 141 | + return null; |
| 142 | + }, |
| 143 | + async create(object: string, data: Record<string, unknown>) { |
| 144 | + nextId += 1; |
| 145 | + const id = (data.id as string) ?? `r_${nextId}`; |
| 146 | + const row = { ...data, id }; |
| 147 | + storeFor(object).set(id, row); |
| 148 | + return row; |
| 149 | + }, |
| 150 | + async update(object: string, id: string, data: Record<string, unknown>) { |
| 151 | + const s = storeFor(object); |
| 152 | + const cur = s.get(id); |
| 153 | + if (!cur) throw new Error(`not found: ${object}/${id}`); |
| 154 | + const updated = { ...cur, ...data, id }; |
| 155 | + s.set(id, updated); |
| 156 | + return updated; |
| 157 | + }, |
| 158 | + async upsert(object: string, data: Record<string, unknown>) { |
| 159 | + const id = data.id as string | undefined; |
| 160 | + if (id && storeFor(object).has(id)) return this.update(object, id, data); |
| 161 | + return this.create(object, data); |
| 162 | + }, |
| 163 | + async delete(object: string, id: string) { return storeFor(object).delete(id); }, |
| 164 | + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, |
| 165 | + async bulkCreate(object: string, rows: Record<string, unknown>[]) { |
| 166 | + return Promise.all(rows.map((r) => this.create(object, r))); |
| 167 | + }, |
| 168 | + async bulkUpdate() { return []; }, async bulkDelete() {}, |
| 169 | + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, |
| 170 | + async commit() {}, async rollback() {}, |
| 171 | + }; |
| 172 | + return { driver, stores }; |
| 173 | +} |
| 174 | + |
| 175 | +/** One manifest declaring `NAME` through every security collection under test, plus the control. */ |
| 176 | +function securityManifest(): Record<string, unknown> { |
| 177 | + const manifest: Record<string, unknown> = { id: PKG, name: 'security_catalog' }; |
| 178 | + for (const type of SECURITY_NON_OVERRIDABLE) { |
| 179 | + if (BODIES[type]) manifest[singularToPlural(type)] = [BODIES[type]]; |
| 180 | + } |
| 181 | + manifest[singularToPlural(OVERLAYABLE_TYPE)] = [OVERLAYABLE_BODY]; |
| 182 | + return manifest; |
| 183 | +} |
| 184 | + |
| 185 | +type Topology = 'environment-scoped' | 'host-config'; |
| 186 | +const TOPOLOGIES: readonly Topology[] = ['environment-scoped', 'host-config']; |
| 187 | + |
| 188 | +async function boot(topology: Topology) { |
| 189 | + const engine = new ObjectQL(); |
| 190 | + const { driver, stores } = makeMemoryDriver(); |
| 191 | + engine.registerDriver(driver, true); |
| 192 | + await engine.init(); |
| 193 | + engine.registry.registerObject(sysMetadataObject, 'test-package'); |
| 194 | + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { |
| 195 | + engine.registry.registerObject(o as any, 'test-package'); |
| 196 | + } |
| 197 | + engine.registerApp(securityManifest()); |
| 198 | + const protocol = new ObjectStackProtocolImplementation( |
| 199 | + engine, |
| 200 | + () => new Map(), |
| 201 | + topology === 'environment-scoped' ? 'env_prod' : undefined, |
| 202 | + ); |
| 203 | + const storedRows = () => Array.from(stores.get('sys_metadata')?.values() ?? []); |
| 204 | + return { engine, protocol, storedRows }; |
| 205 | +} |
| 206 | + |
| 207 | +describe('[#22203] security-domain allowOrgOverride:false — the package door is type-level', () => { |
| 208 | + it('the population is read from the registry, and every member has a fixture', () => { |
| 209 | + // The three the registry declares today. Asserted as a superset so a |
| 210 | + // fourth `security` type joins the pin instead of breaking it — and the |
| 211 | + // per-type loop below then demands a body for it by name. |
| 212 | + expect(SECURITY_NON_OVERRIDABLE).toEqual(expect.arrayContaining(['permission', 'position', 'capability'])); |
| 213 | + for (const type of SECURITY_NON_OVERRIDABLE) { |
| 214 | + expect(BODIES[type], `no fixture body for security type '${type}'`).toBeDefined(); |
| 215 | + } |
| 216 | + }); |
| 217 | + |
| 218 | + it.each(SECURITY_NON_OVERRIDABLE)('the provenance seam registers a stack-declared %s under its package', async (type) => { |
| 219 | + const { engine } = await boot('host-config'); |
| 220 | + // The input `isArtifactBacked` reads: an entry a package registered. |
| 221 | + const artifact = engine.registry.getArtifactItem<any>(type, NAME); |
| 222 | + expect(artifact?._packageId).toBe(PKG); |
| 223 | + expect(artifact?._provenance).toBe('package'); |
| 224 | + }); |
| 225 | + |
| 226 | + for (const topology of TOPOLOGIES) { |
| 227 | + it.each(SECURITY_NON_OVERRIDABLE)(`${topology}: a save over a package-declared %s is refused 403 NOT_OVERRIDABLE and stores nothing`, async (type) => { |
| 228 | + const { protocol, storedRows } = await boot(topology); |
| 229 | + |
| 230 | + const err: any = await protocol |
| 231 | + .saveMetaItem({ type, name: NAME, item: { ...BODIES[type], label: 'Environment fork' } }) |
| 232 | + .then(() => undefined, (e: unknown) => e); |
| 233 | + |
| 234 | + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 }); |
| 235 | + expect(storedRows()).toHaveLength(0); |
| 236 | + }); |
| 237 | + } |
| 238 | + |
| 239 | + it('the by-name read after the refusal still serves the package\'s position', async () => { |
| 240 | + const { protocol } = await boot('host-config'); |
| 241 | + await protocol |
| 242 | + .saveMetaItem({ type: 'position', name: NAME, item: { name: NAME, label: 'Environment fork' } }) |
| 243 | + .catch(() => undefined); |
| 244 | + |
| 245 | + const read: any = await protocol.getMetaItem({ type: 'position', name: NAME }); |
| 246 | + expect(read?.item?.label).toBe('Probe'); |
| 247 | + }); |
| 248 | + |
| 249 | + // ── controls ───────────────────────────────────────────────────────── |
| 250 | + |
| 251 | + it.each(TOPOLOGIES)('%s: a position no package declares still saves (the allowRuntimeCreate tier)', async (topology) => { |
| 252 | + const { protocol, storedRows } = await boot(topology); |
| 253 | + |
| 254 | + const result: any = await protocol.saveMetaItem({ |
| 255 | + type: 'position', name: 'env_only_position', item: { name: 'env_only_position', label: 'Env only' }, |
| 256 | + }); |
| 257 | + |
| 258 | + expect(result?.success).toBe(true); |
| 259 | + expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual(['position/env_only_position']); |
| 260 | + }); |
| 261 | + |
| 262 | + it.each(TOPOLOGIES)(`%s: an allowOrgOverride:true type (${OVERLAYABLE_TYPE}) still saves over its packaged item`, async (topology) => { |
| 263 | + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERLAYABLE_TYPE); |
| 264 | + expect(entry?.allowOrgOverride).toBe(true); |
| 265 | + const { engine, protocol, storedRows } = await boot(topology); |
| 266 | + // The control is only a control if its item is package-declared too. |
| 267 | + expect(engine.registry.getArtifactItem<any>(OVERLAYABLE_TYPE, NAME)?._packageId).toBe(PKG); |
| 268 | + |
| 269 | + const result: any = await protocol.saveMetaItem({ |
| 270 | + type: OVERLAYABLE_TYPE, name: NAME, item: { ...OVERLAYABLE_BODY, label: 'Environment overlay' }, |
| 271 | + }); |
| 272 | + |
| 273 | + expect(result?.success).toBe(true); |
| 274 | + expect(storedRows().map((r: any) => `${r.type}/${r.name}`)).toEqual([`${OVERLAYABLE_TYPE}/${NAME}`]); |
| 275 | + }); |
| 276 | +}); |
0 commit comments