diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 749fe471ba..d857f05fb6 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1365,3 +1365,41 @@ describe('RLSCompiler — SQL→CEL deprecation bridge (ADR-0058 D1)', () => { expect(warned.length).toBe(0); }); }); + +describe('SecurityPlugin – metadata-change cache invalidation', () => { + it('clears metadata-derived caches when metadata changes at runtime', async () => { + const plugin = new SecurityPlugin(); + let watchCb: (() => void) | undefined; + const metadata = { + watch: (_type: string, cb: () => void) => { + watchCb = cb; + return { unsubscribe: vi.fn() }; + }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + hook: vi.fn(), + getService: vi.fn().mockImplementation((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'metadata') return metadata; + if (name === 'objectql') return { registerMiddleware: vi.fn() }; + return undefined; + }), + }; + await plugin.init(ctx); + await plugin.start(ctx); + + expect(typeof watchCb).toBe('function'); + + (plugin as any).fieldNamesCache.set('crm_account', new Set(['name'])); + (plugin as any).tenancyDisabledCache.set('crm_account', true); + (plugin as any).cbpRelCache.set('crm_account', null); + + watchCb!(); + + expect((plugin as any).fieldNamesCache.size).toBe(0); + expect((plugin as any).tenancyDisabledCache.size).toBe(0); + expect((plugin as any).cbpRelCache.size).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2476e96074..ad9b7277e0 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -108,6 +108,8 @@ export class SecurityPlugin implements Plugin { */ private metadata: any = null; private ql: any = null; + /** Unsubscribe handle for metadata-change cache invalidation (runtime metadata edits). */ + private metadataWatch: { unsubscribe: () => void } | null = null; /** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */ private cbpRelCache = new Map(); private dbLoader?: (names: string[]) => Promise; @@ -212,6 +214,19 @@ export class SecurityPlugin implements Plugin { this.logger = ctx.logger; this.rlsCompiler.setLogger?.(ctx.logger); + // Invalidate metadata-derived caches when object/field metadata changes + // at runtime (Studio / AI authoring). Without this they go stale until + // restart — even single-node. With a cluster pub/sub driver the + // metadata.changed event propagates cross-node, so peers invalidate too. + const md: any = this.metadata; + if (typeof md?.watch === 'function') { + this.metadataWatch = md.watch('*', () => { + this.fieldNamesCache.clear(); + this.tenancyDisabledCache.clear(); + this.cbpRelCache.clear(); + }); + } + // Probe for OrgScopingPlugin presence. When registered, its // `init()` exposes itself as the `org-scoping` service. We capture // the boolean once at start time (plugin DI graph is static after @@ -764,7 +779,8 @@ export class SecurityPlugin implements Plugin { } async destroy(): Promise { - // No cleanup needed + this.metadataWatch?.unsubscribe(); + this.metadataWatch = null; } /**