Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/admin/admin.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const mockConfig: Record<string, unknown> = {
searchSamples: true,
sftpHost: "login.esss.dk",
sourceFolder: "/data/ess",
maxFileUploadSizeInMb: "12mb",
maxDirectDownloadSize: 5000000000,
maxFileSizeWarning:
"Some files are above <maxDirectDownloadSize> and cannot be downloaded directly. These file can be downloaded via sftp host: <sftpHost> in directory: <sourceFolder>",
Expand Down Expand Up @@ -92,7 +93,6 @@ describe("AdminService", () => {
const mockConfigService = {
get: jest.fn((propertyPath: string) => {
const config = {
maxFileUploadSizeInMb: "12mb",
frontendConfig: mockConfig,
frontendTheme: mockTheme,
} as Record<string, unknown>;
Expand Down Expand Up @@ -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);
});
});

Expand Down
21 changes: 3 additions & 18 deletions src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,15 @@ export class AdminService {
constructor(private configService: ConfigService) {}

async getConfig(): Promise<Record<string, unknown> | null> {
const modifiedConfig = this.applyBackendConfigAdjustments();
const config =
this.configService.get<Record<string, unknown>>("frontendConfig") || null;

return modifiedConfig;
return config;
}

async getTheme(): Promise<Record<string, unknown> | null> {
const theme =
this.configService.get<Record<string, unknown>>("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<string, unknown> | null {
const config =
this.configService.get<Record<string, unknown>>("frontendConfig") || null;
if (!config) {
return null;
}
const postEncodedMaxFileUploadSize =
this.configService.get<string>("maxFileUploadSizeInMb") || "16mb";
return {
...config,
maxFileUploadSizeInMb: postEncodedMaxFileUploadSize,
};
}
}
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "./runtime-config/runtime-config.module";

@Module({
imports: [
Expand All @@ -50,6 +51,7 @@ import { MaskSensitiveDataInterceptorModule } from "./common/interceptors/mask-s
isGlobal: true,
cache: true,
}),
RuntimeConfigModule,
AuthModule,
CaslModule,
AttachmentsModule,
Expand Down
23 changes: 23 additions & 0 deletions src/casl/casl-ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/runtime-config/schemas/runtime-config.schema";

type Subjects =
| string
Expand All @@ -49,6 +50,7 @@ type Subjects =
| typeof UserSettings
| typeof ElasticSearchActions
| typeof Datablock
| typeof RuntimeConfig
>
| "all";
type PossibleAbilities = [Action, Subjects];
Expand Down Expand Up @@ -84,6 +86,7 @@ export class CaslAbilityFactory {
attachments: this.attachmentEndpointAccess,
history: this.historyEndpointAccess,
datablocks: this.datablockEndpointAccess,
runtimeconfig: this.runtimeConfigEndpointAccess,
};

endpointAccess(endpoint: string, user: JWTUser) {
Expand Down Expand Up @@ -908,6 +911,26 @@ export class CaslAbilityFactory {
item.constructor as ExtractSubjectType<Subjects>,
});
}
runtimeConfigEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);

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<Subjects>,
});
}

policyEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
Expand Down
6 changes: 6 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ const configuration = () => {
);

