Skip to content

Commit 8125c3d

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(security): invalidate metadata-derived caches on runtime metadata change (#2213)
SecurityPlugin's fieldNames/tenancy/controlled-by-parent caches were populated lazily from metadata but never invalidated — so runtime metadata edits (Studio / AI authoring) served stale schema/RLS until restart, even single-node. Subscribe to metadata.watch('*') in start() and clear them on change. Generic open-core mechanism (guarded on the optional watch API). With a cluster pub/sub driver the metadata.changed event propagates cross-node, so this is also the groundwork for cross-node cache invalidation in EE multi-node (cloud ADR-0018). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c3e1b7d commit 8125c3d

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,3 +1365,41 @@ describe('RLSCompiler — SQL→CEL deprecation bridge (ADR-0058 D1)', () => {
13651365
expect(warned.length).toBe(0);
13661366
});
13671367
});
1368+
1369+
describe('SecurityPlugin – metadata-change cache invalidation', () => {
1370+
it('clears metadata-derived caches when metadata changes at runtime', async () => {
1371+
const plugin = new SecurityPlugin();
1372+
let watchCb: (() => void) | undefined;
1373+
const metadata = {
1374+
watch: (_type: string, cb: () => void) => {
1375+
watchCb = cb;
1376+
return { unsubscribe: vi.fn() };
1377+
},
1378+
};
1379+
const ctx: any = {
1380+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
1381+
registerService: vi.fn(),
1382+
hook: vi.fn(),
1383+
getService: vi.fn().mockImplementation((name: string) => {
1384+
if (name === 'manifest') return { register: vi.fn() };
1385+
if (name === 'metadata') return metadata;
1386+
if (name === 'objectql') return { registerMiddleware: vi.fn() };
1387+
return undefined;
1388+
}),
1389+
};
1390+
await plugin.init(ctx);
1391+
await plugin.start(ctx);
1392+
1393+
expect(typeof watchCb).toBe('function');
1394+
1395+
(plugin as any).fieldNamesCache.set('crm_account', new Set(['name']));
1396+
(plugin as any).tenancyDisabledCache.set('crm_account', true);
1397+
(plugin as any).cbpRelCache.set('crm_account', null);
1398+
1399+
watchCb!();
1400+
1401+
expect((plugin as any).fieldNamesCache.size).toBe(0);
1402+
expect((plugin as any).tenancyDisabledCache.size).toBe(0);
1403+
expect((plugin as any).cbpRelCache.size).toBe(0);
1404+
});
1405+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ export class SecurityPlugin implements Plugin {
108108
*/
109109
private metadata: any = null;
110110
private ql: any = null;
111+
/** Unsubscribe handle for metadata-change cache invalidation (runtime metadata edits). */
112+
private metadataWatch: { unsubscribe: () => void } | null = null;
111113
/** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */
112114
private cbpRelCache = new Map<string, { fk: string; master: string } | null>();
113115
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
@@ -212,6 +214,19 @@ export class SecurityPlugin implements Plugin {
212214
this.logger = ctx.logger;
213215
this.rlsCompiler.setLogger?.(ctx.logger);
214216

217+
// Invalidate metadata-derived caches when object/field metadata changes
218+
// at runtime (Studio / AI authoring). Without this they go stale until
219+
// restart — even single-node. With a cluster pub/sub driver the
220+
// metadata.changed event propagates cross-node, so peers invalidate too.
221+
const md: any = this.metadata;
222+
if (typeof md?.watch === 'function') {
223+
this.metadataWatch = md.watch('*', () => {
224+
this.fieldNamesCache.clear();
225+
this.tenancyDisabledCache.clear();
226+
this.cbpRelCache.clear();
227+
});
228+
}
229+
215230
// Probe for OrgScopingPlugin presence. When registered, its
216231
// `init()` exposes itself as the `org-scoping` service. We capture
217232
// the boolean once at start time (plugin DI graph is static after
@@ -764,7 +779,8 @@ export class SecurityPlugin implements Plugin {
764779
}
765780

766781
async destroy(): Promise<void> {
767-
// No cleanup needed
782+
this.metadataWatch?.unsubscribe();
783+
this.metadataWatch = null;
768784
}
769785

770786
/**

0 commit comments

Comments
 (0)