feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

This commit is contained in:
jubnl
2026-08-20 08:06:02 +02:00
parent 5dc78dd5cf
commit 7439bcd33c
9 changed files with 485 additions and 15 deletions
+1
View File
@@ -18,6 +18,7 @@ const ACTION_LABELS: Record<string, string> = {
'admin.user_delete': 'deleted user',
'admin.plugin_retrust': "re-trusted a plugin's author signing key",
'admin.invite_create': 'created invite',
'admin.storage_update': 'updated storage configuration',
'immich.private_ip_configured': 'configured Immich with private IP',
};
@@ -79,6 +79,8 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
'PluginsController.link',
'PluginsController.reload',
'PluginsController.upload',
'StorageAdminController.get',
'StorageAdminController.update',
];
/**
@@ -0,0 +1,51 @@
import { Body, Controller, Get, HttpException, Put, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { redactStorageSecrets } from './storage-secrets';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuditService } from '../audit/audit.service';
import { getClientIp } from '../audit/client-ip';
import { ManagedForbidden } from '../common/managed';
import { StorageAdminService } from './storage-admin.service';
import { StorageConfigDto } from './storage-admin.dto';
/**
* /api/admin/storage — the admin surface over the storage registry (spec:
* docs/superpowers/specs/2026-08-19-storage-admin-config-design.md). Backends,
* category assignment, and mirror composition are managed here; secrets are
* stored encrypted and only ever rendered as the mask. Pipeline refusals are
* operator-grade registry messages, surfaced verbatim in the 400 envelope.
*/
@Controller('api/admin/storage')
@UseGuards(JwtAuthGuard, AdminGuard)
@ManagedForbidden('storage backends and their credentials are hoster-level configuration')
export class StorageAdminController {
constructor(
private readonly service: StorageAdminService,
private readonly audit: AuditService,
) {}
@Get()
get() {
return this.service.state();
}
@Put()
update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) {
try {
this.service.applyConfig(body);
} catch (err) {
throw new HttpException({ error: err instanceof Error ? err.message : String(err) }, 400);
}
this.audit.writeAudit({
userId: user.id,
action: 'admin.storage_update',
ip: getClientIp(req),
details: redactStorageSecrets(body) as unknown as Record<string, unknown>,
});
// The defaults-tab contract: never echo the request — answer the fresh effective world.
return this.service.state();
}
}
@@ -0,0 +1,10 @@
import { createZodDto } from 'nestjs-zod';
import { storageConfigSchema, storageTestRequestSchema } from '@trek/shared';
/**
* createZodDto wrappers over the @trek/shared storage contracts — the global
* ZodValidationPipe (APP_PIPE) validates @Body() params typed with these, and
* validate-body-contracts.ts refuses boot for any unwrapped mutation body.
*/
export class StorageConfigDto extends createZodDto(storageConfigSchema) {}
export class StorageTestRequestDto extends createZodDto(storageTestRequestSchema) {}
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import { Injectable } from '@nestjs/common';
import type { StorageAdminState, StorageConfig } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import {
BACKENDS_KEY,
CATEGORIES_KEY,
StorageRegistryService,
} from './storage-registry.service';
import { StorageService } from './storage.service';
import { SEED_CONFIG_PATH } from './storage-paths';
import {
encryptionGateError,
encryptStorageSecrets,
listPlaintextSecrets,
maskBackendOptions,
unmaskStorageConfig,
} from './storage-secrets';
import { StorageBackendError } from './storage.types';
/**
* Owner of the api/admin/storage read/write pipelines (spec:
* docs/superpowers/specs/2026-08-19-storage-admin-config-design.md, Server).
* Reads render from the registry's live snapshot; writes run
* unmask → encryption gate → preview → encrypt → persist → reload, so an
* admin save either fully applies or changes nothing — never the boot-time
* silent fallback.
*/
@Injectable()
export class StorageAdminService {
constructor(
private readonly db: DatabaseService,
private readonly registry: StorageRegistryService,
private readonly storage: StorageService,
private readonly env: RuntimeEnvService,
) {}
/** The effective world — secrets masked, categories cross-referenced per backend. */
state(): StorageAdminState {
const snapshot = this.registry.snapshot();
const assignments = Object.entries(snapshot.categories) as Array<
[keyof typeof snapshot.categories, { backend: string; source: 'default' | 'settings' }]
>;
return {
backends: snapshot.backends.map((backend) => ({
name: backend.name,
type: backend.type,
source: backend.source,
options: maskBackendOptions(backend.type, backend.options),
categories: assignments.filter(([, a]) => a.backend === backend.name).map(([category]) => category),
})),
categories: snapshot.categories,
health: { replicaFailures: this.storage.health().replicaFailures.map((f) => ({ ...f })) },
encryptionReady: this.env.env().security.encryptionKeySet,
seedFilePresent: fs.existsSync(SEED_CONFIG_PATH),
};
}
/** Full-document replace of the two settings rows. Throws StorageBackendError on any refusal. */
applyConfig(config: StorageConfig): void {
const unmasked = unmaskStorageConfig(config, this.storedBackendsRow());
const plaintext = listPlaintextSecrets(unmasked);
if (plaintext.length > 0 && !this.env.env().security.encryptionKeySet) {
throw new StorageBackendError(encryptionGateError(plaintext[0]!));
}
this.registry.preview({ backends: unmasked.backends, categories: unmasked.categories });
const encrypted = encryptStorageSecrets(unmasked);
this.db.transaction(() => {
const upsert = this.db.prepare(
'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
);
upsert.run(BACKENDS_KEY, JSON.stringify(encrypted.backends));
upsert.run(CATEGORIES_KEY, JSON.stringify(encrypted.categories));
});
this.registry.reload();
}
/** The raw stored backends row — the unmask source (tolerates absent/garbage rows). */
private storedBackendsRow(): unknown {
const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', BACKENDS_KEY);
if (!row?.value) return [];
try {
return JSON.parse(row.value) as unknown;
} catch {
return [];
}
}
}
@@ -8,7 +8,12 @@ import { LocalDriver } from './drivers/local.driver';
import { MirrorDriver, type ReplicaFailure } from './drivers/mirror.driver';
import { S3Driver } from './drivers/s3.driver';
import { DEFAULT_BACKUPS_ROOT, DEFAULT_UPLOADS_ROOT, GLOBAL_TEMP_DIR, SEED_CONFIG_PATH } from './storage-paths';
import { assertNoMaskSentinels, encryptStorageSecrets, encryptionGateError, listPlaintextSecrets } from './storage-secrets';
import {
assertNoMaskSentinels,
encryptStorageSecrets,
encryptionGateError,
listPlaintextSecrets,
} from './storage-secrets';
import {
STORAGE_CATEGORIES,
StorageBackendError,
@@ -67,8 +72,8 @@ interface RegistryState {
snapshot: RegistrySnapshot;
}
const BACKENDS_KEY = 'storage.backends';
const CATEGORIES_KEY = 'storage.categories';
export const BACKENDS_KEY = 'storage.backends';
export const CATEGORIES_KEY = 'storage.categories';
const REPLICA_FAILURE_RING_SIZE = 50;
/**
+17 -12
View File
@@ -1,23 +1,28 @@
import { Module } from '@nestjs/common';
import { AppConfigModule } from '../app-config/app-config.module';
import { AuditModule } from '../audit/audit.module';
import { StorageRegistryService } from './storage-registry.service';
import { StorageService } from './storage.service';
import { StorageAdminService } from './storage-admin.service';
import { StorageAdminController } from './storage-admin.controller';
/**
* The storage core (spec:
* docs/superpowers/specs/2026-07-20-storage-backend-abstraction-design.md).
* Consuming modules import this explicitly (repo convention — SchedulingModule
* is deliberately not consumed via @Global either); AppConfigModule is listed
* even though it is @Global so an e2e TestingModule built around one domain
* still resolves RuntimeEnvService (the memories.module precedent).
* DatabaseModule IS consumed via @Global, like every other domain.
*
* No controller: v1 has no HTTP surface — the registry's reload() is wired to
* an admin route only when the admin UI lands (Deferred).
* Storage container: registry (config), facade (byte-paths), admin surface.
* AuditModule feeds the write audits. AuthModule is deliberately NOT imported
* here — AuthModule itself imports StorageModule (avatar uploads), so the
* reverse import is a real module cycle (Nest resolves it as `imports[1] is
* undefined`, not a clean forwardRef case). JwtAuthGuard/AdminGuard need no
* provider from AuthModule to begin with — they carry no constructor
* dependencies, so `@UseGuards(JwtAuthGuard, AdminGuard)` instantiates them
* directly, the same as BackupModule/BackupController (which also sits behind
* StorageModule) already does. StorageRegistryService stays UNEXPORTED — the
* admin controller reaches it as a same-module provider, and nothing outside
* may cache drivers or trigger reloads.
*/
@Module({
imports: [AppConfigModule],
providers: [StorageRegistryService, StorageService],
imports: [AppConfigModule, AuditModule],
controllers: [StorageAdminController],
providers: [StorageRegistryService, StorageService, StorageAdminService],
exports: [StorageService],
})
export class StorageModule {}