diff --git a/packages/flowerbase/README.md b/packages/flowerbase/README.md index ead4cbd..2769685 100644 --- a/packages/flowerbase/README.md +++ b/packages/flowerbase/README.md @@ -63,6 +63,12 @@ Install the `@flowerforce/flowerbase` library, which provides the tools required npm install @flowerforce/flowerbase ``` +If you want to use Redis as cache provider, install `redis` in your application too: + +```bash +npm install redis +``` + Add Typescript ```bash @@ -123,6 +129,58 @@ initialize({ This initializes the Flowerbase integration, connecting your application to MongoDB Atlas. +### 🧠 Optional Cache Configuration + +Flowerbase can cache server-side Mongo reads and invalidate them automatically after writes. + +Supported providers: + +- `memory`: in-process cache, useful for local development or single-instance deployments +- `redis`: shared cache for multi-instance deployments + +The cache is configured during `initialize()` and is applied to idempotent read operations exposed by the `mongodb-atlas` service. Invalidation is scoped to the current authorization context, so a successful write clears cached reads only for the same `database + collection + runAsSystem + filters` context. + +#### Memory cache + +```ts +import { initialize } from '@flowerforce/flowerbase'; + +initialize({ + projectId: 'my-project-id', + mongodbUrl: process.env.MONGODB_URL, + jwtSecret: process.env.JWT_SECRET, + cache: { + provider: 'memory', + defaultTtlSeconds: 60, + maxEntries: 1000 + } +}) +``` + +#### Redis cache + +```ts +import { initialize } from '@flowerforce/flowerbase'; + +initialize({ + projectId: 'my-project-id', + mongodbUrl: process.env.MONGODB_URL, + jwtSecret: process.env.JWT_SECRET, + cache: { + provider: 'redis', + url: process.env.REDIS_URL!, + defaultTtlSeconds: 300 + } +}) +``` + +Cache notes: + +- no cache provider is enabled by default +- Redis does not fall back to memory automatically +- reads executed with a Mongo session are not cached +- the memory provider supports `maxEntries` and evicts the least recently used entry when the limit is reached + ## 🛠️ 5. Server Configuration – Authentication, Rules, and Functions After setting up the base Flowerbase integration, you can now configure advanced features to control how your backend behaves. diff --git a/packages/flowerbase/package.json b/packages/flowerbase/package.json index 057acd1..69a0edc 100644 --- a/packages/flowerbase/package.json +++ b/packages/flowerbase/package.json @@ -40,6 +40,7 @@ "lodash": "^4.17.21", "node-cron": "^3.0.3", "nodemon": "^3.1.7", + "redis": "^4.7.0", "undici": "^7.1.0" }, "devDependencies": { diff --git a/packages/flowerbase/src/cache/__tests__/index.test.ts b/packages/flowerbase/src/cache/__tests__/index.test.ts new file mode 100644 index 0000000..eab2d5c --- /dev/null +++ b/packages/flowerbase/src/cache/__tests__/index.test.ts @@ -0,0 +1,39 @@ +import { + buildCacheKey, + buildCollectionCacheTag, + createCacheProvider +} from '..' + +describe('cache provider', () => { + it('stores and invalidates memory entries by tag', async () => { + const cache = await createCacheProvider({ provider: 'memory' }) + const tag = buildCollectionCacheTag('main', 'todos') + const key = buildCacheKey({ operation: 'findOne', id: 'todo-1' }) + + await cache.set(key, { _id: 'todo-1', done: false }, { tags: [tag] }) + + expect(await cache.get(key)).toEqual({ _id: 'todo-1', done: false }) + + await cache.invalidateTags([tag]) + + expect(await cache.get(key)).toBeUndefined() + }) + + it('evicts the least recently used entry when maxEntries is reached', async () => { + const cache = await createCacheProvider({ provider: 'memory', maxEntries: 2 }) + const firstKey = buildCacheKey({ id: 'first' }) + const secondKey = buildCacheKey({ id: 'second' }) + const thirdKey = buildCacheKey({ id: 'third' }) + + await cache.set(firstKey, { id: 'first' }) + await cache.set(secondKey, { id: 'second' }) + + expect(await cache.get(firstKey)).toEqual({ id: 'first' }) + + await cache.set(thirdKey, { id: 'third' }) + + expect(await cache.get(firstKey)).toEqual({ id: 'first' }) + expect(await cache.get(secondKey)).toBeUndefined() + expect(await cache.get(thirdKey)).toEqual({ id: 'third' }) + }) +}) diff --git a/packages/flowerbase/src/cache/index.ts b/packages/flowerbase/src/cache/index.ts new file mode 100644 index 0000000..4fd2104 --- /dev/null +++ b/packages/flowerbase/src/cache/index.ts @@ -0,0 +1,333 @@ +import { createHash } from 'node:crypto' +import { EJSON } from 'bson' + +type CacheEntryOptions = { + tags?: string[] + ttlSeconds?: number +} + +export type CacheProviderKind = 'none' | 'memory' | 'redis' + +export type CacheProvider = { + kind: CacheProviderKind + get: (key: string) => Promise + set: (key: string, value: T, options?: CacheEntryOptions) => Promise + invalidateTags: (tags: string[]) => Promise + close: () => Promise +} + +export type MemoryCacheConfig = { + provider: 'memory' + defaultTtlSeconds?: number + maxEntries?: number + keyPrefix?: string +} + +export type RedisCacheConfig = { + provider: 'redis' + url: string + defaultTtlSeconds?: number + keyPrefix?: string +} + +export type CacheConfig = MemoryCacheConfig | RedisCacheConfig + +type MemoryEntry = { + value: string + expiresAt?: number + tags: string[] +} + +type RedisClientLike = { + connect: () => Promise + quit: () => Promise + get: (key: string) => Promise + set: (key: string, value: string, options?: Record) => Promise + del: (...keys: string[]) => Promise + sAdd: (key: string, members: string | string[]) => Promise + sMembers: (key: string) => Promise +} + +const DEFAULT_KEY_PREFIX = 'flowerbase:cache' + +const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise> + +const toStableValue = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map((item) => toStableValue(item)) + } + + if (!value || typeof value !== 'object') return value + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + return value + } + + const sortedEntries = Object.entries(value).sort(([left], [right]) => + left.localeCompare(right) + ) + + return sortedEntries.reduce>((acc, [key, current]) => { + acc[key] = toStableValue(current) + return acc + }, {}) +} + +const serializeValue = (value: unknown) => + JSON.stringify(EJSON.serialize(value, { relaxed: false })) + +const deserializeValue = (value: string): T => + EJSON.deserialize(JSON.parse(value)) as T + +const normalizeTags = (tags?: string[]) => + [...new Set((tags ?? []).filter((tag): tag is string => typeof tag === 'string'))] + +const createPrefixedKey = (prefix: string, kind: 'entry' | 'tag', value: string) => + `${prefix}:${kind}:${value}` + +const getExpiresAt = (ttlSeconds?: number) => + typeof ttlSeconds === 'number' && ttlSeconds > 0 + ? Date.now() + ttlSeconds * 1000 + : undefined + +const addKeyToTagsIndex = ( + tagsIndex: Map>, + key: string, + tags: string[] +) => { + tags.forEach((tag) => { + const existingKeys = tagsIndex.get(tag) ?? new Set() + existingKeys.add(key) + tagsIndex.set(tag, existingKeys) + }) +} + +const removeKeyFromTagsIndex = ( + tagsIndex: Map>, + key: string, + tags: string[] +) => { + tags.forEach((tag) => { + const existingKeys = tagsIndex.get(tag) + if (!existingKeys) return + existingKeys.delete(key) + if (!existingKeys.size) { + tagsIndex.delete(tag) + } + }) +} + +const createMemoryCacheProvider = ( + config: MemoryCacheConfig +): CacheProvider => { + const entries = new Map() + const tagsIndex = new Map>() + const defaultTtlSeconds = config.defaultTtlSeconds + const maxEntries = + typeof config.maxEntries === 'number' && config.maxEntries > 0 + ? Math.floor(config.maxEntries) + : undefined + + const clearEntry = (key: string) => { + const currentEntry = entries.get(key) + if (!currentEntry) return + removeKeyFromTagsIndex(tagsIndex, key, currentEntry.tags) + entries.delete(key) + } + + const touchEntry = (key: string, entry: MemoryEntry) => { + entries.delete(key) + entries.set(key, entry) + } + + const evictLeastRecentlyUsedEntry = () => { + if (typeof maxEntries !== 'number' || entries.size < maxEntries) return + const oldestKey = entries.keys().next().value as string | undefined + if (!oldestKey) return + clearEntry(oldestKey) + } + + return { + kind: 'memory', + get: async (key: string) => { + const entry = entries.get(key) + if (!entry) return undefined + + if (entry.expiresAt && entry.expiresAt <= Date.now()) { + clearEntry(key) + return undefined + } + + touchEntry(key, entry) + return deserializeValue(entry.value) + }, + set: async (key: string, value: T, options?: CacheEntryOptions) => { + clearEntry(key) + evictLeastRecentlyUsedEntry() + + const tags = normalizeTags(options?.tags) + const entry: MemoryEntry = { + value: serializeValue(value), + expiresAt: getExpiresAt(options?.ttlSeconds ?? defaultTtlSeconds), + tags + } + + entries.set(key, entry) + addKeyToTagsIndex(tagsIndex, key, tags) + }, + invalidateTags: async (tags: string[]) => { + tags.forEach((tag) => { + const keys = [...(tagsIndex.get(tag) ?? new Set())] + keys.forEach((key) => clearEntry(key)) + }) + }, + close: async () => { + entries.clear() + tagsIndex.clear() + } + } +} + +const createRedisCacheProvider = async ( + config: RedisCacheConfig +): Promise => { + const redisModule = await dynamicImport('redis') + const createClient = redisModule.createClient as + | ((options: Record) => RedisClientLike) + | undefined + + if (typeof createClient !== 'function') { + throw new Error('Redis client module does not expose createClient') + } + + const keyPrefix = config.keyPrefix ?? DEFAULT_KEY_PREFIX + const defaultTtlSeconds = config.defaultTtlSeconds + const client = createClient({ url: config.url }) + await client.connect() + + return { + kind: 'redis', + get: async (key: string) => { + const value = await client.get(createPrefixedKey(keyPrefix, 'entry', key)) + if (value === null) return undefined + return deserializeValue(value) + }, + set: async (key: string, value: T, options?: CacheEntryOptions) => { + const entryKey = createPrefixedKey(keyPrefix, 'entry', key) + const ttlSeconds = options?.ttlSeconds ?? defaultTtlSeconds + const payload = serializeValue(value) + + if (typeof ttlSeconds === 'number' && ttlSeconds > 0) { + await client.set(entryKey, payload, { EX: ttlSeconds }) + } else { + await client.set(entryKey, payload) + } + + const tags = normalizeTags(options?.tags) + await Promise.all( + tags.map((tag) => + client.sAdd(createPrefixedKey(keyPrefix, 'tag', tag), entryKey) + ) + ) + }, + invalidateTags: async (tags: string[]) => { + for (const tag of tags) { + const tagKey = createPrefixedKey(keyPrefix, 'tag', tag) + const entryKeys = await client.sMembers(tagKey) + if (entryKeys.length) { + await client.del(...entryKeys) + } + await client.del(tagKey) + } + }, + close: async () => { + await client.quit() + } + } +} + +export const noopCacheProvider: CacheProvider = { + kind: 'none', + get: async () => undefined, + set: async () => undefined, + invalidateTags: async () => undefined, + close: async () => undefined +} + +export const createCacheProvider = async ( + config?: CacheConfig +): Promise => { + if (!config) return noopCacheProvider + if (config.provider === 'memory') { + return createMemoryCacheProvider(config) + } + return createRedisCacheProvider(config) +} + +export const buildCacheKey = (payload: unknown) => { + const stablePayload = serializeValue(toStableValue(payload)) + return createHash('sha256').update(stablePayload).digest('hex') +} + +export const buildCollectionCacheTag = (database: string, collection: string) => + `${database}.${collection}` + +export const buildAuthorizationCacheTag = (payload: { + database: string + collection: string + filters?: unknown +}) => + `auth:${buildCacheKey({ + database: payload.database, + collection: payload.collection, + filters: payload.filters + })}` + +/** + * Extracts a stable identity fingerprint from a user object for cache-key purposes. + * Uses `id` (+ `custom_data` when present) to strip volatile JWT noise (e.g., `iat`, + * `exp`, refresh tokens) while preserving per-principal isolation. Falls back to the + * full user when no recognizable id is available, which is the safe option. + */ +export const buildUserCacheIdentity = (user: unknown): unknown => { + if (!user || typeof user !== 'object') return user ?? null + const candidate = user as { + id?: unknown + _id?: unknown + sub?: unknown + custom_data?: unknown + } + const id = + typeof candidate.id === 'string' || typeof candidate.id === 'number' + ? candidate.id + : typeof candidate._id === 'string' || typeof candidate._id === 'number' + ? candidate._id + : typeof candidate.sub === 'string' || typeof candidate.sub === 'number' + ? candidate.sub + : undefined + if (typeof id === 'undefined') return user + return { + id, + ...(typeof candidate.custom_data !== 'undefined' + ? { custom_data: candidate.custom_data } + : {}) + } +} + +/** + * Produces a stable fingerprint of the authorization context (roles + filters) + * so that any change to permission logic (e.g., hot-reloaded rules, role edits) + * automatically produces a different cache key, preventing stale visibility + * leaks for aggregate results like `count` that cannot be re-validated on hit. + */ +export const buildRolesSignature = (payload: { + roles?: unknown + filters?: unknown +}) => + buildCacheKey({ + roles: payload.roles ?? [], + filters: payload.filters ?? [] + }) diff --git a/packages/flowerbase/src/index.ts b/packages/flowerbase/src/index.ts index cb5eecb..d94febc 100644 --- a/packages/flowerbase/src/index.ts +++ b/packages/flowerbase/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import Fastify from 'fastify' +import { CacheConfig, createCacheProvider } from './cache' import { DEFAULT_CONFIG } from './constants' import { generateEndpoints } from './features/endpoints' import { loadEndpoints } from './features/endpoints/utils' @@ -33,6 +34,7 @@ export type InitializeConfig = { corsConfig?: CorsConfig basePath?: string mongodbEncryptionConfig?: MongoDbEncryptionConfig + cache?: CacheConfig } /** @@ -51,7 +53,8 @@ export async function initialize({ mongodbUrl = DEFAULT_CONFIG.MONGODB_URL, corsConfig = DEFAULT_CONFIG.CORS_OPTIONS, basePath, - mongodbEncryptionConfig + mongodbEncryptionConfig, + cache: cacheConfig }: InitializeConfig) { if (!jwtSecret || jwtSecret.trim().length === 0) { throw new Error('JWT secret missing: set JWT_SECRET or pass jwtSecret to initialize()') @@ -61,6 +64,7 @@ export async function initialize({ const fastify = Fastify({ logger: !!DEFAULT_CONFIG.ENABLE_LOGGER }) + const cache = await createCacheProvider(cacheConfig) const isTest = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined const logInfo = (...args: unknown[]) => { @@ -90,6 +94,7 @@ export async function initialize({ endpoints: endpointsList, rules: rulesList, projectId, + cache, app: fastify, services } @@ -153,6 +158,10 @@ export async function initialize({ }) } + fastify.addHook('onClose', async () => { + await cache.close() + }) + await registerPlugins({ register: fastify.register, mongodbUrl, @@ -178,3 +187,5 @@ export async function initialize({ fastify.log.info(`[${projectId}] Server listening on port ${port}`) } + +export type { CacheConfig } diff --git a/packages/flowerbase/src/services/mongodb-atlas/__tests__/cache.test.ts b/packages/flowerbase/src/services/mongodb-atlas/__tests__/cache.test.ts new file mode 100644 index 0000000..b9fe657 --- /dev/null +++ b/packages/flowerbase/src/services/mongodb-atlas/__tests__/cache.test.ts @@ -0,0 +1,245 @@ +import MongoDbAtlas from '..' +import { ObjectId } from 'mongodb' +import { createCacheProvider } from '../../../cache' +import { Rules } from '../../../features/rules/interface' +import { StateManager } from '../../../state' + +const createAppWithCollection = (collection: Record) => ({ + mongo: { + client: { + db: jest.fn().mockReturnValue({ + collection: jest.fn().mockReturnValue(collection) + }) + } + } +}) + +const createRules = (overrides?: Partial): Rules => ({ + todos: { + database: 'db', + collection: 'todos', + filters: overrides?.filters ?? [], + roles: overrides?.roles ?? [ + { + name: 'reader', + apply_when: {}, + insert: true, + delete: true, + search: true, + read: true, + write: true + } + ], + ...overrides + } +}) + +describe('mongodb-atlas cache', () => { + beforeEach(async () => { + StateManager.setData('cache', await createCacheProvider({ provider: 'memory' })) + }) + + afterEach(async () => { + await StateManager.select('cache').close() + StateManager.setData('cache', await createCacheProvider()) + }) + + it('caches count results and invalidates them after updateOne', async () => { + const countDocuments = jest + .fn() + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(9) + const updateOne = jest.fn().mockResolvedValue({ + acknowledged: true, + matchedCount: 1, + modifiedCount: 1 + }) + const collection = { + collectionName: 'todos', + countDocuments, + updateOne + } + + const operators = MongoDbAtlas(createAppWithCollection(collection) as any, { + run_as_system: true + }).db('db').collection('todos') + + expect(await operators.count({ workspace: 'a' })).toBe(5) + expect(await operators.count({ workspace: 'a' })).toBe(5) + expect(countDocuments).toHaveBeenCalledTimes(1) + + await operators.updateOne({ workspace: 'a' }, { $set: { label: 'updated' } }) + + expect(await operators.count({ workspace: 'a' })).toBe(9) + expect(countDocuments).toHaveBeenCalledTimes(2) + }) + + it('caches findOne results for identical reads', async () => { + const id = new ObjectId() + const findOne = jest + .fn() + .mockResolvedValueOnce({ _id: id, label: 'first' }) + const collection = { + collectionName: 'todos', + findOne + } + + const operators = MongoDbAtlas(createAppWithCollection(collection) as any, { + run_as_system: true + }).db('db').collection('todos') + + expect(await operators.findOne({ _id: id })).toEqual({ + _id: id, + label: 'first' + }) + expect(await operators.findOne({ _id: id })).toEqual({ + _id: id, + label: 'first' + }) + expect(findOne).toHaveBeenCalledTimes(1) + }) + + it('does not serve cached findOne values across different role visibility', async () => { + const id = new ObjectId() + const findOne = jest.fn().mockResolvedValue({ _id: id, ownerId: 'user-1', secret: 'top' }) + const collection = { + collectionName: 'todos', + findOne + } + + const allowedOperators = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules(), + user: { id: 'user-1' } + }).db('db').collection('todos') + + expect(await allowedOperators.findOne({ _id: id })).toEqual({ + _id: id, + ownerId: 'user-1', + secret: 'top' + }) + expect(findOne).toHaveBeenCalledTimes(1) + + const hiddenOperators = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules({ + roles: [] + }), + user: { id: 'user-1' } + }).db('db').collection('todos') + + expect(await hiddenOperators.findOne({ _id: id })).toEqual({}) + expect(findOne).toHaveBeenCalledTimes(2) + }) + + it('invalidates the shared authorization context', async () => { + const countDocuments = jest + .fn() + .mockResolvedValueOnce(5) + .mockResolvedValueOnce(7) + .mockResolvedValueOnce(9) + .mockResolvedValueOnce(11) + const updateOne = jest.fn().mockResolvedValue({ + acknowledged: true, + matchedCount: 1, + modifiedCount: 1 + }) + const findOne = jest.fn().mockResolvedValue({ + _id: new ObjectId(), + ownerId: 'user-1', + label: 'before' + }) + const collection = { + collectionName: 'todos', + countDocuments, + updateOne, + findOne + } + + const firstUserOperators = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules(), + user: { id: 'user-1' } + }).db('db').collection('todos') + + const secondUserOperators = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules(), + user: { id: 'user-2' } + }).db('db').collection('todos') + + expect(await firstUserOperators.count({ workspace: 'a' })).toBe(5) + expect(await secondUserOperators.count({ workspace: 'a' })).toBe(7) + expect(countDocuments).toHaveBeenCalledTimes(2) + + await firstUserOperators.updateOne( + { ownerId: 'user-1' }, + { $set: { label: 'updated' } } + ) + + expect(await firstUserOperators.count({ workspace: 'a' })).toBe(9) + expect(await secondUserOperators.count({ workspace: 'a' })).toBe(11) + expect(countDocuments).toHaveBeenCalledTimes(4) + }) + + it('does not serve cached count across different role configurations', async () => { + const countDocuments = jest + .fn() + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(3) + const collection = { + collectionName: 'todos', + countDocuments + } + + const permissiveOperators = MongoDbAtlas( + createAppWithCollection(collection) as any, + { + rules: createRules(), + user: { id: 'user-1' } + } + ).db('db').collection('todos') + + const restrictiveOperators = MongoDbAtlas( + createAppWithCollection(collection) as any, + { + rules: createRules({ + roles: [ + { + name: 'readOnly', + apply_when: { 'user.custom_data.type': 'internal' }, + insert: false, + delete: false, + search: true, + read: true, + write: false + } + ] + }), + user: { id: 'user-1' } + } + ).db('db').collection('todos') + + expect(await permissiveOperators.count({ workspace: 'a' })).toBe(10) + expect(await restrictiveOperators.count({ workspace: 'a' })).toBe(3) + expect(countDocuments).toHaveBeenCalledTimes(2) + }) + + it('reuses cache entries across requests when only volatile user fields differ', async () => { + const countDocuments = jest.fn().mockResolvedValue(42) + const collection = { + collectionName: 'todos', + countDocuments + } + + const firstRequest = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules(), + user: { id: 'user-1', custom_data: { tier: 'gold' }, iat: 1_700_000_000 } + }).db('db').collection('todos') + + const secondRequest = MongoDbAtlas(createAppWithCollection(collection) as any, { + rules: createRules(), + user: { id: 'user-1', custom_data: { tier: 'gold' }, iat: 1_700_000_999, exp: 9_999_999_999 } + }).db('db').collection('todos') + + expect(await firstRequest.count({ workspace: 'a' })).toBe(42) + expect(await secondRequest.count({ workspace: 'a' })).toBe(42) + expect(countDocuments).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/flowerbase/src/services/mongodb-atlas/index.ts b/packages/flowerbase/src/services/mongodb-atlas/index.ts index 8dbac5a..7d13e4d 100644 --- a/packages/flowerbase/src/services/mongodb-atlas/index.ts +++ b/packages/flowerbase/src/services/mongodb-atlas/index.ts @@ -16,8 +16,16 @@ import { UpdateFilter, WithId } from 'mongodb' +import { + buildAuthorizationCacheTag, + buildCacheKey, + buildRolesSignature, + buildUserCacheIdentity, + CacheProvider +} from '../../cache' import { Rules } from '../../features/rules/interface' import { buildRulesMeta } from '../../monitoring/utils' +import { StateManager } from '../../state' import { checkValidation } from '../../utils/roles/machines' import { getWinningRole } from '../../utils/roles/machines/utils' import { emitServiceEvent } from '../monitoring' @@ -491,15 +499,146 @@ const areUpdatedFieldsAllowed = ( return updatedPaths.every((path) => isEqual(get(filtered, path), get(updated, path))) } +const hasSessionOption = (options?: Document | FindOptions | FindOneOptions) => + !!options && + typeof options === 'object' && + !Array.isArray(options) && + 'session' in options && + typeof options.session !== 'undefined' + +const canUseCache = ( + cache: CacheProvider, + options?: Document | FindOptions | FindOneOptions +) => cache.kind !== 'none' && !hasSessionOption(options) + +const buildReadOperationCacheKey = ({ + dbName, + collName, + operation, + query, + options, + projection, + authPrincipal, + rolesSignature, + runAsSystem, + extra +}: { + dbName: string + collName: string + operation: string + query?: unknown + options?: unknown + projection?: unknown + authPrincipal: unknown + rolesSignature: string + runAsSystem?: boolean + extra?: unknown +}) => + buildCacheKey({ + dbName, + collName, + operation, + query, + options, + projection, + authPrincipal, + rolesSignature, + runAsSystem: !!runAsSystem, + extra + }) + +const createCachedCursor = ( + cursor: { + toArray: () => Promise + sort?: (...args: any[]) => unknown + skip?: (...args: any[]) => unknown + limit?: (...args: any[]) => unknown + }, + { + cache, + cacheKey, + tag, + transform, + onCacheHit + }: { + cache: CacheProvider + cacheKey: string + tag: string + transform?: (items: T[]) => Promise | T[] + onCacheHit?: (items: T[]) => Promise | T[] + } +) => { + const originalToArray = cursor.toArray.bind(cursor) + const originalSort = cursor.sort?.bind(cursor) + const originalSkip = cursor.skip?.bind(cursor) + const originalLimit = cursor.limit?.bind(cursor) + const cursorState: { + sort?: unknown + skip?: number + limit?: number + } = {} + + if (originalSort) { + cursor.sort = ((...args: any[]) => { + cursorState.sort = args + originalSort(...args) + return cursor + }) as typeof cursor.sort + } + + if (originalSkip) { + cursor.skip = ((...args: any[]) => { + cursorState.skip = args[0] + originalSkip(...args) + return cursor + }) as typeof cursor.skip + } + + if (originalLimit) { + cursor.limit = ((...args: any[]) => { + cursorState.limit = args[0] + originalLimit(...args) + return cursor + }) as typeof cursor.limit + } + + cursor.toArray = async () => { + const effectiveKey = buildCacheKey({ + cacheKey, + cursorState + }) + + const cached = await cache.get(effectiveKey) + if (typeof cached !== 'undefined') { + return onCacheHit ? await onCacheHit(cached) : cached + } + + const response = await originalToArray() + const transformedResponse = transform ? await transform(response) : response + await cache.set(effectiveKey, transformedResponse, { tags: [tag] }) + return transformedResponse + } + + return cursor +} + const getOperators: GetOperatorsFunction = ( mongo, { rules, dbName, collName, user, run_as_system, monitoringOrigin } ) => { const collection = mongo.client.db(dbName).collection(collName) + const cache = StateManager.select('cache') const normalizedRules: Rules = rules ?? ({} as Rules) const collectionRules = normalizedRules[collName] const filters = collectionRules?.filters ?? [] const roles = collectionRules?.roles ?? [] + const authorizationCacheTag = buildAuthorizationCacheTag({ + database: dbName, + collection: collName, + filters + }) + const authPrincipal = run_as_system ? 'system' : buildUserCacheIdentity(user) + const rolesSignature = buildRolesSignature({ roles, filters }) const fallbackAccess = (doc: Document | null | undefined = undefined) => ({ status: false, document: doc @@ -533,6 +672,35 @@ const getOperators: GetOperatorsFunction = ( origin: monitoringOrigin }) } + const invalidateCollectionCache = async () => { + await cache.invalidateTags([authorizationCacheTag]) + } + const validateReadAccess = async (document: Document | null) => { + if (document === null) return null + + const winningRole = getWinningRole(document, user, roles) + + logDebug('findOne winningRole', { + collection: collName, + winningRoleName: winningRole?.name ?? null, + userId: getUserId(user) + }) + + const { status, document: validatedDocument } = winningRole + ? await checkValidation( + winningRole, + { + type: 'read', + roles, + cursor: document, + expansions: getValidationExpansions(document) + }, + user + ) + : fallbackAccess(document) + + return status ? validatedDocument : {} + } return { /** @@ -561,17 +729,39 @@ const getOperators: GetOperatorsFunction = ( const resolvedOptions = projection || normalizedOptions ? { - ...(normalizedOptions ?? {}), - ...(projection ? { projection } : {}) - } + ...(normalizedOptions ?? {}), + ...(projection ? { projection } : {}) + } : undefined const resolvedQuery = query ?? {} + const cacheKey = buildReadOperationCacheKey({ + dbName, + collName, + operation: 'findOne', + query: resolvedQuery, + options: resolvedOptions, + projection, + authPrincipal, + rolesSignature, + runAsSystem: run_as_system + }) if (!run_as_system) { checkDenyOperation( normalizedRules, collection.collectionName, CRUD_OPERATIONS.READ ) + } + if (canUseCache(cache, resolvedOptions)) { + const cached = await cache.get>(cacheKey) + if (typeof cached !== 'undefined') { + emitMongoEvent('findOne', { cacheHit: true }) + return !run_as_system + ? await validateReadAccess(cached as Document | null) + : cached + } + } + if (!run_as_system) { // Apply access control filters to the query const formattedQuery = getFormattedQuery(filters, resolvedQuery, user) logDebug('update formattedQuery', { @@ -604,33 +794,18 @@ const getOperators: GetOperatorsFunction = ( return null } - const winningRole = getWinningRole(result, user, roles) - - logDebug('findOne winningRole', { - collection: collName, - winningRoleName: winningRole?.name ?? null, - userId: getUserId(user) - }) - const { status, document } = winningRole - ? await checkValidation( - winningRole, - { - type: 'read', - roles, - cursor: result, - expansions: getValidationExpansions(result) - }, - user - ) - : fallbackAccess(result) - - // Return validated document or empty object if not permitted - const response = status ? document : {} + const response = await validateReadAccess(result) + if (canUseCache(cache, resolvedOptions)) { + await cache.set(cacheKey, response, { tags: [authorizationCacheTag] }) + } emitMongoEvent('findOne') return Promise.resolve(response) } // System mode: no validation applied const response = await collection.findOne(resolvedQuery, resolvedOptions) + if (canUseCache(cache, resolvedOptions)) { + await cache.set(cacheKey, response, { tags: [authorizationCacheTag] }) + } emitMongoEvent('findOne') return response } catch (error) { @@ -678,15 +853,15 @@ const getOperators: GetOperatorsFunction = ( }) const { status } = winningRole ? await checkValidation( - winningRole, - { - type: 'delete', - roles, - cursor: result, - expansions: getValidationExpansions(result) - }, - user - ) + winningRole, + { + type: 'delete', + roles, + cursor: result, + expansions: getValidationExpansions(result) + }, + user + ) : fallbackAccess(result) if (!status) { @@ -694,11 +869,13 @@ const getOperators: GetOperatorsFunction = ( } const res = await collection.deleteOne(buildAndQuery(formattedQuery), options) + await invalidateCollectionCache() emitMongoEvent('deleteOne') return res } // System mode: bypass access control const result = await collection.deleteOne(query, options) + await invalidateCollectionCache() emitMongoEvent('deleteOne') return result } catch (error) { @@ -737,15 +914,15 @@ const getOperators: GetOperatorsFunction = ( const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: 'insert', - roles, - cursor: data, - expansions: getValidationExpansions() - }, - user - ) + winningRole, + { + type: 'insert', + roles, + cursor: data, + expansions: getValidationExpansions() + }, + user + ) : fallbackAccess(data) if (!status || !isEqual(data, document)) { @@ -753,6 +930,7 @@ const getOperators: GetOperatorsFunction = ( } logService('insertOne payload', { collName, data }) const insertResult = await collection.insertOne(data, options) + await invalidateCollectionCache() logService('insertOne result', { collName, insertedId: insertResult.insertedId.toString(), @@ -763,6 +941,7 @@ const getOperators: GetOperatorsFunction = ( } // System mode: insert without validation const insertResult = await collection.insertOne(data, options) + await invalidateCollectionCache() emitMongoEvent('insertOne') return insertResult } catch (error) { @@ -817,6 +996,7 @@ const getOperators: GetOperatorsFunction = ( normalizedData, options ) + await invalidateCollectionCache() emitMongoEvent('updateOne') return upsertResult } @@ -831,15 +1011,15 @@ const getOperators: GetOperatorsFunction = ( // Validate update permissions const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: 'write', - roles, - cursor: docToCheck, - expansions: getValidationExpansions(result) - }, - user - ) + winningRole, + { + type: 'write', + roles, + cursor: docToCheck, + expansions: getValidationExpansions(result) + }, + user + ) : fallbackAccess(docToCheck) // Ensure no unauthorized changes are made const areDocumentsEqual = areUpdatedFieldsAllowed( @@ -856,10 +1036,12 @@ const getOperators: GetOperatorsFunction = ( normalizedData, options ) + await invalidateCollectionCache() emitMongoEvent('updateOne') return res } const result = await collection.updateOne(query, normalizedData, options) + await invalidateCollectionCache() emitMongoEvent('updateOne') return result } catch (error) { @@ -918,12 +1100,12 @@ const getOperators: GetOperatorsFunction = ( } else { const [computedDoc] = Array.isArray(normalizedData) ? await collection - .aggregate([ - { $match: buildAndQuery(safeQuery) }, - { $limit: 1 }, - ...normalizedData - ]) - .toArray() + .aggregate([ + { $match: buildAndQuery(safeQuery) }, + { $limit: 1 }, + ...normalizedData + ]) + .toArray() : [applyDocumentUpdateOperators(currentDoc, normalizedData as Document)] docToCheck = computedDoc } @@ -932,17 +1114,17 @@ const getOperators: GetOperatorsFunction = ( const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: validationType, - roles, - cursor: docToCheck, - expansions: getValidationExpansions( - validationType === 'insert' ? undefined : currentDoc - ) - }, - user - ) + winningRole, + { + type: validationType, + roles, + cursor: docToCheck, + expansions: getValidationExpansions( + validationType === 'insert' ? undefined : currentDoc + ) + }, + user + ) : fallbackAccess(docToCheck) const areDocumentsEqual = areUpdatedFieldsAllowed( @@ -956,11 +1138,12 @@ const getOperators: GetOperatorsFunction = ( const updateResult = normalizedOptions ? await collection.findOneAndUpdate( - buildAndQuery(safeQuery), - normalizedData, - normalizedOptions - ) + buildAndQuery(safeQuery), + normalizedData, + normalizedOptions + ) : await collection.findOneAndUpdate(buildAndQuery(safeQuery), normalizedData) + await invalidateCollectionCache() if (!updateResult) { emitMongoEvent('findOneAndUpdate') return updateResult @@ -969,15 +1152,15 @@ const getOperators: GetOperatorsFunction = ( const readRole = getWinningRole(updateResult, user, roles) const readResult = readRole ? await checkValidation( - readRole, - { - type: 'read', - roles, - cursor: updateResult, - expansions: getValidationExpansions(updateResult) - }, - user - ) + readRole, + { + type: 'read', + roles, + cursor: updateResult, + expansions: getValidationExpansions(updateResult) + }, + user + ) : fallbackAccess(updateResult) const sanitizedDoc = readResult.status @@ -990,6 +1173,7 @@ const getOperators: GetOperatorsFunction = ( const updateResult = normalizedOptions ? await collection.findOneAndUpdate(query, data, normalizedOptions) : await collection.findOneAndUpdate(query, data) + await invalidateCollectionCache() emitMongoEvent('findOneAndUpdate') return updateResult } catch (error) { @@ -1026,10 +1210,21 @@ const getOperators: GetOperatorsFunction = ( const resolvedOptions = projection || normalizedOptions ? { - ...(normalizedOptions ?? {}), - ...(projection ? { projection } : {}) - } + ...(normalizedOptions ?? {}), + ...(projection ? { projection } : {}) + } : undefined + const baseCacheKey = buildReadOperationCacheKey({ + dbName, + collName, + operation: 'find', + query, + options: resolvedOptions, + projection, + authPrincipal, + rolesSignature, + runAsSystem: run_as_system + }) if (!run_as_system) { checkDenyOperation( normalizedRules, @@ -1041,28 +1236,53 @@ const getOperators: GetOperatorsFunction = ( const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {} // aggiunto filter per evitare questo errore: $and argument's entries must be objects const cursor = collection.find(currentQuery, resolvedOptions) - const originalToArray = cursor.toArray.bind(cursor) + createCachedCursor(cursor, { + cache, + cacheKey: baseCacheKey, + tag: authorizationCacheTag, + onCacheHit: async (response) => { + const filteredResponse = await Promise.all( + response.map(async (currentDoc) => { + const winningRole = getWinningRole(currentDoc, user, roles) + + logDebug('find winningRole', { + collection: collName, + userId: getUserId(user), + winningRoleName: winningRole?.name ?? null, + rolesLength: roles.length + }) + const { status, document } = winningRole + ? await checkValidation( + winningRole, + { + type: 'read', + roles, + cursor: currentDoc, + expansions: getValidationExpansions(currentDoc) + }, + user + ) + : fallbackAccess(currentDoc) - /** - * Overridden `toArray` method that validates each document for read access. - * - * @returns {Promise} An array of documents the user is authorized to read. - */ - cursor.toArray = async () => { - const response = await originalToArray() - - const filteredResponse = await Promise.all( - response.map(async (currentDoc) => { - const winningRole = getWinningRole(currentDoc, user, roles) - - logDebug('find winningRole', { - collection: collName, - userId: getUserId(user), - winningRoleName: winningRole?.name ?? null, - rolesLength: roles.length + return status ? document : undefined }) - const { status, document } = winningRole - ? await checkValidation( + ) + + return filteredResponse.filter(Boolean) as WithId[] + }, + transform: async (response) => { + const filteredResponse = await Promise.all( + response.map(async (currentDoc) => { + const winningRole = getWinningRole(currentDoc, user, roles) + + logDebug('find winningRole', { + collection: collName, + userId: getUserId(user), + winningRoleName: winningRole?.name ?? null, + rolesLength: roles.length + }) + const { status, document } = winningRole + ? await checkValidation( winningRole, { type: 'read', @@ -1072,20 +1292,26 @@ const getOperators: GetOperatorsFunction = ( }, user ) - : fallbackAccess(currentDoc) + : fallbackAccess(currentDoc) - return status ? document : undefined - }) - ) + return status ? document : undefined + }) + ) - return filteredResponse.filter(Boolean) as WithId[] - } + return filteredResponse.filter(Boolean) as WithId[] + } + }) emitMongoEvent('find') return cursor } // System mode: return original unfiltered cursor const cursor = collection.find(query, resolvedOptions) + createCachedCursor(cursor, { + cache, + cacheKey: baseCacheKey, + tag: authorizationCacheTag + }) emitMongoEvent('find') return cursor } catch (error) { @@ -1095,21 +1321,46 @@ const getOperators: GetOperatorsFunction = ( }, count: async (query, options) => { try { + const cacheKey = buildReadOperationCacheKey({ + dbName, + collName, + operation: 'count', + query, + options, + authPrincipal, + rolesSignature, + runAsSystem: run_as_system + }) if (!run_as_system) { checkDenyOperation( normalizedRules, collection.collectionName, CRUD_OPERATIONS.READ ) + } + if (canUseCache(cache, options)) { + const cached = await cache.get(cacheKey) + if (typeof cached !== 'undefined') { + emitMongoEvent('count', { cacheHit: true }) + return cached + } + } + if (!run_as_system) { const formattedQuery = getFormattedQuery(filters, query, user) const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {} logService('count query', { collName, currentQuery }) const result = await collection.countDocuments(currentQuery, options) + if (canUseCache(cache, options)) { + await cache.set(cacheKey, result, { tags: [authorizationCacheTag] }) + } emitMongoEvent('count') return result } const result = await collection.countDocuments(query, options) + if (canUseCache(cache, options)) { + await cache.set(cacheKey, result, { tags: [authorizationCacheTag] }) + } emitMongoEvent('count') return result } catch (error) { @@ -1119,21 +1370,46 @@ const getOperators: GetOperatorsFunction = ( }, countDocuments: async (query, options) => { try { + const cacheKey = buildReadOperationCacheKey({ + dbName, + collName, + operation: 'countDocuments', + query, + options, + authPrincipal, + rolesSignature, + runAsSystem: run_as_system + }) if (!run_as_system) { checkDenyOperation( normalizedRules, collection.collectionName, CRUD_OPERATIONS.READ ) + } + if (canUseCache(cache, options)) { + const cached = await cache.get(cacheKey) + if (typeof cached !== 'undefined') { + emitMongoEvent('countDocuments', { cacheHit: true }) + return cached + } + } + if (!run_as_system) { const formattedQuery = getFormattedQuery(filters, query, user) const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {} logService('countDocuments query', { collName, currentQuery }) const result = await collection.countDocuments(currentQuery, options) + if (canUseCache(cache, options)) { + await cache.set(cacheKey, result, { tags: [authorizationCacheTag] }) + } emitMongoEvent('countDocuments') return result } const result = await collection.countDocuments(query, options) + if (canUseCache(cache, options)) { + await cache.set(cacheKey, result, { tags: [authorizationCacheTag] }) + } emitMongoEvent('countDocuments') return result } catch (error) { @@ -1190,19 +1466,19 @@ const getOperators: GetOperatorsFunction = ( const allowDeleteBypass = watchPipelineRequestsDelete(requestedPipeline) const firstStep = watchFormattedQuery.length ? { - $match: allowDeleteBypass - ? { - $or: [ - { - $and: watchFormattedQuery - }, - { operationType: 'delete' } - ] - } - : { + $match: allowDeleteBypass + ? { + $or: [ + { $and: watchFormattedQuery - } - } + }, + { operationType: 'delete' } + ] + } + : { + $and: watchFormattedQuery + } + } : undefined const formattedPipeline = [firstStep, ...requestedPipeline].filter( @@ -1225,29 +1501,29 @@ const getOperators: GetOperatorsFunction = ( const fullDocumentValidation = winningRole ? await checkValidation( - winningRole, - { - type: 'read', - roles, - cursor: fullDocument, - expansions: getValidationExpansions(fullDocument) - }, - user - ) + winningRole, + { + type: 'read', + roles, + cursor: fullDocument, + expansions: getValidationExpansions(fullDocument) + }, + user + ) : fallbackAccess(fullDocument) const { status, document } = fullDocumentValidation const { status: updatedFieldsStatus, document: updatedFields } = winningRole ? await checkValidation( - winningRole, - { - type: 'read', - roles, - cursor: updateDescription?.updatedFields, - expansions: getValidationExpansions(fullDocument) - }, - user - ) + winningRole, + { + type: 'read', + roles, + cursor: updateDescription?.updatedFields, + expansions: getValidationExpansions(fullDocument) + }, + user + ) : fallbackAccess(updateDescription?.updatedFields) return { @@ -1393,15 +1669,15 @@ const getOperators: GetOperatorsFunction = ( const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: 'insert', - roles, - cursor: currentDoc, - expansions: getValidationExpansions() - }, - user - ) + winningRole, + { + type: 'insert', + roles, + cursor: currentDoc, + expansions: getValidationExpansions() + }, + user + ) : fallbackAccess(currentDoc) return status ? document : undefined @@ -1415,11 +1691,13 @@ const getOperators: GetOperatorsFunction = ( } const result = await collection.insertMany(documents, options) + await invalidateCollectionCache() emitMongoEvent('insertMany') return normalizeInsertManyResult(result) } // If system mode is active, insert all documents without validation const result = await collection.insertMany(documents, options) + await invalidateCollectionCache() emitMongoEvent('insertMany') return normalizeInsertManyResult(result) } catch (error) { @@ -1457,15 +1735,15 @@ const getOperators: GetOperatorsFunction = ( const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: 'write', - roles, - cursor: currentDoc, - expansions: getValidationExpansions(result[index]) - }, - user - ) + winningRole, + { + type: 'write', + roles, + cursor: currentDoc, + expansions: getValidationExpansions(result[index]) + }, + user + ) : fallbackAccess(currentDoc) return status ? document : undefined @@ -1486,10 +1764,12 @@ const getOperators: GetOperatorsFunction = ( normalizedData, options ) + await invalidateCollectionCache() emitMongoEvent('updateMany') return res } const result = await collection.updateMany(query, normalizedData, options) + await invalidateCollectionCache() emitMongoEvent('updateMany') return result } catch (error) { @@ -1533,15 +1813,15 @@ const getOperators: GetOperatorsFunction = ( const { status, document } = winningRole ? await checkValidation( - winningRole, - { - type: 'delete', - roles, - cursor: currentDoc, - expansions: getValidationExpansions(currentDoc) - }, - user - ) + winningRole, + { + type: 'delete', + roles, + cursor: currentDoc, + expansions: getValidationExpansions(currentDoc) + }, + user + ) : fallbackAccess(currentDoc) return status ? document : undefined @@ -1566,11 +1846,13 @@ const getOperators: GetOperatorsFunction = ( $and: [...formattedQuery, { _id: { $in: elementsToDelete } }] } const result = await collection.deleteMany(deleteQuery, options) + await invalidateCollectionCache() emitMongoEvent('deleteMany') return result } // If running as system, bypass access control and delete directly const result = await collection.deleteMany(query, options) + await invalidateCollectionCache() emitMongoEvent('deleteMany') return result } catch (error) { diff --git a/packages/flowerbase/src/state.ts b/packages/flowerbase/src/state.ts index f5679dd..a336bb1 100644 --- a/packages/flowerbase/src/state.ts +++ b/packages/flowerbase/src/state.ts @@ -1,4 +1,5 @@ import { FastifyInstance } from 'fastify' +import { CacheProvider, noopCacheProvider } from './cache' import { Endpoints } from './features/endpoints/interface' import { Functions } from './features/functions/interface' import { FunctionsQueue } from './features/functions/queue' @@ -14,6 +15,7 @@ type State = { endpoints: Endpoints rules: Rules projectId: string + cache: CacheProvider app?: FastifyInstance functionsQueue: FunctionsQueue monitoring: { @@ -28,6 +30,7 @@ export class StateManager { endpoints: [], projectId: '', rules: {}, + cache: noopCacheProvider, functionsQueue: new FunctionsQueue(), monitoring: {} }