@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
205 lines (204 loc) • 7.17 kB
JavaScript
import { useEnv } from '@directus/env';
import { LRUMapWithDelete } from 'mnemonist';
import { useBus } from '../../bus/index.js';
import { IRRELEVANT_COLLECTIONS } from './constants.js';
const env = useEnv();
/**
* Caches permission check results for collaborative editing clients.
* Supports granular invalidation based on collection, item, and relational dependencies.
*/
export class PermissionCache {
cache;
tags = new Map();
keyTags = new Map();
timers = new Map();
bus = useBus();
invalidationCount = 0;
constructor(maxSize) {
this.cache = new LRUMapWithDelete(maxSize);
this.bus.subscribe('websocket.event', (event) => {
this.handleInvalidation(event);
});
}
/**
* Used for race condition protection during async permission fetches.
*/
getInvalidationCount() {
return this.invalidationCount;
}
/**
* Clears entire cache for system collections, or performs granular invalidation for user data.
*/
handleInvalidation(event) {
const { collection, keys, key } = event;
const items = keys || (key ? [key] : []);
const affectedKeys = new Set();
// System Invalidation (Roles, Permissions, Policies, Schema)
if ([
'directus_roles',
'directus_permissions',
'directus_policies',
'directus_access',
'directus_fields',
'directus_relations',
'directus_collections',
].includes(collection)) {
this.clear();
return;
}
// Skip known high-traffic collections
if (IRRELEVANT_COLLECTIONS.includes(collection)) {
return;
}
this.invalidationCount++;
// Prevent overflow issues in long-running processes
if (this.invalidationCount >= Number.MAX_SAFE_INTEGER) {
this.invalidationCount = 1;
}
// Skip if no keys are watching
if (!this.tags.has(`collection:${collection}`) &&
!this.tags.has(`dependency:${collection}`) &&
!this.tags.has(`collection-dependency:${collection}`)) {
return;
}
// Collection-level Invalidation
if (items.length === 0 && this.tags.has(`collection:${collection}`)) {
for (const k of this.tags.get(`collection:${collection}`))
affectedKeys.add(k);
}
// Item-level Invalidation
for (const id of items) {
const tag = `item:${collection}:${id}`;
if (this.tags.has(tag)) {
for (const k of this.tags.get(tag))
affectedKeys.add(k);
}
}
// Dependency Invalidation (Items + Relational)
const depTags = [`dependency:${collection}`];
if (items.length > 0) {
for (const id of items) {
depTags.push(`dependency:${collection}:${id}`);
}
}
else {
depTags.push(`collection-dependency:${collection}`);
}
for (const tag of depTags) {
if (this.tags.has(tag)) {
for (const k of this.tags.get(tag))
affectedKeys.add(k);
}
}
for (const k of affectedKeys) {
this.invalidateKey(k);
}
}
/**
* Get cached allowed fields for a given accountability and collection/item.
* LRUMap automatically updates access order on get().
*/
get(accountability, collection, item, action) {
const key = this.getCacheKey(accountability, collection, item, action);
return this.cache.get(key);
}
/**
* Store allowed fields in the cache with optional TTL and dependencies.
*/
set(accountability, collection, item, action, fields, dependencies = [], ttlMs) {
const key = this.getCacheKey(accountability, collection, item, action);
// Clear existing timer if any
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
this.timers.delete(key);
}
// Clean up metadata for LRU eviction if at capacity
// LRUMapWithDelete auto-evicts, but we need to clean up our tag mappings
if (!this.cache.has(key) && this.cache.size >= this.cache.capacity) {
const lruKey = this.cache.keys().next().value;
if (lruKey) {
this.cleanupKeyMetadata(lruKey);
}
}
this.cache.set(key, fields);
if (ttlMs) {
const timer = setTimeout(() => {
this.invalidateKey(key);
}, ttlMs);
this.timers.set(key, timer);
}
// Always tag the specific item
this.addTag(key, `item:${collection}:${item}`);
// Always tag the collection to cover batch updates
this.addTag(key, `collection:${collection}`);
// Add custom dependencies such as relational collections
for (const dep of dependencies) {
this.addTag(key, `dependency:${dep}`);
if (dep.includes(':')) {
const [dependencyCollection] = dep.split(':');
this.addTag(key, `collection-dependency:${dependencyCollection}`);
}
}
}
/**
* Called before LRU eviction or explicit invalidation to prevent orphaned metadata.
*/
cleanupKeyMetadata(key) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key));
this.timers.delete(key);
}
const tags = this.keyTags.get(key);
if (tags) {
for (const tag of tags) {
const keys = this.tags.get(tag);
if (keys) {
keys.delete(key);
if (keys.size === 0)
this.tags.delete(tag);
}
}
this.keyTags.delete(key);
}
}
/**
* Maintains bidirectional mappings: tag → keys and key → tags.
*/
addTag(key, tag) {
if (!this.tags.has(tag)) {
this.tags.set(tag, new Set());
}
this.tags.get(tag).add(key);
if (!this.keyTags.has(key)) {
this.keyTags.set(key, new Set());
}
this.keyTags.get(key).add(tag);
}
/**
* Cleans up metadata first, then removes from cache.
*/
invalidateKey(key) {
this.cleanupKeyMetadata(key);
this.cache.delete(key);
}
/**
* Cache key format: user:collection:item:action
*/
getCacheKey(accountability, collection, item, action) {
return `${accountability.user || 'public'}:${collection}:${item || 'singleton'}:${action}`;
}
/**
* Clear the entire cache.
*/
clear() {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
this.cache.clear();
this.tags.clear();
this.keyTags.clear();
this.invalidationCount++;
}
}
export const permissionCache = new PermissionCache(Number(env['WEBSOCKETS_COLLAB_PERMISSIONS_CACHE_CAPACITY'] ?? 2000));