const config = {
configSyncToDb: {
enabled: boolean(process.env.CONFIG_SYNC_TO_DB_ENABLED || false),
configList: process.env.CONFIG_SYNC_TO_DB_LIST
? process.env.CONFIG_SYNC_TO_DB_LIST.split(",").map((v) => v.trim())
: ["frontendConfig", "frontendTheme"],
},
maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default
versions: {
api: "3",
Expand Down
36 changes: 36 additions & 0 deletions src/runtime-config/dto/runtime-config.dto.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also the fields createdBy, updatedAt, createdAt.

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsOptional, IsObject } from "class-validator";

export class OutputRuntimeConfigDto {
@ApiProperty({
type: String,
description: "Unique config identifier (e.g. 'frontend', 'backend', etc.)",
example: "frontend",
})
@IsString()
_id: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest key for the unique identifier. _id should only be used for the internal mongo document id (which should not be accessible in the API).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to have more than "frontend" and "backend"? I would make this an enum. Or are you thinking there might be use cases where other microservices store most of their configs on the scicat backend?

Copy link
Member Author

@Junjiequan Junjiequan Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_id should only be used for the internal mongo document id (which should not be accessible in the API).

I'm not sure about this statement.
await this.datablocksService.findOne({ where: { _id: id }, }); we have been implementing query like this in many places, I don't see what's the cons here..

Does it make sense to have more than "frontend" and "backend"? I would make this an enum. Or are you thinking there might be use cases where other microservices store most of their configs on the scicat backend?

We don’t want to lock it to just “frontend” or “backend”.
The idea is to let operators store any JSON config they want, and the backend shouldnt restrict it. They decide what config files they want to use, not us. It could be frontend-config, published-data-schema.json, proposal-ui-config etc

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest just id as we ill store the whole configuration in one document... or at least, that was the idea last time we brainstormed.


@ApiProperty({
type: Object,
description: "Configuration content as a JSON object",
})
@IsObject()
data: Record<string, unknown>;

@ApiProperty({
type: String,
required: false,
description: "Optional description of this configuration entry",
})
@IsOptional()
@IsString()
description?: string;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the use of this? Just internal documentation?

Copy link
Member

@nitrosx nitrosx Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the idea.
If a site admin uses this subsystem to store additional configurations, we should also have a name and a description for management purposes.


@ApiProperty({
type: String,
description: "User or system that last updated the configuration",
example: "system",
})
@IsString()
updatedBy: string;
}
65 changes: 65 additions & 0 deletions src/runtime-config/runtime-config.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
Body,
Controller,
Get,
Param,
Put,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiBody,
ApiOkResponse,
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/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";
@ApiBearerAuth()
@ApiTags("runtime-config")
@Controller("runtime-config")
export class RuntimeConfigController {
constructor(private readonly runtimeConfigService: RuntimeConfigService) {}

@AllowAny()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs the swagger descriptors (ApiOperation, ApiParam, etc)

@ApiOkResponse({ type: OutputRuntimeConfigDto })
@Get("data/:id")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data strikes me as a surprising choice for the endpoint path. I see a close link between this functionality and the /admin/config endpoint. Maybe it could just be GET/PUT/PATCH /admin/config/:id? And there should be a way to get either the merged configuration or just the overridden values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not remember why we decided to move it away from admin/config.
If I remember correctly, the reason for it is that this subsystem can become a store for additional blobs besides configurations. In our mind admin/config relates only to admin users, which is true for updating but not for retrieving.
Hopefully it makes sense, but I will be happy to discuss further

async getConfig(
@Param("id") id: string,
): Promise<OutputRuntimeConfigDto | null> {
const config = await this.runtimeConfigService.getConfig(id);

return config;
}

@UseGuards(PoliciesGuard)
@CheckPolicies("runtimeconfig", (ability: AppAbility) =>
ability.can(Action.Update, RuntimeConfig),
)
@Put("data/:id")
@ApiBody({
type: Object,
description: "Runtime config object",
})
@ApiOkResponse({ type: OutputRuntimeConfigDto })
async updateConfig(
@Req() request: Request,
@Param("id") id: string,
@Body() config: Record<string, unknown>,
): Promise<OutputRuntimeConfigDto | null> {
const user: JWTUser = request.user as JWTUser;
return await this.runtimeConfigService.updateConfig(
id,
config,
user.username,
);
}
}
45 changes: 45 additions & 0 deletions src/runtime-config/runtime-config.module.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming is perfect if we are going to use this subsystem to store only configurations.

Original file line number Diff line number Diff line change
@@ -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 {}
Loading