diff --git a/docs/developer-guide/runtime-config.md b/docs/developer-guide/runtime-config.md new file mode 100644 index 000000000..e9aef34fa --- /dev/null +++ b/docs/developer-guide/runtime-config.md @@ -0,0 +1,54 @@ +--- +title: Runtime Configuration System Overview +audience: Technical +created_by: Junjie Quan +created_on: 2026-01-08 +--- + +# Runtime Configuration: Technical Documentation + +## Overview + +SciCat frontend supports runtime-editable configuration stored in the backend and fetched dynamically at application startup. + +Configuration is identified by a configuration ID (cid) and accessed via the runtime-config API. + +## Predefined Configuration IDs + +```json +CONFIG_SYNC_TO_DB_LIST="frontendConfig, frontendTheme" +``` + +- When provided, it is parsed as a comma-separated list of configuration IDs. +- When not provided, the system always includes the following defaults: - frontendConfig - frontendTheme + These default configuration IDs are always synchronized to ensure frontend functionality. + +### Runtime Config API + +- `GET /api/v3/runtime-config/:id` + Retrieves the current runtime configuration by `cid`. Response includes: `cid`, `data`, `updatedBy` + +- `PUT /api/v3/runtime-config/:id` + Updates the runtime configuration and persists changes to the database. This endpoint accepts only the data object in the request body and `updatedBy` is automatically set from the user name. + +## Backend Synchronization Flow + +On backend startup: + +1. `CONFIG_SYNC_TO_DB_LIST` is read from environment variables. +2. For each configuration ID: + - If no record exists in the database, a new record is created from the corresponding config file. + - If a record already exists, it is overwritten with values from the config file. +3. After initialization, all changes are managed via the `runtime-config` API. + +## Extensibility + +The system is designed to support additional runtime configurations in the future. + +For any new configuration to be editable via the UI: + +1. The configuration ID must be included in CONFIG_SYNC_TO_DB_LIST +2. A corresponding JSONForms Schema & UI Schema must be provided +3. The schema files must be created in the frontend + +Without schema support, configuration values may exist in the database but will not be editable via the UI. diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 87159e3ae..f78a66667 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -1,8 +1,10 @@ import { Module } from "@nestjs/common"; import { AdminService } from "./admin.service"; import { AdminController } from "./admin.controller"; +import { RuntimeConfigModule } from "src/config/runtime-config/runtime-config.module"; @Module({ + imports: [RuntimeConfigModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index 9791c4331..593936a35 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from "@nestjs/testing"; -import { ConfigService } from "@nestjs/config"; import { AdminService } from "./admin.service"; +import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service"; const mockConfig: Record = { accessTokenPrefix: "Bearer ", @@ -51,6 +51,7 @@ const mockConfig: Record = { searchSamples: true, sftpHost: "login.esss.dk", sourceFolder: "/data/ess", + maxFileUploadSizeInMb: "12mb", maxDirectDownloadSize: 5000000000, maxFileSizeWarning: "Some files are above and cannot be downloaded directly. These file can be downloaded via sftp host: in directory: ", @@ -89,12 +90,11 @@ const mockTheme: Record = { describe("AdminService", () => { let service: AdminService; - const mockConfigService = { - get: jest.fn((propertyPath: string) => { + const mockRuntimeConfigService = { + getConfig: jest.fn((propertyPath: string) => { const config = { - maxFileUploadSizeInMb: "12mb", - frontendConfig: mockConfig, - frontendTheme: mockTheme, + frontendConfig: { cid: "frontendConfig", data: mockConfig }, + frontendTheme: { cid: "frontendTheme", data: mockTheme }, } as Record; return config[propertyPath]; @@ -106,8 +106,8 @@ describe("AdminService", () => { providers: [ AdminService, { - provide: ConfigService, - useValue: mockConfigService, + provide: RuntimeConfigService, + useValue: mockRuntimeConfigService, }, ], }).compile(); @@ -120,13 +120,10 @@ describe("AdminService", () => { }); describe("getConfig", () => { - it("should return modified config", async () => { + it("should return frontend config", async () => { const result = await service.getConfig(); - expect(result).toEqual({ - ...mockConfig, - maxFileUploadSizeInMb: "12mb", - }); + expect(result).toEqual(mockConfig); }); }); diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 4a6bdf4e8..a6b550650 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,35 +1,18 @@ import { Injectable } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { RuntimeConfigService } from "src/config/runtime-config/runtime-config.service"; @Injectable() export class AdminService { - constructor(private configService: ConfigService) {} + constructor(private runtimeConfigService: RuntimeConfigService) {} async getConfig(): Promise | null> { - const modifiedConfig = this.applyBackendConfigAdjustments(); + const config = await this.runtimeConfigService.getConfig("frontendConfig"); - return modifiedConfig; + return config?.data || null; } async getTheme(): Promise | null> { - const theme = - this.configService.get>("frontendTheme") || null; - return theme; - } - - // NOTE: Adjusts backend config values for frontend use (e.g., file upload limits). - // Add future backend-dependent adjustments here as needed. - private applyBackendConfigAdjustments(): Record | null { - const config = - this.configService.get>("frontendConfig") || null; - if (!config) { - return null; - } - const postEncodedMaxFileUploadSize = - this.configService.get("maxFileUploadSizeInMb") || "16mb"; - return { - ...config, - maxFileUploadSizeInMb: postEncodedMaxFileUploadSize, - }; + const theme = await this.runtimeConfigService.getConfig("frontendTheme"); + return theme?.data || null; } } diff --git a/src/app.module.ts b/src/app.module.ts index 20ec459ab..054d8066a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -42,6 +42,7 @@ import { } from "./common/schemas/generic-history.schema"; import { HistoryModule } from "./history/history.module"; import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-sensitive-data.interceptor"; +import { RuntimeConfigModule } from "./config/runtime-config/runtime-config.module"; @Module({ imports: [ @@ -50,6 +51,7 @@ import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-s isGlobal: true, cache: true, }), + RuntimeConfigModule, AuthModule, CaslModule, AttachmentsModule, diff --git a/src/attachments/attachments.v4.controller.spec.ts b/src/attachments/attachments.v4.controller.spec.ts index 1a8ec4844..df06a21e7 100644 --- a/src/attachments/attachments.v4.controller.spec.ts +++ b/src/attachments/attachments.v4.controller.spec.ts @@ -41,7 +41,6 @@ describe("AttachmentsController - findOneAndUpdate", () => { providers: [ { provide: AttachmentsV4Service, - useValue: mockAttachmentsV4Service, useValue: { findOneAndUpdate: jest .fn() diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index 1b8c0b7cb..ed6b95789 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -29,6 +29,7 @@ import { UserIdentity } from "src/users/schemas/user-identity.schema"; import { UserSettings } from "src/users/schemas/user-settings.schema"; import { User } from "src/users/schemas/user.schema"; import { Action } from "./action.enum"; +import { RuntimeConfig } from "src/config/runtime-config/schemas/runtime-config.schema"; import { accessibleBy } from "@casl/mongoose"; type Subjects = @@ -50,6 +51,7 @@ type Subjects = | typeof UserSettings | typeof ElasticSearchActions | typeof Datablock + | typeof RuntimeConfig > | "all"; type PossibleAbilities = [Action, Subjects]; @@ -85,6 +87,7 @@ export class CaslAbilityFactory { attachments: this.attachmentEndpointAccess, history: this.historyEndpointAccess, datablocks: this.datablockEndpointAccess, + runtimeconfig: this.runtimeConfigEndpointAccess, }; endpointAccess(endpoint: string, user: JWTUser) { @@ -911,6 +914,26 @@ export class CaslAbilityFactory { item.constructor as ExtractSubjectType, }); } + runtimeConfigEndpointAccess(user: JWTUser) { + const { can, build } = new AbilityBuilder( + createMongoAbility, + ); + + can(Action.Read, RuntimeConfig); + if ( + user && + user.currentGroups.some((g) => this.accessGroups?.admin.includes(g)) + ) { + /* + / user that belongs to any of the group listed in ADMIN_GROUPS + */ + can(Action.Update, RuntimeConfig); + } + return build({ + detectSubjectType: (item) => + item.constructor as ExtractSubjectType, + }); + } policyEndpointAccess(user: JWTUser) { const { can, build } = new AbilityBuilder( diff --git a/src/config/configuration.ts b/src/config/configuration.ts index b613033ef..1115bfbc1 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -193,6 +193,19 @@ const configuration = () => { ); const config = { + configSyncToDb: { + configList: process.env.CONFIG_SYNC_TO_DB_LIST + ? [ + ...new Set([ + "frontendConfig", + "frontendTheme", + ...(process.env.CONFIG_SYNC_TO_DB_LIST?.split(",").map((v) => + v.trim(), + ) ?? []), + ]), + ] // Always include frontendConfig and frontendTheme + : ["frontendConfig", "frontendTheme"], + }, maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default versions: { api: "3", diff --git a/src/config/runtime-config/dto/output-runtime-config.dto.ts b/src/config/runtime-config/dto/output-runtime-config.dto.ts new file mode 100644 index 000000000..f8736cc7a --- /dev/null +++ b/src/config/runtime-config/dto/output-runtime-config.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsObject, IsDateString } from "class-validator"; + +export class OutputRuntimeConfigDto { + /** + * Unique config identifier (e.g. 'frontendConfig', 'frontendTheme', etc.) + */ + @IsString() + cid: string; + + /** + * Configuration content as a JSON object + */ + @IsObject() + data: Record; + + /** + * User or system that last updated the configuration + */ + @IsString() + updatedBy: string; + + /** + * User or system that created the configuration + */ + @IsString() + createdBy: string; + + /** + * Date/time when the configuration was created. ISO 8601 format. + */ + @IsDateString() + createdAt: Date; + + /** + * Date/time when the configuration was last updated. ISO 8601 format. + */ + @IsDateString() + updatedAt: Date; +} diff --git a/src/config/runtime-config/dto/update-runtime-config.dto.ts b/src/config/runtime-config/dto/update-runtime-config.dto.ts new file mode 100644 index 000000000..6321f095f --- /dev/null +++ b/src/config/runtime-config/dto/update-runtime-config.dto.ts @@ -0,0 +1,9 @@ +import { IsObject } from "class-validator"; + +export class UpdateRuntimeConfigDto { + /** + * Configuration content as a JSON object + */ + @IsObject() + data: Record; +} diff --git a/src/config/runtime-config/runtime-config.controller.spec.ts b/src/config/runtime-config/runtime-config.controller.spec.ts new file mode 100644 index 000000000..cd360aba2 --- /dev/null +++ b/src/config/runtime-config/runtime-config.controller.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { RuntimeConfigController } from "./runtime-config.controller"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { NotFoundException } from "@nestjs/common"; +import { Request } from "express"; +import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; + +class RuntimeConfigServiceMock { + getConfig = jest.fn(); + updateConfig = jest.fn(); +} + +class CaslAbilityFactoryMock {} + +describe("RuntimeConfigController", () => { + let controller: RuntimeConfigController; + let service: RuntimeConfigServiceMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RuntimeConfigController], + providers: [ + { provide: RuntimeConfigService, useClass: RuntimeConfigServiceMock }, + { provide: CaslAbilityFactory, useClass: CaslAbilityFactoryMock }, + ], + }).compile(); + + controller = await module.resolve( + RuntimeConfigController, + ); + service = module.get(RuntimeConfigService); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("getConfig", () => { + it("should return config when found", async () => { + const cfg = { cid: "frontendConfig", data: { a: 1 } }; + service.getConfig.mockResolvedValue(cfg); + + const res = await controller.getConfig("frontendConfig"); + expect(res).toEqual(cfg); + expect(service.getConfig).toHaveBeenCalledWith("frontendConfig"); + }); + + it("should throw NotFoundException when missing", async () => { + service.getConfig.mockRejectedValue(new NotFoundException("not found")); + + await expect(controller.getConfig("missing")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("updateConfig", () => { + it("should call service.updateConfig with user from request", async () => { + const cfg = { cid: "frontendConfig", data: { a: 2 } }; + service.updateConfig.mockResolvedValue(cfg); + + const mockReq = { + user: { username: "adminIngestor" }, + } as unknown as Request; + + const dto = { data: { a: 2 } }; + + const res = await controller.updateConfig(mockReq, "frontendConfig", dto); + + expect(res).toEqual(cfg); + expect(service.updateConfig).toHaveBeenCalledWith( + "frontendConfig", + dto, + mockReq.user, + ); + }); + + it("should throw NotFoundException from service when config id does not exist", async () => { + service.updateConfig.mockRejectedValue( + new NotFoundException("not found"), + ); + + const mockReq = { + user: { username: "adminIngestor" }, + } as unknown as Request; + + await expect( + controller.updateConfig(mockReq, "missing", { data: {} }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/config/runtime-config/runtime-config.controller.ts b/src/config/runtime-config/runtime-config.controller.ts new file mode 100644 index 000000000..fb13da0af --- /dev/null +++ b/src/config/runtime-config/runtime-config.controller.ts @@ -0,0 +1,86 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Put, + Req, + UseGuards, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { Request } from "express"; +import { AllowAny } from "src/auth/decorators/allow-any.decorator"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { OutputRuntimeConfigDto } from "./dto/output-runtime-config.dto"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { PoliciesGuard } from "src/casl/guards/policies.guard"; +import { Action } from "src/casl/action.enum"; +import { AppAbility } from "src/casl/casl-ability.factory"; +import { CheckPolicies } from "src/casl/decorators/check-policies.decorator"; +import { RuntimeConfig } from "./schemas/runtime-config.schema"; +import { UpdateRuntimeConfigDto } from "./dto/update-runtime-config.dto"; +@ApiBearerAuth() +@ApiTags("runtime configurations") +@Controller("runtime-config") +export class RuntimeConfigController { + constructor(private readonly runtimeConfigService: RuntimeConfigService) {} + + @AllowAny() + @ApiParam({ + name: "id", + description: "Runtime config cid (e.g. frontendConfig, frontendTheme)", + type: String, + }) + @ApiResponse({ status: HttpStatus.OK, type: OutputRuntimeConfigDto }) + @ApiOperation({ summary: "Get runtime configuration by cid" }) + @ApiOkResponse({ type: OutputRuntimeConfigDto }) + @ApiNotFoundResponse({ description: "Config ':id' not found" }) + @Get(":id") + async getConfig( + @Param("id") cid: string, + ): Promise { + const config = await this.runtimeConfigService.getConfig(cid); + + return config; + } + + @UseGuards(PoliciesGuard) + @CheckPolicies("runtimeconfig", (ability: AppAbility) => + ability.can(Action.Update, RuntimeConfig), + ) + @Put(":id") + @ApiParam({ + name: "id", + description: "Runtime config cid (e.g. frontendConfig, frontendTheme)", + schema: { type: "string" }, + }) + @ApiBody({ + type: Object, + description: "Runtime config object", + }) + @ApiOkResponse({ type: OutputRuntimeConfigDto }) + @ApiNotFoundResponse({ description: "Config ':id' not found" }) + @ApiOperation({ summary: "Overwrite runtime configuration by cid" }) + async updateConfig( + @Req() request: Request, + @Param("id") cid: string, + @Body() updateRuntimeConfigDto: UpdateRuntimeConfigDto, + ): Promise { + const user: JWTUser = request.user as JWTUser; + return await this.runtimeConfigService.updateConfig( + cid, + updateRuntimeConfigDto, + user, + ); + } +} diff --git a/src/config/runtime-config/runtime-config.module.ts b/src/config/runtime-config/runtime-config.module.ts new file mode 100644 index 000000000..8bc063b71 --- /dev/null +++ b/src/config/runtime-config/runtime-config.module.ts @@ -0,0 +1,45 @@ +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { RuntimeConfigController } from "./runtime-config.controller"; +import { + RuntimeConfig, + RuntimeConfigSchema, +} from "./schemas/runtime-config.schema"; +import { + GenericHistory, + GenericHistorySchema, +} from "src/common/schemas/generic-history.schema"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { CaslModule } from "src/casl/casl.module"; +import { applyHistoryPluginOnce } from "src/common/mongoose/plugins/history.plugin.util"; + +@Module({ + imports: [ + CaslModule, + ConfigModule, + MongooseModule.forFeature([ + { + name: GenericHistory.name, + schema: GenericHistorySchema, + }, + ]), + MongooseModule.forFeatureAsync([ + { + name: RuntimeConfig.name, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const schema = RuntimeConfigSchema; + applyHistoryPluginOnce(schema, configService); + + return schema; + }, + }, + ]), + ], + controllers: [RuntimeConfigController], + providers: [RuntimeConfigService], + exports: [RuntimeConfigService], +}) +export class RuntimeConfigModule {} diff --git a/src/config/runtime-config/runtime-config.service.spec.ts b/src/config/runtime-config/runtime-config.service.spec.ts new file mode 100644 index 000000000..148b03394 --- /dev/null +++ b/src/config/runtime-config/runtime-config.service.spec.ts @@ -0,0 +1,154 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { getModelToken } from "@nestjs/mongoose"; +import { ConfigService } from "@nestjs/config"; +import { RuntimeConfigService } from "./runtime-config.service"; +import { RuntimeConfig } from "./schemas/runtime-config.schema"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; + +class ConfigServiceMock { + get = jest.fn(); +} + +class RuntimeConfigModelMock { + findOne = jest.fn(); + findOneAndUpdate = jest.fn(); + updateOne = jest.fn(); + create = jest.fn(); +} + +describe("RuntimeConfigService", () => { + let service: RuntimeConfigService; + let configService: ConfigServiceMock; + let model: RuntimeConfigModelMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RuntimeConfigService, + { provide: ConfigService, useClass: ConfigServiceMock }, + { + provide: getModelToken(RuntimeConfig.name), + useClass: RuntimeConfigModelMock, + }, + ], + }).compile(); + + service = module.get(RuntimeConfigService); + configService = module.get(ConfigService); + model = module.get(getModelToken(RuntimeConfig.name)); + }); + + afterEach(() => jest.clearAllMocks()); + + describe("getConfig", () => { + it("returns config when found", async () => { + model.findOne.mockReturnValue({ + lean: () => ({ cid: "c1", data: { a: 1 } }), + }); + + await expect(service.getConfig("c1")).resolves.toEqual({ + cid: "c1", + data: { a: 1 }, + }); + expect(model.findOne).toHaveBeenCalledWith({ cid: "c1" }); + }); + + it("throws NotFoundException when missing", async () => { + model.findOne.mockReturnValue({ lean: () => null }); + + await expect(service.getConfig("missing")).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe("updateConfig", () => { + it("updates and returns updated doc", async () => { + const updated = { cid: "c1", data: { a: 2 }, updatedBy: "admin" }; + model.findOneAndUpdate.mockResolvedValue(updated); + + const dto = { data: { a: 2 } }; + const user = { username: "admin" }; + const res = await service.updateConfig("c1", dto, user as JWTUser); + + expect(res).toEqual(updated); + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + { cid: "c1" }, + { + $set: expect.objectContaining({ data: { a: 2 }, updatedBy: "admin" }), + }, + { new: true }, + ); + }); + + it("throws NotFoundException if cid not found", async () => { + model.findOneAndUpdate.mockResolvedValue(null); + + await expect( + service.updateConfig("missing", { data: {} }, { + username: "admin", + } as JWTUser), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("syncConfig", () => { + it("skips when config is empty/missing", async () => { + configService.get.mockReturnValue({}); + + await service.syncConfig("frontendConfig"); + + expect(model.findOne).not.toHaveBeenCalled(); + expect(model.create).not.toHaveBeenCalled(); + expect(model.updateOne).not.toHaveBeenCalled(); + }); + + it("creates entry if not existing", async () => { + const source = { foo: "bar" }; + configService.get.mockReturnValue(source); + model.findOne.mockReturnValue({ lean: () => null }); + model.create.mockResolvedValue({ cid: "x" }); + + await service.syncConfig("frontendConfig"); + + expect(model.create).toHaveBeenCalledWith( + expect.objectContaining({ + cid: "frontendConfig", + data: source, + createdBy: "system", + updatedBy: "system", + }), + ); + }); + + it("overwrites entry if existing", async () => { + const source = { foo: "bar" }; + configService.get.mockReturnValue(source); + model.findOne.mockReturnValue({ + lean: () => ({ cid: "frontendConfig" }), + }); + model.updateOne.mockResolvedValue({ acknowledged: true }); + + await service.syncConfig("frontendConfig"); + + expect(model.updateOne).toHaveBeenCalledWith( + { cid: "frontendConfig" }, + { data: source, updatedBy: "system" }, + ); + }); + }); + + describe("onModuleInit", () => { + it("syncs each configId from configList", async () => { + configService.get.mockReturnValue(["a", "b"]); + const spy = jest.spyOn(service, "syncConfig").mockResolvedValue(); + + await service.onModuleInit(); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledWith("a"); + expect(spy).toHaveBeenCalledWith("b"); + }); + }); +}); diff --git a/src/config/runtime-config/runtime-config.service.ts b/src/config/runtime-config/runtime-config.service.ts new file mode 100644 index 000000000..3b1ad8bd0 --- /dev/null +++ b/src/config/runtime-config/runtime-config.service.ts @@ -0,0 +1,108 @@ +import { + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { InjectModel } from "@nestjs/mongoose"; +import { Model } from "mongoose"; +import { OutputRuntimeConfigDto } from "./dto/output-runtime-config.dto"; +import { JWTUser } from "src/auth/interfaces/jwt-user.interface"; +import { + RuntimeConfig, + RuntimeConfigDocument, +} from "./schemas/runtime-config.schema"; +import { addCreatedByFields, addUpdatedByField } from "src/common/utils"; +import { UpdateRuntimeConfigDto } from "./dto/update-runtime-config.dto"; + +@Injectable() +export class RuntimeConfigService implements OnModuleInit { + constructor( + private configService: ConfigService, + @InjectModel(RuntimeConfig.name) + private runtimeConfigModel: Model, + ) {} + + async onModuleInit() { + const configList: string[] = this.configService.get( + "configSyncToDb.configList", + )!; + for (const configId of configList) await this.syncConfig(configId); + } + + async getConfig(cid: string): Promise { + const data = await this.runtimeConfigModel.findOne({ cid: cid }).lean(); + + if (!data) { + throw new NotFoundException(`Config '${cid}' not found`); + } + return data; + } + + async updateConfig( + cid: string, + updateRuntimeConfigDto: UpdateRuntimeConfigDto, + user: JWTUser, + ): Promise { + const updateData = addUpdatedByField(updateRuntimeConfigDto, user.username); + + const updatedDoc = await this.runtimeConfigModel.findOneAndUpdate( + { cid: cid }, + { $set: { ...updateData } }, + { new: true }, + ); + + if (!updatedDoc) { + throw new NotFoundException(`Config '${cid}' not found`); + } + Logger.log( + `Updated app config entry '${cid}' by user '${updateData.updatedBy}'`, + "RuntimeConfigService", + ); + + return updatedDoc; + } + + async syncConfig(configId: string): Promise { + const sourceConfig = + this.configService.get>(configId) || {}; + + if (!sourceConfig || Object.keys(sourceConfig).length === 0) { + Logger.error( + `Config file: ${configId} is empty or missing, skipping sync`, + "RuntimeConfigService", + ); + return; + } + + const existing = await this.runtimeConfigModel + .findOne({ cid: configId }) + .lean(); + + // If no existing config, create one from config file + if (!existing) { + const createData = addCreatedByFields( + { cid: configId, data: sourceConfig }, + "system", + ); + await this.runtimeConfigModel.create({ ...createData }); + Logger.log( + `Created runtime config entry: '${configId}' with config file`, + "RuntimeConfigService", + ); + return; + } + + // overwrite existing config with config file + await this.runtimeConfigModel.updateOne( + { cid: configId }, + { data: sourceConfig, updatedBy: "system" }, + ); + + Logger.log( + `RuntimeConfigService - [${configId}] synchronized with config file`, + "RuntimeConfigService", + ); + } +} diff --git a/src/config/runtime-config/schemas/runtime-config.schema.ts b/src/config/runtime-config/schemas/runtime-config.schema.ts new file mode 100644 index 000000000..b56373521 --- /dev/null +++ b/src/config/runtime-config/schemas/runtime-config.schema.ts @@ -0,0 +1,28 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { ApiProperty } from "@nestjs/swagger"; +import { Document } from "mongoose"; +import { QueryableClass } from "src/common/schemas/queryable.schema"; + +export type RuntimeConfigDocument = RuntimeConfig & Document; + +@Schema({ + collection: "RuntimeConfig", + timestamps: true, +}) +export class RuntimeConfig extends QueryableClass { + @ApiProperty({ + type: String, + description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)", + }) + @Prop({ type: String, unique: true, required: true, index: true }) + cid: string; + + @ApiProperty({ + type: Object, + description: "Configuration data stored as JSON", + }) + @Prop({ type: Object, required: true, default: {} }) + data: Record; +} + +export const RuntimeConfigSchema = SchemaFactory.createForClass(RuntimeConfig); diff --git a/test/RuntimeConfig.js b/test/RuntimeConfig.js new file mode 100644 index 000000000..cb007c03a --- /dev/null +++ b/test/RuntimeConfig.js @@ -0,0 +1,53 @@ +"use strict"; +const utils = require("./LoginUtils"); +const { TestData } = require("./TestData"); + +let accessTokenAdmin = null; +let accessTokenUser1 = null; + +describe("RuntimeConfig ACL", () => { + const cid = "frontendConfig"; + + before(async () => { + accessTokenAdmin = await utils.getToken(appUrl, { + username: "adminIngestor", + password: TestData.Accounts["adminIngestor"]["password"], + }); + + accessTokenUser1 = await utils.getToken(appUrl, { + username: "user1", + password: TestData.Accounts["user1"]["password"], + }); + }); + + it("should allow any user to fetch config", async () => { + return request(appUrl) + .get(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenUser1}` }) + .expect(TestData.SuccessfulGetStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("cid").and.be.equal(cid); + res.body.should.have.property("data"); + }); + }); + + it("should forbid non-admin user to update config", async () => { + return request(appUrl) + .put(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .send({ data: { test: true } }) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenUser1}` }) + .expect(TestData.AccessForbiddenStatusCode); + }); + + it("should allow admin to update config", async () => { + return request(appUrl) + .put(`/api/v3/runtime-config/${encodeURIComponent(cid)}`) + .send({ data: { test: true } }) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenAdmin}` }) + .expect(TestData.SuccessfulPatchStatusCode); + }); +});