mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits
This commit is contained in:
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user