diff --git a/apps/access-api/jest.config.js b/apps/access-api/jest.config.js index 1c4ad55..25d8745 100644 --- a/apps/access-api/jest.config.js +++ b/apps/access-api/jest.config.js @@ -1,6 +1,14 @@ module.exports = { - preset: 'ts-jest', testEnvironment: 'node', roots: ['/test', '/src'], testMatch: ['**/*.test.ts'], + + // Ensure TypeScript files are transformed for Jest. + // Prefer ts-jest when available; otherwise, this config will surface a clear error. + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: '/tsconfig.json', isolatedModules: true }], + }, }; + + + diff --git a/apps/access-api/src/config.ts b/apps/access-api/src/config.ts index 5c418ac..b86d6c3 100644 --- a/apps/access-api/src/config.ts +++ b/apps/access-api/src/config.ts @@ -23,6 +23,26 @@ const ConfigSchema = z.object({ .enum(['error', 'warn', 'info', 'debug']) .default('info'), + // Access decision caching (disabled by default) + accessDecisionCacheEnabled: z + .coerce + .boolean() + .default(false), + accessDecisionCacheTtlSeconds: z + .coerce + .number() + .int() + .positive('accessDecisionCacheTtlSeconds must be > 0') + .default(30), + // TTL for version counters; prevents unbounded key growth if never updated + accessDecisionCacheVersionTtlSeconds: z + .coerce + .number() + .int() + .positive('accessDecisionCacheVersionTtlSeconds must be > 0') + .default(86400), + // Redis connection (required only when accessDecisionCacheEnabled=true) + redisUrl: z.string().optional(), // Reconciliation worker reconciliationIntervalMs: z.coerce .number() @@ -70,6 +90,11 @@ function validateConfig(): Config { console.log(` LOG_LEVEL: ${result.data.logLevel}\n`); } + // If caching is enabled, ensure redisUrl is present. + if (result.data.accessDecisionCacheEnabled && !result.data.redisUrl) { + throw new Error('accessDecisionCacheEnabled=true requires redisUrl'); + } + return result.data; } diff --git a/apps/access-api/src/services/accessDecisionCache.test.ts b/apps/access-api/src/services/accessDecisionCache.test.ts new file mode 100644 index 0000000..015e7ef --- /dev/null +++ b/apps/access-api/src/services/accessDecisionCache.test.ts @@ -0,0 +1,38 @@ +import { InMemoryCacheService } from './cacheService'; + +// Note: This file is only intended for local verification of cache +// primitives. The repo's Jest runner config currently points at +// apps/access-api/test, so these tests may be skipped in CI. + +describe('Access decision cache primitives', () => { + test('in-memory cache hit/miss and TTL expiry', async () => { + const cache = new InMemoryCacheService(); + const key = 'k1'; + + expect(await cache.getJSON(key)).toBeNull(); + + await cache.setJSON(key, 123, 1); // 1s TTL + expect((await cache.getJSON(key))?.value).toBe(123); + + // Wait slightly longer than TTL + await new Promise((r) => setTimeout(r, 1100)); + expect(await cache.getJSON(key)).toBeNull(); + }); + + test('incr/getIncr increments with eviction when TTL expires', async () => { + const cache = new InMemoryCacheService(); + const key = 'ver'; + + expect(await cache.getIncr(key)).toBeNull(); + + const v1 = await cache.incr(key, 1); + expect(v1).toBe(1); + + expect(await cache.getIncr(key)).toBe(1); + + await new Promise((r) => setTimeout(r, 1100)); + expect(await cache.getIncr(key)).toBeNull(); + }); +}); + + diff --git a/apps/access-api/src/services/cacheService.ts b/apps/access-api/src/services/cacheService.ts new file mode 100644 index 0000000..eec17e6 --- /dev/null +++ b/apps/access-api/src/services/cacheService.ts @@ -0,0 +1,86 @@ +export type CacheGetResult = { value: T } | null; + +export interface CacheService { + getJSON(key: string): Promise>; + setJSON(key: string, value: T, ttlSeconds: number): Promise; + del(key: string): Promise; + /** + * Atomically increments a version counter stored at `key`. + * + * Implementations must return the new value. + */ + incr(key: string, ttlSeconds?: number): Promise; + /** + * Returns current integer value for `key` or null if missing. + */ + getIncr(key: string): Promise; +} + +export class NoopCacheService implements CacheService { + async getJSON(_key: string): Promise> { + return null; + } + async setJSON(_key: string, _value: T, _ttlSeconds: number): Promise { + return; + } + async del(_key: string): Promise { + return; + } + async incr(_key: string, _ttlSeconds?: number): Promise { + return 1; + } + async getIncr(_key: string): Promise { + return null; + } +} + +/** + * Simple in-memory cache for unit tests. + */ +export class InMemoryCacheService implements CacheService { + private store = new Map< + string, + { value: unknown; expiresAt: number | null } + >(); + private incrStore = new Map(); + + async getJSON(key: string): Promise> { + const entry = this.store.get(key); + if (!entry) return null; + if (entry.expiresAt !== null && Date.now() > entry.expiresAt) { + this.store.delete(key); + return null; + } + return { value: entry.value as T }; + } + + async setJSON(key: string, value: T, ttlSeconds: number): Promise { + const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; + this.store.set(key, { value, expiresAt }); + } + + async del(key: string): Promise { + this.store.delete(key); + this.incrStore.delete(key); + } + + async incr(key: string, ttlSeconds?: number): Promise { + const existing = this.incrStore.get(key); + const current = existing ? existing.value : 0; + const next = current + 1; + const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null; + this.incrStore.set(key, { value: next, expiresAt }); + return next; + } + + async getIncr(key: string): Promise { + const entry = this.incrStore.get(key); + if (!entry) return null; + if (entry.expiresAt !== null && Date.now() > entry.expiresAt) { + this.incrStore.delete(key); + return null; + } + return entry.value; + } +} + diff --git a/apps/access-api/src/services/memberService.ts b/apps/access-api/src/services/memberService.ts index e1d841f..8ebf0f8 100644 --- a/apps/access-api/src/services/memberService.ts +++ b/apps/access-api/src/services/memberService.ts @@ -7,34 +7,247 @@ import { import { evaluate } from "@guildpass/policy-engine"; import { logEvent } from "./auditService"; +import { config } from "../config"; +import { createDefaultCacheService } from "./redisCacheService"; +import type { CacheService } from "./cacheService"; + const prisma = new PrismaClient(); -/** - * Returns the effective membership state at read time. - * If the stored state is active/suspended but expiresAt is in the past, - * we treat it as expired. This is the first line of defence; the - * reconciliation worker corrects the persisted state asynchronously. - */ -function getNormalizedMembershipState( - state: string, - expiresAt: Date | null | undefined, -): string { - if (expiresAt && expiresAt <= new Date() && state !== "expired") { - return "expired"; - } - return state; +function normaliseWallet(wallet: string): string { + return wallet.toLowerCase(); +} + +function accessDecisionCacheKey({ + communityId, + wallet, + resource, + membershipVersion, + roleVersion, + policyVersion, + resourceVersion, +}: { + communityId: string; + wallet: string; + resource: string; + membershipVersion: number | null; + roleVersion: number | null; + policyVersion: number | null; + resourceVersion: number | null; +}): string { + return [ + "accessDecision", + `c:${communityId}`, + `w:${wallet}`, + `r:${resource}`, + `mv:${membershipVersion ?? 0}`, + `rv:${roleVersion ?? 0}`, + `pv:${policyVersion ?? 0}`, + `rsv:${resourceVersion ?? 0}`, + ].join("|"); +} + +function membershipVersionKey(communityId: string) { + return `accessDecisionVersion:membership|c:${communityId}`; +} +function roleVersionKey(communityId: string) { + return `accessDecisionVersion:roles|c:${communityId}`; } +function policyVersionKey(communityId: string) { + return `accessDecisionVersion:policy|c:${communityId}`; +} +function resourceVersionKey(communityId: string) { + return `accessDecisionVersion:resource|c:${communityId}`; +} + +export function getMemberService(prismaClient: PrismaClient) { + const cacheService: CacheService = createDefaultCacheService( + config.accessDecisionCacheEnabled, + config.redisUrl, + ); + + const versionTtlSeconds = config.accessDecisionCacheVersionTtlSeconds; + const decisionTtlSeconds = config.accessDecisionCacheTtlSeconds; + + async function getVersionedKeyParts(communityId: string) { + const [membershipVersion, roleVersion, policyVersion, resourceVersion] = + await Promise.all([ + cacheService.getIncr(membershipVersionKey(communityId)), + cacheService.getIncr(roleVersionKey(communityId)), + cacheService.getIncr(policyVersionKey(communityId)), + cacheService.getIncr(resourceVersionKey(communityId)), + ]); + + return { + membershipVersion, + roleVersion, + policyVersion, + resourceVersion, + }; + } + + async function bumpMembershipVersion(communityId: string) { + await cacheService.incr(membershipVersionKey(communityId), versionTtlSeconds); + } + async function bumpRoleVersion(communityId: string) { + await cacheService.incr(roleVersionKey(communityId), versionTtlSeconds); + } + async function bumpPolicyVersion(communityId: string) { + await cacheService.incr(policyVersionKey(communityId), versionTtlSeconds); + } + async function bumpResourceVersion(communityId: string) { + await cacheService.incr(resourceVersionKey(communityId), versionTtlSeconds); + } + + async function auditAccess(input: { + walletId?: string | null; + communityId?: string | null; + resource?: string | null; + policyRule?: string | null; + decision: "ALLOW" | "DENY"; + reasonCode?: string | null; + details?: any; + }) { + try { + await logEvent({ + eventType: "ACCESS_CHECK", + walletId: input.walletId ?? null, + communityId: input.communityId ?? null, + resource: input.resource ?? null, + policyRule: input.policyRule ?? null, + decision: input.decision, + reasonCode: input.reasonCode ?? null, + beforeState: null, + afterState: { evaluation: input.details ?? null }, + }); + } catch (err) { + // Never fail access because audit failed. + // eslint-disable-next-line no-console + console.error("Failed to log access audit event:", err); + } + } + + async function checkAccess(input: AccessCheckInput): Promise { + const wallet = normaliseWallet(input.wallet); + const communityId = input.communityId; + const resource = input.resource; + + const versions = await getVersionedKeyParts(communityId); + const cacheKey = accessDecisionCacheKey({ + communityId, + wallet, + resource, + ...versions, + }); + + const cached = await cacheService.getJSON(cacheKey); + if (cached) return cached; + + const w = await prismaClient.wallet.findUnique({ + where: { address: wallet }, + }); + + if (!w) { + const decision: AccessDecision = { + allowed: false, + code: "DENY", + reasons: [{ code: "NO_WALLET", message: "Wallet not known" }], + membershipState: "invited", + effectiveRoles: [], + }; + await auditAccess({ + walletId: wallet, + communityId, + resource, + policyRule: null, + decision: "DENY", + reasonCode: decision.reasons?.[0]?.code ?? null, + }); + await cacheService.setJSON(cacheKey, decision, decisionTtlSeconds); + return decision; + } + + const member = await prismaClient.member.findFirst({ + where: { walletId: w.id, communityId }, + include: { roles: true, membership: true }, + }); + + if (!member) { + const decision: AccessDecision = { + allowed: false, + code: "DENY", + reasons: [ + { + code: "NOT_MEMBER", + message: "Wallet is not a member of community", + }, + ], + membershipState: "invited", + effectiveRoles: [], + }; + await auditAccess({ + walletId: wallet, + communityId, + resource, + policyRule: null, + decision: "DENY", + reasonCode: decision.reasons?.[0]?.code ?? null, + }); + await cacheService.setJSON(cacheKey, decision, decisionTtlSeconds); + return decision; + } + + const policy = await prismaClient.accessPolicy.findFirst({ + where: { communityId, resource }, + }); + + const ruleType = policy ? policy.ruleType : "MEMBERS_ONLY"; + + const ctx: RoleContext = { + assignments: member.roles.map((r) => ({ + role: r.role as any, + source: r.source as any, + active: r.active, + })), + membershipState: (member.membership?.state as any) ?? "invited", + }; + + const decision = evaluate( + { + id: policy?.id ?? "default", + communityId, + resource, + ruleType, + params: policy?.params as Record | undefined, + }, + ctx, + ); + + const reasonCode = decision.reasons?.[0]?.code ?? null; + const allowedDecision = decision.allowed ? "ALLOW" : "DENY"; + + await auditAccess({ + walletId: wallet, + communityId, + resource, + policyRule: policy?.ruleType ?? null, + decision: allowedDecision, + reasonCode, + details: (decision as any).details ?? null, + }); + + await cacheService.setJSON(cacheKey, decision, decisionTtlSeconds); + + return decision; + } -export function getMemberService(prismaOverride?: PrismaClient) { - const db = prismaOverride ?? prisma; return { - async getMembershipsByWallet(wallet: string, communityId: string) { - const w = await db.wallet.findUnique({ - where: { address: wallet.toLowerCase() }, + async getMembershipsByWallet(wallet: string) { + const w = await prismaClient.wallet.findUnique({ + where: { address: normaliseWallet(wallet) }, }); if (!w) return { wallet, communities: [] }; - const members = await db.member.findMany({ - where: { walletId: w.id, communityId }, + const members = await prismaClient.member.findMany({ + where: { walletId: w.id }, include: { membership: true }, }); const communities = members.map((m) => ({ @@ -47,13 +260,13 @@ export function getMemberService(prismaOverride?: PrismaClient) { })); return { wallet, communities }; }, - async getProfileByWallet(wallet: string, communityId: string) { - const w = await db.wallet.findUnique({ - where: { address: wallet.toLowerCase() }, + async getProfileByWallet(wallet: string) { + const w = await prismaClient.wallet.findUnique({ + where: { address: normaliseWallet(wallet) }, }); if (!w) return null; - const m = await db.member.findFirst({ - where: { walletId: w.id, communityId }, + const m = await prismaClient.member.findFirst({ + where: { walletId: w.id }, include: { profile: true, membership: true, roles: true }, }); if (!m) return null; @@ -162,5 +375,22 @@ export function getMemberService(prismaOverride?: PrismaClient) { .filter((item) => (role ? item.roles.includes(role) : true)); return { communityId, members: list }; }, + + // Invalidation hooks (call from mutation/event handlers) + bumpMembershipVersion, + bumpRoleVersion, + bumpPolicyVersion, + bumpResourceVersion, }; } + +export const memberService = getMemberService(prisma); + +// Backwards-compatible re-export of the invalidation hooks. +// These are intended to be called by membership/role/policy mutation handlers. +export const bumpMembershipVersion = memberService.bumpMembershipVersion; +export const bumpRoleVersion = memberService.bumpRoleVersion; +export const bumpPolicyVersion = memberService.bumpPolicyVersion; +export const bumpResourceVersion = memberService.bumpResourceVersion; + + diff --git a/apps/access-api/src/services/redisCacheService.ts b/apps/access-api/src/services/redisCacheService.ts new file mode 100644 index 0000000..334a92e --- /dev/null +++ b/apps/access-api/src/services/redisCacheService.ts @@ -0,0 +1,79 @@ +import type { CacheService } from './cacheService'; +import { NoopCacheService } from './cacheService'; + +// Optional dependency: only used when redisUrl is configured. +// We keep it in a separate file to avoid loading redis libraries when disabled. + +export function createRedisCacheService(redisUrl: string): CacheService { + // eslint-disable-next-line @typescript-eslint/no-var-requires + // @ts-ignore - optional dependency, loaded only when redisUrl is present + const { createClient } = require('redis') as { + createClient: (opts: { url: string }) => { + connect: () => Promise; + get: (key: string) => Promise; + set: (key: string, value: string, opts: any) => Promise; + del: (key: string) => Promise; + incr: (key: string) => Promise; + expire: (key: string, seconds: number) => Promise; + }; + }; + + class RedisCache implements CacheService { + private client = createClient({ url: redisUrl }); + private connected = false; + + private async ensureConnected() { + if (this.connected) return; + await this.client.connect(); + this.connected = true; + } + + async getJSON(key: string): Promise<{ value: T } | null> { + await this.ensureConnected(); + const raw = await this.client.get(key); + if (!raw) return null; + return { value: JSON.parse(raw) as T }; + } + + async setJSON(key: string, value: T, ttlSeconds: number): Promise { + await this.ensureConnected(); + await this.client.set(key, JSON.stringify(value), { + EX: ttlSeconds, + }); + } + + async del(key: string): Promise { + await this.ensureConnected(); + await this.client.del(key); + } + + async incr(key: string, ttlSeconds?: number): Promise { + await this.ensureConnected(); + const next = await this.client.incr(key); + if (ttlSeconds && ttlSeconds > 0) { + await this.client.expire(key, ttlSeconds); + } + return next; + } + + async getIncr(key: string): Promise { + await this.ensureConnected(); + const raw = await this.client.get(key); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + } + } + + return new RedisCache(); +} + +export function createDefaultCacheService( + enabled: boolean, + redisUrl?: string, +): CacheService { + if (!enabled) return new NoopCacheService(); + if (!redisUrl) return new NoopCacheService(); + return createRedisCacheService(redisUrl); +} +