Skip to content

Commit 2d3dd83

Browse files
committed
feat(config-service): add centralized configuration service
1 parent 63d7779 commit 2d3dd83

14 files changed

Lines changed: 8927 additions & 0 deletions

microservices/config-service/package-lock.json

Lines changed: 8346 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { IsBoolean, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
2+
import { ConfigKind } from '../entities';
3+
4+
export class CreateEnvironmentDto {
5+
@IsString()
6+
@IsNotEmpty()
7+
@MaxLength(50)
8+
name: string;
9+
10+
@IsOptional()
11+
@IsString()
12+
description?: string;
13+
}
14+
15+
export class UpsertConfigDto {
16+
@IsNotEmpty()
17+
value: unknown;
18+
19+
@IsOptional()
20+
@IsEnum(ConfigKind)
21+
kind?: ConfigKind;
22+
23+
@IsOptional()
24+
@IsString()
25+
description?: string;
26+
}
27+
28+
export class UpsertSecretDto {
29+
@IsString()
30+
@IsNotEmpty()
31+
value: string;
32+
}
33+
34+
export class RegisterWebhookDto {
35+
@IsString()
36+
@IsNotEmpty()
37+
service: string;
38+
39+
@IsString()
40+
@IsNotEmpty()
41+
environment: string;
42+
43+
@IsUrl({ require_tld: false })
44+
url: string;
45+
46+
@IsOptional()
47+
@IsString()
48+
signingSecret?: string;
49+
}
50+
51+
export class SetWebhookStateDto {
52+
@IsBoolean()
53+
active: boolean;
54+
}
55+
56+
export class RollbackDto {
57+
@IsString()
58+
@IsNotEmpty()
59+
actor: string;
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
2+
3+
@Entity('config_versions')
4+
@Index(['resourceType', 'resourceId', 'version'])
5+
export class ConfigVersion {
6+
@PrimaryGeneratedColumn('uuid')
7+
id: string;
8+
9+
@Column({ length: 20 })
10+
resourceType: 'config' | 'secret';
11+
12+
@Column('uuid')
13+
resourceId: string;
14+
15+
@Column()
16+
version: number;
17+
18+
@Column({ type: 'jsonb' })
19+
snapshot: Record<string, unknown>;
20+
21+
@Column({ nullable: true })
22+
changedBy?: string;
23+
24+
@CreateDateColumn()
25+
createdAt: Date;
26+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
2+
3+
@Entity('config_webhooks')
4+
@Index(['service', 'environment'])
5+
export class Webhook {
6+
@PrimaryGeneratedColumn('uuid')
7+
id: string;
8+
9+
@Column({ length: 100 })
10+
service: string;
11+
12+
@Column({ length: 50 })
13+
environment: string;
14+
15+
@Column()
16+
url: string;
17+
18+
@Column({ nullable: true, select: false })
19+
signingSecret?: string;
20+
21+
@Column({ default: true })
22+
active: boolean;
23+
24+
@CreateDateColumn()
25+
createdAt: Date;
26+
27+
@UpdateDateColumn()
28+
updatedAt: Date;
29+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
import { timingSafeEqual } from 'crypto';
4+
5+
@Injectable()
6+
export class ApiKeyGuard implements CanActivate {
7+
constructor(private readonly config: ConfigService) {}
8+
9+
canActivate(context: ExecutionContext): boolean {
10+
const expected = this.config.get<string>('CONFIG_API_KEY') || (this.config.get('NODE_ENV') === 'production' ? '' : 'dev-config-key');
11+
const supplied = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined> }>().headers['x-config-api-key'] || '';
12+
const expectedBuffer = Buffer.from(expected);
13+
const suppliedBuffer = Buffer.from(supplied);
14+
if (!expected || expectedBuffer.length !== suppliedBuffer.length || !timingSafeEqual(expectedBuffer, suppliedBuffer)) {
15+
throw new UnauthorizedException('A valid x-config-api-key header is required');
16+
}
17+
return true;
18+
}
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { AuditLog } from '../entities';
5+
6+
@Injectable()
7+
export class AuditService {
8+
constructor(@InjectRepository(AuditLog) private readonly repository: Repository<AuditLog>) {}
9+
10+
record(entry: Partial<AuditLog>): Promise<AuditLog> {
11+
return this.repository.save(this.repository.create(entry));
12+
}
13+
14+
list(service?: string, environment?: string, limit = 100): Promise<AuditLog[]> {
15+
return this.repository.find({
16+
where: { ...(service ? { service } : {}), ...(environment ? { environment } : {}) },
17+
order: { createdAt: 'DESC' },
18+
take: Math.min(Math.max(limit, 1), 500),
19+
});
20+
}
21+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { CacheService } from './cache.service';
3+
4+
describe('CacheService', () => {
5+
it('stores and invalidates config by prefix', () => {
6+
const cache = new CacheService(new ConfigService({ CACHE_TTL_SECONDS: 60 }));
7+
cache.set('config:api:dev', { enabled: true });
8+
expect(cache.get('config:api:dev')).toEqual({ enabled: true });
9+
cache.deleteByPrefix('config:api');
10+
expect(cache.get('config:api:dev')).toBeUndefined();
11+
});
12+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ConfigService as NestConfigService } from '@nestjs/config';
3+
4+
interface CacheItem<T> {
5+
value: T;
6+
expiresAt: number;
7+
}
8+
9+
@Injectable()
10+
export class CacheService {
11+
private readonly entries = new Map<string, CacheItem<unknown>>();
12+
private readonly ttlMs: number;
13+
14+
constructor(config: NestConfigService) {
15+
this.ttlMs = Math.max(1, config.get<number>('CACHE_TTL_SECONDS', 60)) * 1000;
16+
}
17+
18+
get<T>(key: string): T | undefined {
19+
const item = this.entries.get(key);
20+
if (!item) return undefined;
21+
if (item.expiresAt <= Date.now()) {
22+
this.entries.delete(key);
23+
return undefined;
24+
}
25+
return item.value as T;
26+
}
27+
28+
set<T>(key: string, value: T): void {
29+
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
30+
}
31+
32+
deleteByPrefix(prefix: string): void {
33+
for (const key of this.entries.keys()) {
34+
if (key.startsWith(prefix)) this.entries.delete(key);
35+
}
36+
}
37+
38+
clear(): void {
39+
this.entries.clear();
40+
}
41+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Injectable, NotFoundException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { UpsertConfigDto } from '../dto/config.dto';
5+
import { ConfigEntry, ConfigKind, ConfigVersion } from '../entities';
6+
import { AuditService } from './audit.service';
7+
import { CacheService } from './cache.service';
8+
import { EnvironmentService } from './environment.service';
9+
import { WebhookService } from './webhook.service';
10+
11+
export interface ResolvedConfiguration {
12+
service: string;
13+
environment: string;
14+
values: Record<string, unknown>;
15+
featureFlags: Record<string, unknown>;
16+
versions: Record<string, number>;
17+
fetchedAt: string;
18+
}
19+
20+
@Injectable()
21+
export class ConfigurationService {
22+
constructor(
23+
@InjectRepository(ConfigEntry) private readonly repository: Repository<ConfigEntry>,
24+
@InjectRepository(ConfigVersion) private readonly versionRepository: Repository<ConfigVersion>,
25+
private readonly environments: EnvironmentService,
26+
private readonly cache: CacheService,
27+
private readonly audit: AuditService,
28+
private readonly webhooks: WebhookService,
29+
) {}
30+
31+
async resolve(service: string, environmentName: string): Promise<ResolvedConfiguration> {
32+
const cacheKey = this.cacheKey(service, environmentName);
33+
const cached = this.cache.get<ResolvedConfiguration>(cacheKey);
34+
if (cached) return cached;
35+
const environment = await this.environments.get(environmentName);
36+
const entries = await this.repository.find({ where: { service, environment: { id: environment.id } }, order: { key: 'ASC' } });
37+
const result: ResolvedConfiguration = {
38+
service,
39+
environment: environment.name,
40+
values: {},
41+
featureFlags: {},
42+
versions: {},
43+
fetchedAt: new Date().toISOString(),
44+
};
45+
for (const entry of entries) {
46+
const target = entry.kind === ConfigKind.FEATURE_FLAG ? result.featureFlags : result.values;
47+
target[entry.key] = entry.value;
48+
result.versions[entry.key] = entry.version;
49+
}
50+
this.cache.set(cacheKey, result);
51+
return result;
52+
}
53+
54+
async upsert(service: string, environmentName: string, key: string, dto: UpsertConfigDto, actor: string): Promise<ConfigEntry> {
55+
const environment = await this.environments.get(environmentName);
56+
let entry = await this.repository.findOne({ where: { service, environment: { id: environment.id }, key } });
57+
if (entry) {
58+
entry.value = dto.value;
59+
entry.kind = dto.kind ?? entry.kind;
60+
entry.description = dto.description ?? entry.description;
61+
entry.version += 1;
62+
} else {
63+
entry = this.repository.create({ service, environment, key, value: dto.value, kind: dto.kind ?? ConfigKind.SETTING, description: dto.description });
64+
}
65+
const saved = await this.repository.save(entry);
66+
await this.versionRepository.save(this.versionRepository.create({
67+
resourceType: 'config', resourceId: saved.id, version: saved.version,
68+
snapshot: { value: saved.value, kind: saved.kind, description: saved.description }, changedBy: actor,
69+
}));
70+
this.invalidate(service, environment.name);
71+
await this.audit.record({ action: entry.version === 1 ? 'create' : 'update', resourceType: 'config', resourceId: saved.id, actor, service, environment: environment.name, metadata: { key, version: saved.version } });
72+
void this.webhooks.publish({ event: 'config.updated', service, environment: environment.name, key, version: saved.version, occurredAt: new Date().toISOString() });
73+
return saved;
74+
}
75+
76+
async remove(service: string, environmentName: string, key: string, actor: string): Promise<void> {
77+
const environment = await this.environments.get(environmentName);
78+
const entry = await this.repository.findOne({ where: { service, environment: { id: environment.id }, key } });
79+
if (!entry) throw new NotFoundException(`Configuration ${key} not found`);
80+
await this.repository.remove(entry);
81+
this.invalidate(service, environment.name);
82+
await this.audit.record({ action: 'delete', resourceType: 'config', resourceId: entry.id, actor, service, environment: environment.name, metadata: { key, version: entry.version } });
83+
void this.webhooks.publish({ event: 'config.deleted', service, environment: environment.name, key, version: entry.version, occurredAt: new Date().toISOString() });
84+
}
85+
86+
listVersions(resourceId: string): Promise<ConfigVersion[]> {
87+
return this.versionRepository.find({ where: { resourceId }, order: { version: 'DESC' } });
88+
}
89+
90+
async rollback(resourceId: string, version: number, actor: string): Promise<ConfigEntry> {
91+
const entry = await this.repository.findOne({ where: { id: resourceId } });
92+
if (!entry) throw new NotFoundException('Configuration not found');
93+
const historical = await this.versionRepository.findOne({ where: { resourceId, version } });
94+
if (!historical) throw new NotFoundException(`Version ${version} not found`);
95+
const snapshot = historical.snapshot;
96+
return this.upsert(entry.service, entry.environment.name, entry.key, {
97+
value: snapshot.value,
98+
kind: snapshot.kind as ConfigKind,
99+
description: snapshot.description as string | undefined,
100+
}, actor);
101+
}
102+
103+
private invalidate(service: string, environment: string): void {
104+
this.cache.deleteByPrefix(this.cacheKey(service, environment));
105+
}
106+
107+
private cacheKey(service: string, environment: string): string {
108+
return `config:${service}:${environment.toLowerCase()}`;
109+
}
110+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ConfigService } from '@nestjs/config';
2+
import { EncryptionService } from './encryption.service';
3+
4+
describe('EncryptionService', () => {
5+
it('encrypts with authenticated encryption and decrypts the value', () => {
6+
const config = new ConfigService({ ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'), ENCRYPTION_KEY_VERSION: '2' });
7+
const service = new EncryptionService(config);
8+
const encrypted = service.encrypt('very-secret');
9+
expect(encrypted).not.toContain('very-secret');
10+
expect(service.decrypt(encrypted, 2)).toBe('very-secret');
11+
});
12+
13+
it('rejects tampered ciphertext', () => {
14+
const config = new ConfigService({ ENCRYPTION_KEY: Buffer.alloc(32, 3).toString('base64') });
15+
const service = new EncryptionService(config);
16+
const encrypted = service.encrypt('secret');
17+
expect(() => service.decrypt(`${encrypted.slice(0, -1)}x`, 1)).toThrow();
18+
});
19+
});

0 commit comments

Comments
 (0)