From 9457b3cda150582914f1ed31307e4e478b844293 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 1 Jul 2025 23:45:38 +0200 Subject: [PATCH] feat: api keys --- .../modules/Auth/AuthApiKeys.controllers.ts | 4 +- .../AuthApiKeyAuthorization.service.ts | 17 +++++++ .../Auth/commands/GenerateApiKey.service.ts | 17 +++++-- .../src/modules/Auth/models/ApiKey.model.ts | 49 ++++++++++++++++++- .../Auth/queries/GetApiKeys.service.ts | 1 + .../Auth/queries/GetApiKeys.transformer.ts | 13 +---- .../modules/Tenancy/TenancyGlobal.guard.ts | 6 ++- 7 files changed, 87 insertions(+), 20 deletions(-) diff --git a/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts index 742635e93..98109436e 100644 --- a/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts +++ b/packages/server/src/modules/Auth/AuthApiKeys.controllers.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Param, Get } from '@nestjs/common'; +import { Controller, Post, Param, Get, Put } from '@nestjs/common'; import { GenerateApiKey } from './commands/GenerateApiKey.service'; import { GetApiKeysService } from './queries/GetApiKeys.service'; @@ -14,7 +14,7 @@ export class AuthApiKeysController { return this.generateApiKeyService.generate(); } - @Post(':id/revoke') + @Put(':id/revoke') async revoke(@Param('id') id: number) { return this.generateApiKeyService.revoke(id); } diff --git a/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts b/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts index 2cd0ef361..9fb602fae 100644 --- a/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts +++ b/packages/server/src/modules/Auth/commands/AuthApiKeyAuthorization.service.ts @@ -1,11 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { ApiKeyModel } from '../models/ApiKey.model'; +import { ClsService } from 'nestjs-cls'; +import { TenantModel } from '@/modules/System/models/TenantModel'; @Injectable() export class AuthApiKeyAuthorizeService { constructor( + private readonly clsService: ClsService, + @Inject(ApiKeyModel.name) private readonly apikeyModel: typeof ApiKeyModel, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel, ) {} /** @@ -28,6 +35,16 @@ export class AuthApiKeyAuthorizeService { ) { return false; } + const tenant = await this.tenantModel + .query() + .findById(apiKeyRecord.tenantId); + + if (!tenant) return false; + + this.clsService.set('tenantId', tenant.id); + this.clsService.set('organizationId', tenant.organizationId); + this.clsService.set('userId', apiKeyRecord.userId); + return true; } } diff --git a/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts index 084f0312a..5b7ec1788 100644 --- a/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts +++ b/packages/server/src/modules/Auth/commands/GenerateApiKey.service.ts @@ -12,8 +12,9 @@ export class GenerateApiKey { ) {} /** - * - * @returns + * Generates a new secure API key for the current tenant and system user. + * The key is saved in the database and returned (only the key and id for security). + * @returns {Promise<{ key: string; id: number }>} The generated API key and its database id. */ async generate() { const tenant = await this.tenancyContext.getTenant(); @@ -27,15 +28,23 @@ export class GenerateApiKey { tenantId: tenant.id, userId: user.id, createdAt: new Date(), - revoked: false, + revokedAt: null, }); // Return the created API key (not the full record for security) return { key: apiKeyRecord.key, id: apiKeyRecord.id }; } + /** + * Revokes an API key by setting its revokedAt timestamp. + * @param {number} apiKeyId - The id of the API key to revoke. + * @returns {Promise<{ id: number; revoked: boolean }>} The id of the revoked API key and a revoked flag. + */ async revoke(apiKeyId: number) { // Set the revoked flag to true for the given API key - await ApiKeyModel.query().findById(apiKeyId).patch({ revoked: true }); + await ApiKeyModel.query() + .findById(apiKeyId) + .patch({ revokedAt: new Date() }); + return { id: apiKeyId, revoked: true }; } } diff --git a/packages/server/src/modules/Auth/models/ApiKey.model.ts b/packages/server/src/modules/Auth/models/ApiKey.model.ts index b61df86dc..94ae52e92 100644 --- a/packages/server/src/modules/Auth/models/ApiKey.model.ts +++ b/packages/server/src/modules/Auth/models/ApiKey.model.ts @@ -1,14 +1,23 @@ import { SystemModel } from '@/modules/System/models/SystemModel'; +import { Model } from 'objection'; export class ApiKeyModel extends SystemModel { readonly key: string; readonly name?: string; readonly createdAt: Date; readonly expiresAt?: Date; - readonly revoked?: boolean; + readonly revokedAt?: Date; readonly userId: number; readonly tenantId: number; + get revoked() { + return !!this.revokedAt; + } + + static get virtualAttributes() { + return ['revoked']; + } + /** * Table name */ @@ -22,4 +31,42 @@ export class ApiKeyModel extends SystemModel { get timestamps() { return ['createdAt']; } + + /** + * Relation mappings for Objection.js + */ + static get relationMappings() { + const { SystemUser } = require('../../System/models/SystemUser'); + const { TenantModel } = require('../../System/models/TenantModel'); + + return { + user: { + relation: Model.BelongsToOneRelation, + modelClass: SystemUser, + join: { + from: 'api_keys.userId', + to: 'users.id', + }, + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: TenantModel, + join: { + from: 'api_keys.tenantId', + to: 'tenants.id', + }, + }, + }; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + notRevoked(query) { + query.whereNull('revokedAt'); + }, + }; + } } diff --git a/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts b/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts index c0b1db677..abb0c812b 100644 --- a/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts +++ b/packages/server/src/modules/Auth/queries/GetApiKeys.service.ts @@ -18,6 +18,7 @@ export class GetApiKeysService { const tenant = await this.tenancyContext.getTenant(); const apiKeys = await this.apiKeyModel .query() + .modify('notRevoked') .where({ tenantId: tenant.id }); return this.injectableTransformer.transform( diff --git a/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts index 0ddc1312f..8d4d5d128 100644 --- a/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts +++ b/packages/server/src/modules/Auth/queries/GetApiKeys.transformer.ts @@ -1,16 +1,7 @@ import { Transformer } from '@/modules/Transformer/Transformer'; export class GetApiKeysTransformer extends Transformer { - public includeAttributes = (): string[] => { - return [ - 'id', - 'key', - 'name', - 'createdAt', - 'expiresAt', - 'revoked', - 'userId', - 'tenantId', - ]; + public excludeAttributes = (): string[] => { + return ['tenantId']; }; } diff --git a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts index 43f9673cc..97a6f24fe 100644 --- a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts +++ b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { Injectable, CanActivate, @@ -24,11 +25,12 @@ export class TenancyGlobalGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const organizationId = request.headers['organization-id']; + const authorization = request.headers['authorization']?.trim(); const isPublic = this.reflector.getAllAndOverride( IS_PUBLIC_ROUTE, [context.getHandler(), context.getClass()], - ) + ); const isTenantAgnostic = this.reflector.getAllAndOverride( IS_TENANT_AGNOSTIC, [context.getHandler(), context.getClass()], @@ -36,7 +38,7 @@ export class TenancyGlobalGuard implements CanActivate { if (isPublic || isTenantAgnostic) { return true; } - if (!organizationId) { + if (!isEmpty(authorization) && !organizationId) { throw new UnauthorizedException('Organization ID is required.'); } return true;