diff --git a/apps/server/src/core/api-key/api-key.controller.spec.ts b/apps/server/src/core/api-key/api-key.controller.spec.ts new file mode 100644 index 00000000..3cf3d94c --- /dev/null +++ b/apps/server/src/core/api-key/api-key.controller.spec.ts @@ -0,0 +1,193 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ApiKeyController } from './api-key.controller'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../casl/interfaces/workspace-ability.type'; + +/** + * Authorization contract for the /api-keys management surface. + * + * - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method) + * — GitHub-PAT semantics closing post-revocation laundering. + * - admin (CASL Manage on API) sees/revokes all workspace keys; a member only + * their own. + * - kill-switch OFF -> the surface 404s (looks like the feature does not exist). + */ + +function makeController(over: any = {}) { + const apiKeyService = { + create: jest.fn().mockResolvedValue({ + token: 'tok', + key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() }, + }), + revoke: jest.fn().mockResolvedValue(undefined), + ...(over.apiKeyService ?? {}), + }; + const apiKeyRepo = { + findAllInWorkspace: jest.fn().mockResolvedValue([]), + findByCreator: jest.fn().mockResolvedValue([]), + findById: jest.fn(), + ...(over.apiKeyRepo ?? {}), + }; + const workspaceAbility = { + createForUser: jest.fn().mockReturnValue({ + can: (_a: any, _s: any) => over.canManage ?? false, + }), + ...(over.workspaceAbility ?? {}), + }; + const environmentService = { + isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true), + ...(over.environmentService ?? {}), + }; + const auditService = { log: jest.fn() }; + const controller = new ApiKeyController( + apiKeyService as any, + apiKeyRepo as any, + workspaceAbility as any, + environmentService as any, + auditService as any, + ); + return { + controller, + apiKeyService, + apiKeyRepo, + workspaceAbility, + environmentService, + auditService, + }; +} + +const user = { id: 'u-1', workspaceId: 'ws-1' } as any; +const workspace = { id: 'ws-1' } as any; +const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any; +const reqApiKey = () => + ({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any; + +describe('ApiKeyController — a token cannot manage tokens', () => { + it('403 on create for an api_key principal', async () => { + const { controller, apiKeyService } = makeController(); + await expect( + controller.create({ name: 'x' } as any, user, reqApiKey()), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(apiKeyService.create).not.toHaveBeenCalled(); + }); + + it('403 on list for an api_key principal', async () => { + const { controller } = makeController(); + await expect( + controller.list(user, workspace, reqApiKey()), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('403 on revoke for an api_key principal', async () => { + const { controller } = makeController(); + await expect( + controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); + +describe('ApiKeyController — kill-switch OFF → 404', () => { + it('create/list/revoke all 404 when disabled', async () => { + const { controller } = makeController({ enabled: false }); + await expect( + controller.create({ name: 'x' } as any, user, reqAccess()), + ).rejects.toBeInstanceOf(NotFoundException); + await expect( + controller.list(user, workspace, reqAccess()), + ).rejects.toBeInstanceOf(NotFoundException); + await expect( + controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('ApiKeyController — list scoping', () => { + it('admin (Manage on API) lists ALL workspace keys', async () => { + const { controller, apiKeyRepo } = makeController({ canManage: true }); + await controller.list(user, workspace, reqAccess()); + expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1'); + expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled(); + }); + + it('member lists ONLY their own keys', async () => { + const { controller, apiKeyRepo } = makeController({ canManage: false }); + await controller.list(user, workspace, reqAccess()); + expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1'); + expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled(); + }); +}); + +describe('ApiKeyController — revoke scoping', () => { + it("member cannot revoke another user's key (403)", async () => { + const { controller, apiKeyRepo, apiKeyService } = makeController({ + canManage: false, + }); + apiKeyRepo.findById.mockResolvedValue({ + id: 'k1', + creatorId: 'someone-else', + workspaceId: 'ws-1', + }); + await expect( + controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(apiKeyService.revoke).not.toHaveBeenCalled(); + }); + + it('member CAN revoke their own key', async () => { + const { controller, apiKeyRepo, apiKeyService } = makeController({ + canManage: false, + }); + apiKeyRepo.findById.mockResolvedValue({ + id: 'k1', + creatorId: 'u-1', + workspaceId: 'ws-1', + }); + await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()); + expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1'); + }); + + it("admin CAN revoke another user's key", async () => { + const { controller, apiKeyRepo, apiKeyService } = makeController({ + canManage: true, + }); + apiKeyRepo.findById.mockResolvedValue({ + id: 'k1', + creatorId: 'someone-else', + workspaceId: 'ws-1', + }); + await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()); + expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1'); + }); + + it('404 when the key does not exist', async () => { + const { controller, apiKeyRepo } = makeController({ canManage: true }); + apiKeyRepo.findById.mockResolvedValue(undefined); + await expect( + controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('ApiKeyController — create emits audit + returns token once', () => { + it('logs API_KEY_CREATED and returns the token + computed expiry', async () => { + const { controller, auditService } = makeController(); + const res = await controller.create( + { name: 'ci' } as any, + user, + reqAccess(), + ); + expect(res.token).toBe('tok'); + expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' }); + expect(auditService.log).toHaveBeenCalledWith( + expect.objectContaining({ event: 'api_key.created' }), + ); + }); +}); + +// Sanity: the CASL subject used is the workspace API subject. +it('uses WorkspaceCaslSubject.API with the Manage action', () => { + expect(WorkspaceCaslSubject.API).toBe('api_key'); + expect(WorkspaceCaslAction.Manage).toBeDefined(); +}); diff --git a/apps/server/src/core/api-key/api-key.controller.ts b/apps/server/src/core/api-key/api-key.controller.ts new file mode 100644 index 00000000..455d64d0 --- /dev/null +++ b/apps/server/src/core/api-key/api-key.controller.ts @@ -0,0 +1,201 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Inject, + Logger, + NotFoundException, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { FastifyRequest } from 'fastify'; +import { ApiKeyService } from './api-key.service'; +import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo'; +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { RevokeApiKeyDto } from './dto/revoke-api-key.dto'; +import { AuthUser } from '../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../casl/interfaces/workspace-ability.type'; +import { AuditEvent, AuditResource } from '../../common/events/audit-events'; +import { + AUDIT_SERVICE, + IAuditService, +} from '../../integrations/audit/audit.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard'; +import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names'; + +@UseGuards(JwtAuthGuard) +@Controller('api-keys') +export class ApiKeyController { + private readonly logger = new Logger(ApiKeyController.name); + + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly apiKeyRepo: ApiKeyRepo, + private readonly workspaceAbility: WorkspaceAbilityFactory, + private readonly environmentService: EnvironmentService, + @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, + ) {} + + // Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 — + // it must look like the feature does not exist. + private assertEnabled(): void { + if (!this.environmentService.isApiKeysEnabled()) { + throw new NotFoundException(); + } + } + + // A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is + // refused on the management surface, so a leaked key cannot mint a replacement + // or revoke the keys that would lock it out (closes post-revocation laundering). + private rejectApiKeyPrincipal(req: FastifyRequest): void { + if ((req.raw as { authType?: string }).authType === 'api_key') { + throw new ForbiddenException('API keys cannot manage API keys'); + } + } + + private clientIp(req: FastifyRequest): string { + return req.ip ?? req.socket?.remoteAddress ?? 'unknown'; + } + + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } }) + @Post('create') + async create( + @Body() dto: CreateApiKeyDto, + @AuthUser() user: User, + @Req() req: FastifyRequest, + ) { + this.assertEnabled(); + this.rejectApiKeyPrincipal(req); + + // undefined -> service default (1 year); null -> unlimited; string -> Date. + const expiresAt = + dto.expiresAt === undefined + ? undefined + : dto.expiresAt === null + ? null + : new Date(dto.expiresAt); + + const { token, key } = await this.apiKeyService.create( + user, + dto.name, + expiresAt, + ); + + this.auditService.log({ + event: AuditEvent.API_KEY_CREATED, + resourceType: AuditResource.API_KEY, + resourceId: key.id, + }); + // The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this + // build). No token material — the JWT is only ever returned in the response. + this.logger.log( + `API key created: id=${key.id} name=${JSON.stringify( + key.name, + )} actor=${user.id} expiresAt=${ + key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never' + } ip=${this.clientIp(req)}`, + ); + + // Return the token ONCE (never retrievable again) and the computed expiry so + // the caller/UI can surface "expires " (the year-default time-bomb + // early-warning). + return { + token, + apiKey: { + id: key.id, + name: key.name, + expiresAt: key.expiresAt, + createdAt: key.createdAt, + }, + }; + } + + @HttpCode(HttpStatus.OK) + @Post('list') + async list( + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Req() req: FastifyRequest, + ) { + this.assertEnabled(); + this.rejectApiKeyPrincipal(req); + + const ability = this.workspaceAbility.createForUser(user, workspace); + // Admin (CASL Manage on API): every key in the workspace, attributed to its + // creator (closes "a leaked key of a departed employee is invisible"). A + // member sees only their own. + const keys = ability.can( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.API, + ) + ? await this.apiKeyRepo.findAllInWorkspace(workspace.id) + : await this.apiKeyRepo.findByCreator(user.id, workspace.id); + + return keys.map((k) => ({ + id: k.id, + name: k.name, + expiresAt: k.expiresAt, + lastUsedAt: k.lastUsedAt, + createdAt: k.createdAt, + creator: k.creator, + })); + } + + @HttpCode(HttpStatus.OK) + @Post('revoke') + async revoke( + @Body() dto: RevokeApiKeyDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + @Req() req: FastifyRequest, + ) { + this.assertEnabled(); + this.rejectApiKeyPrincipal(req); + + const key = await this.apiKeyRepo.findById(dto.id, workspace.id); + // Uniform 404 for a missing/already-revoked key regardless of who asks — no + // existence oracle for keys the caller may not own. + if (!key) { + throw new NotFoundException(); + } + + const ability = this.workspaceAbility.createForUser(user, workspace); + const canManageAll = ability.can( + WorkspaceCaslAction.Manage, + WorkspaceCaslSubject.API, + ); + // Admin may revoke any key in the workspace; a member only their own. + if (!canManageAll && key.creatorId !== user.id) { + throw new ForbiddenException(); + } + + await this.apiKeyService.revoke(key.id, workspace.id); + + this.auditService.log({ + event: AuditEvent.API_KEY_DELETED, + resourceType: AuditResource.API_KEY, + resourceId: key.id, + }); + this.logger.log( + `API key revoked: id=${key.id} name=${JSON.stringify( + key.name, + )} actor=${user.id} ip=${this.clientIp(req)}`, + ); + + return { success: true }; + } +} diff --git a/apps/server/src/core/api-key/api-key.module.ts b/apps/server/src/core/api-key/api-key.module.ts new file mode 100644 index 00000000..9c002f5b --- /dev/null +++ b/apps/server/src/core/api-key/api-key.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ApiKeyService } from './api-key.service'; +import { ApiKeyController } from './api-key.controller'; +import { TokenModule } from '../auth/token.module'; + +// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator +// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router). +// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule +// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService +// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for +// jwt.strategy) and McpModule (for the /mcp router) can inject it directly, +// replacing the absent EE `ee/api-key` dynamic require. +@Module({ + imports: [TokenModule], + controllers: [ApiKeyController], + providers: [ApiKeyService], + exports: [ApiKeyService], +}) +export class ApiKeyModule {} diff --git a/apps/server/src/core/api-key/api-key.service.spec.ts b/apps/server/src/core/api-key/api-key.service.spec.ts new file mode 100644 index 00000000..248267ea --- /dev/null +++ b/apps/server/src/core/api-key/api-key.service.spec.ts @@ -0,0 +1,299 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ApiKeyService } from './api-key.service'; +import { JwtType } from '../auth/dto/jwt-payload'; + +/** + * Security contract for ApiKeyService.validate — the single validator shared by + * jwt.strategy (REST) and the /mcp Bearer router. + * + * Invariants under test: + * - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled + * user, workspace mismatch, kill-switch off) throws the SAME bare + * UnauthorizedException — an agent cannot distinguish expired from revoked. + * - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as + * itself (→ 5xx), never masked as a 401. + * - Expiry is read from the ROW, never a JWT exp claim. + * - No validate cache (each call re-reads the row → immediate revocation). + */ + +const APP_SECRET = 'secret'; + +function makeDeps(over: Partial> = {}) { + const apiKeyRepo = { + findById: jest.fn(), + insert: jest.fn(), + softDelete: jest.fn(), + touchLastUsed: jest.fn().mockResolvedValue(undefined), + ...(over.apiKeyRepo ?? {}), + }; + const userRepo = { + findById: jest.fn(), + ...(over.userRepo ?? {}), + }; + const workspaceRepo = { + findById: jest.fn().mockResolvedValue({ id: 'ws-1' }), + ...(over.workspaceRepo ?? {}), + }; + const tokenService = { + generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'), + ...(over.tokenService ?? {}), + }; + const environmentService = { + isApiKeysEnabled: jest.fn().mockReturnValue(true), + getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined), + getAppSecret: jest.fn().mockReturnValue(APP_SECRET), + ...(over.environmentService ?? {}), + }; + const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)( + apiKeyRepo, + userRepo, + workspaceRepo, + tokenService, + environmentService, + ); + return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService }; +} + +const payload = (over: Record = {}) => ({ + sub: 'u-1', + workspaceId: 'ws-1', + apiKeyId: 'key-1', + type: JwtType.API_KEY, + ...over, +}); + +const activeRow = (over: Record = {}) => ({ + id: 'key-1', + name: 'k', + creatorId: 'u-1', + workspaceId: 'ws-1', + expiresAt: null, + lastUsedAt: null, + deletedAt: null, + ...over, +}); + +const activeUser = (over: Record = {}) => ({ + id: 'u-1', + workspaceId: 'ws-1', + deactivatedAt: null, + deletedAt: null, + isAgent: false, + ...over, +}); + +describe('ApiKeyService.validate', () => { + it('returns { user, workspace } for a valid active key', async () => { + const { service, apiKeyRepo, userRepo } = makeDeps(); + apiKeyRepo.findById.mockResolvedValue(activeRow()); + userRepo.findById.mockResolvedValue(activeUser()); + + const res = await service.validate(payload() as any); + + expect(res.user).toMatchObject({ id: 'u-1' }); + expect(res.workspace).toMatchObject({ id: 'ws-1' }); + // Loads isAgent so downstream provenance does not silently degrade. + expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', { + includeIsAgent: true, + }); + }); + + // --- Anti-enumeration: every deny is the SAME bare 401 ------------------- + const denyCases: Array<[string, (d: ReturnType) => void]> = [ + [ + 'missing/orphaned row', + (d) => d.apiKeyRepo.findById.mockResolvedValue(undefined), + ], + [ + 'revoked (soft-deleted → findById returns undefined)', + (d) => d.apiKeyRepo.findById.mockResolvedValue(undefined), + ], + [ + 'expired (expires_at in the past)', + (d) => { + d.apiKeyRepo.findById.mockResolvedValue( + activeRow({ expiresAt: new Date(Date.now() - 1000) }), + ); + d.userRepo.findById.mockResolvedValue(activeUser()); + }, + ], + [ + 'disabled user', + (d) => { + d.apiKeyRepo.findById.mockResolvedValue(activeRow()); + d.userRepo.findById.mockResolvedValue( + activeUser({ deactivatedAt: new Date() }), + ); + }, + ], + [ + 'user not found', + (d) => { + d.apiKeyRepo.findById.mockResolvedValue(activeRow()); + d.userRepo.findById.mockResolvedValue(undefined); + }, + ], + [ + 'creator/sub mismatch', + (d) => { + d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' })); + d.userRepo.findById.mockResolvedValue(activeUser()); + }, + ], + [ + 'workspace row missing', + (d) => { + d.apiKeyRepo.findById.mockResolvedValue(activeRow()); + d.userRepo.findById.mockResolvedValue(activeUser()); + d.workspaceRepo.findById.mockResolvedValue(undefined); + }, + ], + [ + 'kill-switch OFF', + (d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false), + ], + [ + 'malformed payload (missing apiKeyId)', + () => undefined, + ], + ]; + + it.each(denyCases)( + 'throws a bare UnauthorizedException with NO message for: %s', + async (label, arrange) => { + const deps = makeDeps(); + arrange(deps); + const p = + label === 'malformed payload (missing apiKeyId)' + ? (payload({ apiKeyId: undefined }) as any) + : (payload() as any); + + const err = await deps.service.validate(p).catch((e) => e); + expect(err).toBeInstanceOf(UnauthorizedException); + // Anti-enumeration: identical, class-less message for every failure mode. + expect(err.message).toBe('Unauthorized'); + }, + ); + + it('kill-switch OFF denies BEFORE any DB read', async () => { + const { service, apiKeyRepo, environmentService } = makeDeps(); + environmentService.isApiKeysEnabled.mockReturnValue(false); + + await expect(service.validate(payload() as any)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(apiKeyRepo.findById).not.toHaveBeenCalled(); + }); + + // --- Infra error propagates (5xx), NOT masked as 401 --------------------- + it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => { + const { service, apiKeyRepo } = makeDeps(); + const boom = new Error('connection terminated'); + apiKeyRepo.findById.mockRejectedValue(boom); + + await expect(service.validate(payload() as any)).rejects.toBe(boom); + }); + + // --- No cache: revocation is immediate on the next call ------------------ + it('re-reads the row on every call (no validate cache → immediate revocation)', async () => { + const { service, apiKeyRepo, userRepo } = makeDeps(); + apiKeyRepo.findById.mockResolvedValue(activeRow()); + userRepo.findById.mockResolvedValue(activeUser()); + await service.validate(payload() as any); + + // Now the key is revoked (row invisible) — the very next call denies. + apiKeyRepo.findById.mockResolvedValue(undefined); + await expect(service.validate(payload() as any)).rejects.toBeInstanceOf( + UnauthorizedException, + ); + expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2); + }); + + // --- last_used_at is throttled + fire-and-forget ------------------------- + it('touches last_used_at when stale and skips when fresh', async () => { + const { service, apiKeyRepo, userRepo } = makeDeps(); + userRepo.findById.mockResolvedValue(activeUser()); + + // Stale (never used) -> touch. + apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null })); + await service.validate(payload() as any); + expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1); + + // Fresh (used 1 minute ago) -> skip. + apiKeyRepo.touchLastUsed.mockClear(); + apiKeyRepo.findById.mockResolvedValue( + activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }), + ); + await service.validate(payload() as any); + expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled(); + }); + + it('a failing last_used_at touch never fails the request', async () => { + const { service, apiKeyRepo, userRepo } = makeDeps(); + apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null })); + userRepo.findById.mockResolvedValue(activeUser()); + apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed')); + + await expect(service.validate(payload() as any)).resolves.toMatchObject({ + user: { id: 'u-1' }, + }); + }); +}); + +describe('ApiKeyService.create (mint-then-insert, no exp)', () => { + it('mints the JWT BEFORE inserting the row and returns the token once', async () => { + const { service, apiKeyRepo, tokenService } = makeDeps(); + const order: string[] = []; + tokenService.generateApiToken.mockImplementation(async () => { + order.push('mint'); + return 'tok'; + }); + apiKeyRepo.insert.mockImplementation(async (row: any) => { + order.push('insert'); + return { ...row, createdAt: new Date() }; + }); + + const user = { id: 'u-1', workspaceId: 'ws-1' } as any; + const res = await service.create(user, 'my key'); + + expect(order).toEqual(['mint', 'insert']); + expect(res.token).toBe('tok'); + // The id is generated before minting and reused for the row. + const mintArg = tokenService.generateApiToken.mock.calls[0][0]; + const insertArg = apiKeyRepo.insert.mock.calls[0][0]; + expect(mintArg.apiKeyId).toBe(insertArg.id); + }); + + it('applies the 1-year default when expiresAt is undefined', async () => { + const { service, apiKeyRepo } = makeDeps(); + apiKeyRepo.insert.mockImplementation(async (row: any) => row); + const user = { id: 'u-1', workspaceId: 'ws-1' } as any; + + await service.create(user, 'k'); + + const insertArg = apiKeyRepo.insert.mock.calls[0][0]; + const days = + (insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000); + expect(days).toBeGreaterThan(364); + expect(days).toBeLessThan(367); + }); + + it('honors an explicit null (unlimited)', async () => { + const { service, apiKeyRepo } = makeDeps(); + apiKeyRepo.insert.mockImplementation(async (row: any) => row); + const user = { id: 'u-1', workspaceId: 'ws-1' } as any; + + await service.create(user, 'k', null); + + expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull(); + }); + + it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => { + const { service, apiKeyRepo, tokenService } = makeDeps(); + tokenService.generateApiToken.mockRejectedValue(new Error('mint failed')); + const user = { id: 'u-1', workspaceId: 'ws-1' } as any; + + await expect(service.create(user, 'k')).rejects.toThrow('mint failed'); + expect(apiKeyRepo.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/core/api-key/api-key.service.ts b/apps/server/src/core/api-key/api-key.service.ts new file mode 100644 index 00000000..797fee8d --- /dev/null +++ b/apps/server/src/core/api-key/api-key.service.ts @@ -0,0 +1,191 @@ +import { + Injectable, + Logger, + OnModuleInit, + UnauthorizedException, +} from '@nestjs/common'; +import { v7 as uuid7 } from 'uuid'; +import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo'; +import { UserRepo } from '@docmost/db/repos/user/user.repo'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { TokenService } from '../auth/services/token.service'; +import { EnvironmentService } from '../../integrations/environment/environment.service'; +import { JwtApiKeyPayload } from '../auth/dto/jwt-payload'; +import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types'; +import { isUserDisabled } from '../../common/helpers'; + +// Default lifetime for a new key when the caller does not specify one: 1 year. +// The owner runs a homelab where agents live for years; forcing rotation is +// operational pain, so an explicit `null` (unlimited) is also allowed. +const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000; + +// last_used_at is a best-effort forensics stamp, not an access record; we only +// refresh it when it is older than this to avoid a write on every request. The +// 1h resolution is a deliberate constant (forensics granularity, not accounting). +const LAST_USED_THROTTLE_MS = 60 * 60 * 1000; + +/** + * Core API-key lifecycle service. Owns minting (create), the single validator + * shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate), + * and revocation (revoke). The `api_keys` ROW is the sole source of truth for a + * key's lifetime and revocation — never the JWT, which carries no `exp` claim. + */ +@Injectable() +export class ApiKeyService implements OnModuleInit { + private readonly logger = new Logger(ApiKeyService.name); + + constructor( + private readonly apiKeyRepo: ApiKeyRepo, + private readonly userRepo: UserRepo, + private readonly workspaceRepo: WorkspaceRepo, + private readonly tokenService: TokenService, + private readonly environmentService: EnvironmentService, + ) {} + + onModuleInit() { + // Boot log so the kill-switch state after each deploy is verifiable in logs. + const enabled = this.environmentService.isApiKeysEnabled(); + const raw = this.environmentService.getApiKeysEnabledRaw(); + this.logger.log( + `API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${ + raw ?? 'unset' + })`, + ); + } + + /** + * Mint a new key for `user`. mint-then-insert ordering (R1): + * 1. generate the id first — it must be in the JWT payload before the row. + * 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is + * written (inert), so a half-created key cannot exist. + * 3. insert the row last. A lost response leaves an orphaned row that is + * visible in `list` and self-heals (the user revokes it). + * The token is returned ONCE and never stored — the JWT is self-contained, so + * no token material lives in the table. + * + * `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit); + * a Date -> that instant (a past date is rejected at the DTO layer). + */ + async create( + user: User, + name: string, + expiresAt?: Date | null, + ): Promise<{ token: string; key: ApiKey }> { + const resolvedExpiresAt = + expiresAt === undefined + ? new Date(Date.now() + DEFAULT_LIFETIME_MS) + : expiresAt; + + const apiKeyId = uuid7(); + + const token = await this.tokenService.generateApiToken({ + apiKeyId, + user, + workspaceId: user.workspaceId, + }); + + const key = await this.apiKeyRepo.insert({ + id: apiKeyId, + name, + creatorId: user.id, + workspaceId: user.workspaceId, + expiresAt: resolvedExpiresAt, + }); + + return { token, key }; + } + + /** + * The single validator for an api-key principal, shared by jwt.strategy and the + * /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access + * path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work + * unchanged. + * + * Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature + * disabled, missing/revoked/expired row, workspace mismatch, disabled user — + * throws a bare `UnauthorizedException` (a single generic 401 for every case; + * an agent cannot distinguish expired from revoked, and its reaction is + * identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so + * the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra). + * There is NO validate cache: it is 2–3 PK lookups (~1ms), two orders of + * magnitude cheaper than the bcrypt it replaces; a cache would only add a + * Date-serialization trap and a revocation lag. Revocation is immediate. + */ + async validate( + payload: JwtApiKeyPayload, + ): Promise<{ user: User; workspace: Workspace }> { + // Kill-switch OFF: deny unconditionally (same generic 401). Note the + // endpoints additionally 404 at the controller; here we deny the token. + if (!this.environmentService.isApiKeysEnabled()) { + throw new UnauthorizedException(); + } + + if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) { + throw new UnauthorizedException(); + } + + const row = await this.apiKeyRepo.findById( + payload.apiKeyId, + payload.workspaceId, + ); + // Absent row = revoked (soft-deleted, invisible to findById), orphaned + // (creator/workspace cascade-deleted), or never existed. All terminal deny. + if (!row) { + throw new UnauthorizedException(); + } + + // Expiry is read from the ROW, never an `exp` JWT claim. + if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) { + throw new UnauthorizedException(); + } + + const user = await this.userRepo.findById( + payload.sub, + payload.workspaceId, + { includeIsAgent: true }, + ); + if (!user || isUserDisabled(user)) { + throw new UnauthorizedException(); + } + + // The key acts only as its creator (defence in depth against a token whose + // signed `sub` ever drifted from the row's owner). + if (row.creatorId !== user.id) { + throw new UnauthorizedException(); + } + + const workspace = await this.workspaceRepo.findById(payload.workspaceId); + if (!workspace) { + throw new UnauthorizedException(); + } + + // Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks. + this.touchLastUsed(row); + + return { user, workspace }; + } + + /** + * Revoke (soft-delete) a key. Authorization/ownership is decided by the caller + * (the controller, via CASL); this only performs the terminal write. Idempotent: + * a second revoke is a no-op (the row is already invisible). + */ + async revoke(id: string, workspaceId: string): Promise { + await this.apiKeyRepo.softDelete(id, workspaceId); + } + + // Throttled best-effort last_used_at bump: skip if it was touched within the + // window; otherwise fire-and-forget so a stamp write never fails or slows the + // request (mirrors SessionActivityService.trackActivity). + private touchLastUsed(row: ApiKey): void { + const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0; + if (Date.now() - last < LAST_USED_THROTTLE_MS) return; + void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => { + this.logger.warn( + `Failed to update api_key last_used_at for ${row.id}: ${ + (err as Error)?.message ?? err + }`, + ); + }); + } +} diff --git a/apps/server/src/core/api-key/dto/create-api-key.dto.ts b/apps/server/src/core/api-key/dto/create-api-key.dto.ts new file mode 100644 index 00000000..a3ee1bff --- /dev/null +++ b/apps/server/src/core/api-key/dto/create-api-key.dto.ts @@ -0,0 +1,51 @@ +import { + IsDateString, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + registerDecorator, + ValidationOptions, +} from 'class-validator'; + +/** + * `expiresAt` must be strictly in the future. Skips validation for `null` + * (explicit "unlimited") and `undefined` (server applies the 1-year default), + * so only an actually-supplied date is range-checked. Rejecting a PAST date at + * the DTO layer means a caller cannot mint an already-dead key. + */ +function IsFutureDateString(options?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isFutureDateString', + target: object.constructor, + propertyName, + options: { + message: 'expiresAt must be a date in the future', + ...options, + }, + validator: { + validate(value: unknown) { + if (value === null || value === undefined) return true; + if (typeof value !== 'string') return false; + const t = Date.parse(value); + return !Number.isNaN(t) && t > Date.now(); + }, + }, + }); + }; +} + +export class CreateApiKeyDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + // undefined -> default 1 year (applied server-side); null -> unlimited + // (explicit); an ISO date string -> that instant, which must be in the future. + @IsOptional() + @IsDateString() + @IsFutureDateString() + expiresAt?: string | null; +} diff --git a/apps/server/src/core/api-key/dto/revoke-api-key.dto.ts b/apps/server/src/core/api-key/dto/revoke-api-key.dto.ts new file mode 100644 index 00000000..c304fa8f --- /dev/null +++ b/apps/server/src/core/api-key/dto/revoke-api-key.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class RevokeApiKeyDto { + @IsUUID() + id: string; +} diff --git a/apps/server/src/core/auth/auth.module.ts b/apps/server/src/core/auth/auth.module.ts index 236f4dd5..3895fff0 100644 --- a/apps/server/src/core/auth/auth.module.ts +++ b/apps/server/src/core/auth/auth.module.ts @@ -5,9 +5,13 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { WorkspaceModule } from '../workspace/workspace.module'; import { SignupService } from './services/signup.service'; import { TokenModule } from './token.module'; +import { ApiKeyModule } from '../api-key/api-key.module'; @Module({ - imports: [TokenModule, WorkspaceModule], + // ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an + // api_key Bearer/cookie token is validated directly (replacing the absent EE + // `ee/api-key` dynamic require). + imports: [TokenModule, WorkspaceModule, ApiKeyModule], controllers: [AuthController], providers: [AuthService, SignupService, JwtStrategy], exports: [SignupService, AuthService], diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.spec.ts b/apps/server/src/core/auth/strategies/jwt.strategy.spec.ts index 20a669ca..cbcb3fc9 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.spec.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.spec.ts @@ -22,7 +22,8 @@ describe('JwtStrategy — provenance derivation', () => { const userSessionRepo: any = { findActiveById: jest.fn() }; const sessionActivityService: any = { trackActivity: jest.fn() }; const environmentService: any = { getAppSecret: () => 'test-secret' }; - const moduleRef: any = {}; + // ACCESS-path tests never touch the api-key seam; a bare stub suffices. + const apiKeyService: any = { validate: jest.fn() }; const strategy = new JwtStrategy( userRepo, @@ -30,7 +31,7 @@ describe('JwtStrategy — provenance derivation', () => { userSessionRepo, sessionActivityService, environmentService, - moduleRef, + apiKeyService, ); return { strategy, userRepo }; } @@ -122,25 +123,29 @@ describe('JwtStrategy — provenance derivation', () => { }); /** - * Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486). + * Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486 + * + #501). * * The access-token path stamped provenance; the API-key path returned early * WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker. * The API-key payload carries no signed claim, so provenance is resolved from the - * SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent', + * SERVER-SIDE user returned by ApiKeyService.validate: isAgent -> 'agent', * otherwise 'user'; aiChatId is always null (an API key has no ai_chats row). * - * The enterprise ApiKeyService is not bundled in the OSS build, so the strategy - * loads it through an overridable `resolveApiKeyService` seam that we stub here. + * #501 wires the CORE ApiKeyService (the EE `ee/api-key` module is absent in the + * fork) directly into the strategy — no dynamic require. The strategy also stamps + * `req.raw.authType='api_key'` + `req.raw.apiKeyId` for the "a token cannot manage + * tokens" guard on the /api-keys surface. */ -describe('JwtStrategy — API-key provenance derivation (#486)', () => { - function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise) { +describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => { + function makeApiKeyStrategy(validateImpl: (p: any) => Promise) { const userRepo: any = { findById: jest.fn() }; const workspaceRepo: any = { findById: jest.fn() }; const userSessionRepo: any = { findActiveById: jest.fn() }; const sessionActivityService: any = { trackActivity: jest.fn() }; const environmentService: any = { getAppSecret: () => 'test-secret' }; - const moduleRef: any = {}; + const validate = jest.fn(validateImpl); + const apiKeyService: any = { validate }; const strategy = new JwtStrategy( userRepo, @@ -148,14 +153,9 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => { userSessionRepo, sessionActivityService, environmentService, - moduleRef, + apiKeyService, ); - // Stub the EE ApiKeyService seam (the real module is not in the OSS build). - const validateApiKey = jest.fn(validateApiKeyImpl); - jest - .spyOn(strategy as any, 'resolveApiKeyService') - .mockReturnValue({ validateApiKey }); - return { strategy, validateApiKey }; + return { strategy, validate }; } const makeReq = () => ({ raw: {} as Record }); @@ -166,22 +166,23 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => { type: JwtType.API_KEY, }); - it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => { + it("stamps actor='agent' + authType/apiKeyId for an is_agent API key", async () => { const validated = { user: { id: 'svc-1', isAgent: true }, workspace: { id: 'ws-1' }, }; - const { strategy, validateApiKey } = makeApiKeyStrategy( - async () => validated, - ); + const { strategy, validate } = makeApiKeyStrategy(async () => validated); const req = makeReq(); const result = await strategy.validate(req, apiKeyPayload() as any); - expect(validateApiKey).toHaveBeenCalledTimes(1); + expect(validate).toHaveBeenCalledTimes(1); expect(req.raw.actor).toBe('agent'); // API keys carry no internal ai_chats row -> null. expect(req.raw.aiChatId).toBeNull(); + // Principal-kind markers for the management-surface guard. + expect(req.raw.authType).toBe('api_key'); + expect(req.raw.apiKeyId).toBe('key-1'); // The validated auth object is returned unchanged (req.user shape preserved). expect(result).toBe(validated); }); @@ -197,25 +198,19 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => { expect(req.raw.actor).toBe('user'); expect(req.raw.aiChatId).toBeNull(); + expect(req.raw.authType).toBe('api_key'); }); - it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => { - const userRepo: any = { findById: jest.fn() }; - const strategy = new JwtStrategy( - userRepo, - { findById: jest.fn() } as any, - { findActiveById: jest.fn() } as any, - { trackActivity: jest.fn() } as any, - { getAppSecret: () => 'test-secret' } as any, - {} as any, - ); - // EE not bundled: the seam returns null. - jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null); + it('propagates a validate() rejection and stamps nothing', async () => { + const { strategy } = makeApiKeyStrategy(async () => { + throw new UnauthorizedException(); + }); const req = makeReq(); await expect( strategy.validate(req, apiKeyPayload() as any), ).rejects.toThrow(UnauthorizedException); expect(req.raw.actor).toBeUndefined(); + expect(req.raw.authType).toBeUndefined(); }); }); diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts index 2abd65c2..ebb60283 100644 --- a/apps/server/src/core/auth/strategies/jwt.strategy.ts +++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy } from 'passport-jwt'; import { EnvironmentService } from '../../../integrations/environment/environment.service'; @@ -9,20 +9,18 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { SessionActivityService } from '../../session/session-activity.service'; import { FastifyRequest } from 'fastify'; import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers'; -import { ModuleRef } from '@nestjs/core'; import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator'; +import { ApiKeyService } from '../../api-key/api-key.service'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { - private logger = new Logger('JwtStrategy'); - constructor( private userRepo: UserRepo, private workspaceRepo: WorkspaceRepo, private userSessionRepo: UserSessionRepo, private sessionActivityService: SessionActivityService, private readonly environmentService: EnvironmentService, - private moduleRef: ModuleRef, + private readonly apiKeyService: ApiKeyService, ) { super({ jwtFromRequest: (req: FastifyRequest) => { @@ -102,12 +100,17 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } private async validateApiKey(req: any, payload: JwtApiKeyPayload) { - const apiKeyService = this.resolveApiKeyService(); - if (!apiKeyService) { - throw new UnauthorizedException('Enterprise API Key module missing'); - } + // The fork ships the core `ApiKeyService` (the EE `ee/api-key` module is + // absent). `validate` throws a bare UnauthorizedException on any definite + // deny (missing/revoked/expired row, disabled user, kill-switch off) and + // propagates infra errors (→ 5xx) rather than masking them as a 401. + const result = await this.apiKeyService.validate(payload); - const result = await apiKeyService.validateApiKey(payload); + // Stamp the principal kind + key id so the /api-keys management surface can + // enforce "a token cannot manage tokens". Done in this branch because it + // returns before the shared ACCESS-path stamping below. + req.raw.authType = 'api_key'; + req.raw.apiKeyId = payload.apiKeyId; // Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the // access-token path above, it CANNOT be resolved before this point: the @@ -119,32 +122,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { // SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable // — mirroring the access-token path. Passing `null` for the claim means the // actor is decided solely by user.isAgent. - const provenance = resolveProvenance((result as any)?.user, null); + const provenance = resolveProvenance(result.user, null); req.raw.actor = provenance.actor; req.raw.aiChatId = provenance.aiChatId; return result; } - - /** - * Resolve the enterprise ApiKeyService, or `null` when the EE module is not - * bundled in this build (community build). Extracted as an overridable seam so - * the API-key provenance stamping can be unit-tested without the EE package - * present (docmost is OSS + a separate EE bundle; `require` of the EE path - * throws here). Any load/resolve error is treated as "module missing". - */ - protected resolveApiKeyService(): { - validateApiKey: (payload: JwtApiKeyPayload) => Promise; - } | null { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const ApiKeyModule = require('./../../../ee/api-key/api-key.service'); - return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false }); - } catch (err) { - this.logger.debug( - 'API Key module requested but enterprise module not bundled in this build', - ); - return null; - } - } } diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index e898a4a1..50d7f090 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; +import { ApiKeyModule } from './api-key/api-key.module'; import { WorkspaceModule } from './workspace/workspace.module'; import { PageModule } from './page/page.module'; import { AttachmentModule } from './attachment/attachment.module'; @@ -29,6 +30,7 @@ import { ClsMiddleware } from 'nestjs-cls'; imports: [ UserModule, AuthModule, + ApiKeyModule, WorkspaceModule, PageModule, AttachmentModule, diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index 3e9218cc..8d16e5f9 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -37,6 +37,7 @@ import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo'; +import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo'; import { PageListener } from '@docmost/db/listeners/page.listener'; import { PostgresJSDialect } from 'kysely-postgres-js'; import * as postgres from 'postgres'; @@ -129,6 +130,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants'; AiMcpServerRepo, AiAgentRoleRepo, PageEmbeddingRepo, + ApiKeyRepo, PageListener, ], exports: [ @@ -164,6 +166,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants'; AiMcpServerRepo, AiAgentRoleRepo, PageEmbeddingRepo, + ApiKeyRepo, ], }) export class DatabaseModule implements OnApplicationBootstrap { diff --git a/apps/server/src/database/repos/api-key/api-key.repo.ts b/apps/server/src/database/repos/api-key/api-key.repo.ts new file mode 100644 index 00000000..835f0ac0 --- /dev/null +++ b/apps/server/src/database/repos/api-key/api-key.repo.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { DB, Users } from '@docmost/db/types/db'; +import { dbOrTx } from '@docmost/db/utils'; +import { ApiKey, InsertableApiKey } from '@docmost/db/types/entity.types'; +import { jsonObjectFrom } from 'kysely/helpers/postgres'; +import { ExpressionBuilder } from 'kysely'; + +// Public-facing shape: the api_keys row plus a compact creator attribution used +// by the admin list (the workspace-wide view attributes each key to its author). +export type ApiKeyWithCreator = ApiKey & { + creator: Pick | null; +}; + +@Injectable() +export class ApiKeyRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + // Compact creator attribution embedded in the admin list rows. A nested + // subquery (jsonObjectFrom) keeps it one round-trip; the token material is + // never stored, so nothing sensitive is joined in. + private withCreator(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom('users') + .select(['users.id', 'users.name', 'users.email', 'users.avatarUrl']) + .whereRef('users.id', '=', 'apiKeys.creatorId'), + ).as('creator'); + } + + async findById( + id: string, + workspaceId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('apiKeys') + .selectAll() + .where('id', '=', id) + .where('workspaceId', '=', workspaceId) + // Convention (soft-delete): a revoked key is invisible to reads. + .where('deletedAt', 'is', null) + .executeTakeFirst(); + } + + async findByCreator( + creatorId: string, + workspaceId: string, + ): Promise { + return this.db + .selectFrom('apiKeys') + .selectAll('apiKeys') + .select((eb) => this.withCreator(eb)) + .where('creatorId', '=', creatorId) + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute() as unknown as Promise; + } + + async findAllInWorkspace( + workspaceId: string, + ): Promise { + return this.db + .selectFrom('apiKeys') + .selectAll('apiKeys') + .select((eb) => this.withCreator(eb)) + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .orderBy('createdAt', 'desc') + .execute() as unknown as Promise; + } + + async insert( + insertable: InsertableApiKey, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('apiKeys') + .values(insertable) + .returningAll() + .executeTakeFirst(); + } + + // Soft-delete = revoke. Terminal: the row stays for forensics but is invisible + // to every read above, so validate() denies it immediately (no grace window). + async softDelete(id: string, workspaceId: string): Promise { + await this.db + .updateTable('apiKeys') + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where('id', '=', id) + .where('workspaceId', '=', workspaceId) + .where('deletedAt', 'is', null) + .execute(); + } + + // Throttled best-effort forensics stamp. Not on the critical path: callers + // fire-and-forget and swallow errors, so it never fails a request. + async touchLastUsed(id: string): Promise { + await this.db + .updateTable('apiKeys') + .set({ lastUsedAt: new Date() }) + .where('id', '=', id) + .where('deletedAt', 'is', null) + .execute(); + } +} diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index cea4d00d..e1fc423c 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -70,6 +70,23 @@ export class EnvironmentService { return this.configService.get('JWT_TOKEN_EXPIRES_IN', '90d'); } + // Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a + // deploy that never sets the variable must NOT silently kill every agent. The + // parse is STRICT — the value is validated by environment.validation to be + // exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False` + // fails at boot rather than being read as "enabled" (which would leave an + // operator who yanked the switch during an incident with it still on). When + // OFF: validate() denies every api-key token and the issuance endpoints 404. + isApiKeysEnabled(): boolean { + return this.configService.get('API_KEYS_ENABLED', 'true') !== 'false'; + } + + // Raw value (or undefined) for the boot log, so the state after each deploy is + // verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`. + getApiKeysEnabledRaw(): string | undefined { + return this.configService.get('API_KEYS_ENABLED'); + } + getCookieExpiresIn(): Date { const expiresInStr = this.getJwtTokenExpiresIn(); let msUntilExpiry: number; diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index 476bc81c..3b2f65eb 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -103,6 +103,15 @@ export class EnvironmentVariables { @IsString() TYPESENSE_LOCALE: string; + // Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the + // literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot + // instead of being silently read as "enabled" — the switch must actually flip + // when an operator flips it. See EnvironmentService.isApiKeysEnabled. + @IsOptional() + @IsIn(['true', 'false']) + @IsString() + API_KEYS_ENABLED: string; + @IsOptional() @ValidateIf((obj) => obj.AI_DRIVER) @IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])