Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
});
});
18 changes: 17 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, { fk: string; master: string } | null>();
private dbLoader?: (names: string[]) => Promise<PermissionSet[]>;
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -764,7 +779,8 @@ export class SecurityPlugin implements Plugin {
}

async destroy(): Promise<void> {
// No cleanup needed
this.metadataWatch?.unsubscribe();
this.metadataWatch = null;
}

/**
Expand Down