diff --git a/packages/identity-py/fuzefront_identity/registry.py b/packages/identity-py/fuzefront_identity/registry.py index 2adef714b..40435e0a8 100644 --- a/packages/identity-py/fuzefront_identity/registry.py +++ b/packages/identity-py/fuzefront_identity/registry.py @@ -51,6 +51,12 @@ # SPINE_PREFIXES so --namespace accepts them. "namespace": "cns", "keyDefinition": "ckd", + # "configHistory" — FF-EPIC-18 (FFRNT-280), the append-only change + # trail ("GET /v1/config/history") and reveal-once secret audit + # ("POST /v1/config/secrets/reveal") both write. Bare "cvh_", same + # reasoning as "cns_"/"ckd_" above. MUST stay in parity with + # packages/identity/src/registry.ts (registry-parity gate). + "configHistory": "cvh", } ) diff --git a/packages/identity/dist/registry.d.ts b/packages/identity/dist/registry.d.ts index 4f01e4359..8d1177681 100644 --- a/packages/identity/dist/registry.d.ts +++ b/packages/identity/dist/registry.d.ts @@ -16,6 +16,11 @@ export declare const ENTITY_PREFIXES: { readonly conversation: "cnv"; readonly message: "msg"; readonly notification: "ntf"; + readonly selectionList: "front_sl"; + readonly selectionListItem: "front_sli"; + readonly namespace: "cns"; + readonly keyDefinition: "ckd"; + readonly configHistory: "cvh"; }; export type EntityType = keyof typeof ENTITY_PREFIXES; export type EntityPrefix = (typeof ENTITY_PREFIXES)[EntityType]; diff --git a/packages/identity/dist/registry.js b/packages/identity/dist/registry.js index 676317e9f..44e067264 100644 --- a/packages/identity/dist/registry.js +++ b/packages/identity/dist/registry.js @@ -43,6 +43,25 @@ exports.ENTITY_PREFIXES = { conversation: 'cnv', message: 'msg', notification: 'ntf', + // selection-list-service — product-local types, namespaced front_ per the + // namespace gate (gate_identifier.py --namespace). Prefixes are permanent once + // shipped: changing one is a wire-breaking change for every stored reference. + selectionList: 'front_sl', + selectionListItem: 'front_sli', + // config-service (FF-EPIC-17) — bare spine prefixes, not front_-namespaced: + // config-service is FuzeFront-hosted for the whole family (same tier as the + // billing/messaging sets above), and the frozen contract + // (services/config-service/openapi.yaml, FFRNT-153) already declares these as + // bare `cns_`/`ckd_`. Mirrored in scripts/gate_identifier.py SPINE_PREFIXES so + // `--namespace` accepts them. + namespace: 'cns', + keyDefinition: 'ckd', + // `configHistory` — FF-EPIC-18 (FFRNT-280), the append-only change trail + // (`GET /v1/config/history`) and reveal-once secret audit + // (`POST /v1/config/secrets/reveal`) both write. Bare `cvh_`, per the same + // FuzeFront-hosted-for-the-family reasoning as `cns_`/`ckd_` above — already + // declared in the frozen contract's "Identifiers" section. + configHistory: 'cvh', }; /** Reverse index, built once. Used to name the type in error messages. */ const TYPE_BY_PREFIX = Object.freeze(Object.fromEntries(Object.entries(exports.ENTITY_PREFIXES).map(([type, prefix]) => [prefix, type]))); diff --git a/packages/identity/src/registry.ts b/packages/identity/src/registry.ts index 3a17539c9..04ff5ac32 100644 --- a/packages/identity/src/registry.ts +++ b/packages/identity/src/registry.ts @@ -51,6 +51,12 @@ export const ENTITY_PREFIXES = { // `--namespace` accepts them. namespace: 'cns', keyDefinition: 'ckd', + // `configHistory` — FF-EPIC-18 (FFRNT-280), the append-only change trail + // (`GET /v1/config/history`) and reveal-once secret audit + // (`POST /v1/config/secrets/reveal`) both write. Bare `cvh_`, per the same + // FuzeFront-hosted-for-the-family reasoning as `cns_`/`ckd_` above — already + // declared in the frozen contract's "Identifiers" section. + configHistory: 'cvh', } as const export type EntityType = keyof typeof ENTITY_PREFIXES diff --git a/scripts/gate_identifier.py b/scripts/gate_identifier.py index 191163966..c09dbff3f 100644 --- a/scripts/gate_identifier.py +++ b/scripts/gate_identifier.py @@ -537,6 +537,7 @@ def check_registry_parity(root: str) -> list[str]: # as bare prefixes. "cns": "FuzeFront", "ckd": "FuzeFront", + "cvh": "FuzeFront", } TS_PREFIX_RE = re.compile(r"^\s*(\w+):\s*'([a-z][a-z_]*)',", re.M) diff --git a/services/config-service/package-lock.json b/services/config-service/package-lock.json index e44257d53..b2bfb69f7 100644 --- a/services/config-service/package-lock.json +++ b/services/config-service/package-lock.json @@ -54,6 +54,10 @@ "tsup": "^8.0.2", "typescript": "^5.9.3" }, + "engines": { + "node": ">=24.0.0", + "npm": ">=10.0.0" + }, "peerDependencies": { "express": "^4.22.2" }, diff --git a/services/config-service/src/app.ts b/services/config-service/src/app.ts index bffa276c5..379d765c1 100644 --- a/services/config-service/src/app.ts +++ b/services/config-service/src/app.ts @@ -3,6 +3,7 @@ import { Pool } from 'pg'; import { PgNamespaceRepository } from './repositories/namespace.repository'; import { PgKeyDefinitionRepository } from './repositories/key-definition.repository'; import { PgValueRepository } from './repositories/value.repository'; +import { PgHistoryRepository } from './repositories/history.repository'; import { createConfigReadRouter } from './routes/config-read.routes'; import { createWriteRouter } from './routes/write.router'; import { createDocsRouter } from './routes/docs.routes'; @@ -88,9 +89,11 @@ export function createApp(deps?: AppDeps): Application { // repo is only ever constructed once per story's router, and neither // story's router touches the other's routes. // - GET /v1/namespaces, GET /v1/namespaces/{namespace}/keys[/{key}], - // GET /v1/config -> FFRNT-157 (createConfigReadRouter) + // GET /v1/config, GET /v1/config/history + // -> FFRNT-157 + FFRNT-280 (createConfigReadRouter) // - POST /v1/namespaces, PUT /v1/namespaces/{namespace}/keys, - // PUT /v1/config -> FFRNT-158 (createWriteRouter) + // PUT /v1/config, POST /v1/config/secrets/reveal + // -> FFRNT-158 + FFRNT-280 (createWriteRouter) // Route ordering, Permit gating, ETag/If-None-Match, ConfigWriteRequest's // batch-transaction semantics, and pagination (gate-pagination, for the // list endpoints) are each router's own responsibility per @@ -99,10 +102,11 @@ export function createApp(deps?: AppDeps): Application { const namespaceRepo = new PgNamespaceRepository(deps.pool); const keyDefinitionRepo = new PgKeyDefinitionRepository(deps.pool); const valueRepo = new PgValueRepository(deps.pool); - app.use('/v1', createConfigReadRouter({ namespaceRepo, keyDefinitionRepo, valueRepo })); // FFRNT-157 (GET routes) + const historyRepo = new PgHistoryRepository(deps.pool); + app.use('/v1', createConfigReadRouter({ namespaceRepo, keyDefinitionRepo, valueRepo, historyRepo })); // FFRNT-157 + FFRNT-280 (GET routes, incl. GET /v1/config/history) // Shares deps.pool rather than letting createWriteRouter() open its own — // one pool per process, not one per story's router. - app.use(createWriteRouter(deps.pool)); // FFRNT-158 (POST/PUT write routes) + app.use(createWriteRouter(deps.pool)); // FFRNT-158 + FFRNT-280 (POST/PUT write routes, incl. POST /v1/config/secrets/reveal) } return app; diff --git a/services/config-service/src/middleware/authz.ts b/services/config-service/src/middleware/authz.ts index b428037f7..ea9b437e1 100644 --- a/services/config-service/src/middleware/authz.ts +++ b/services/config-service/src/middleware/authz.ts @@ -52,9 +52,24 @@ const SECURITY_SERVICE_URL = process.env.SECURITY_SERVICE_URL ?? 'http://fuzefro export const isNoOpMode: boolean = process.env.NODE_ENV === 'test'; function makeNoOpProxy(): AuthzClient { + // `grant`/`revoke`/`listGrants` are part of `@fuzefront/auth`'s `AuthzClient` + // shape but this service never calls them — config-service only ever asks + // authorization QUESTIONS (`check`/`bulkCheck`); granting/revoking roles is + // backend/security's own concern. Stubbed here only so this no-op test + // double keeps satisfying the interface as it grows; a real call would be a + // bug in this service, so each throws rather than silently no-opping. return { check: async (): Promise => ({ allow: true }), bulkCheck: async (checks: AuthzCheck[]): Promise => checks.map(() => ({ allow: true })), + grant: async (): Promise => { + throw new Error('makeNoOpProxy: grant() is not used by config-service'); + }, + revoke: async (): Promise => { + throw new Error('makeNoOpProxy: revoke() is not used by config-service'); + }, + listGrants: async (): Promise => { + throw new Error('makeNoOpProxy: listGrants() is not used by config-service'); + }, }; } diff --git a/services/config-service/src/migrations/004_config_history.sql b/services/config-service/src/migrations/004_config_history.sql new file mode 100644 index 000000000..08df1a111 --- /dev/null +++ b/services/config-service/src/migrations/004_config_history.sql @@ -0,0 +1,75 @@ +-- Migration 004: config_history (FF-EPIC-18 / FFRNT-280) +-- Idempotent: safe to re-run (CREATE ... IF NOT EXISTS everywhere). +-- +-- The append-only change trail `GET /v1/config/history` reads and every +-- `set`/`unset`/`lock`/`unlock` (PUT /v1/config) and `reveal` +-- (POST /v1/config/secrets/reveal) writes — see openapi.yaml +-- `ConfigHistoryEntry` / `listConfigHistory`. Rows are NEVER updated or +-- deleted by this service: a revert or a reveal always APPENDS a new entry +-- rather than touching an existing one (openapi.yaml: "Nothing is ever +-- deleted from this trail and no entry is ever mutated in place"). +-- +-- IDENTITY: `id` is a native `uuid` column populated by the application from +-- a server-minted TypeID (`cvh_…`, mintId('configHistory')) — same pattern as +-- config_namespaces.id / config_key_definitions.id (migrations 001/002). +-- +-- DENORMALIZED `namespace`/`key`: `ConfigHistoryEntry.namespace`/`.key` are +-- TEXT copies taken at write time rather than joined through `definition_id` +-- at read time. A key definition is never hard-deleted (only ever marked +-- `deprecated_at` — migration 002's own doc comment), so the join would +-- always resolve; the denormalization exists purely so `listConfigHistory`'s +-- hot path (filtered by namespace+key+scope) is a single indexed lookup +-- against this table alone, with no join back to config_key_definitions. +-- `definition_id` is still kept (FK, ON DELETE CASCADE) as the authoritative +-- link and for `is_secret`-driven redaction bookkeeping elsewhere. +-- +-- scope_type/scope_id mirrors config_values' polymorphic-scope shape exactly +-- (migration 003) — same CHECK invariant, same "not a real FK" reasoning +-- (portal_id/org_id/user_id are owned by other services/tables). + +CREATE TABLE IF NOT EXISTS config.config_history ( + id UUID PRIMARY KEY, + definition_id UUID NOT NULL REFERENCES config.config_key_definitions(id) ON DELETE CASCADE, + namespace TEXT NOT NULL, + key TEXT NOT NULL, + scope_type TEXT NOT NULL CHECK (scope_type IN ('platform', 'portal', 'org', 'user')), + -- NULL exactly when scope_type = 'platform' (a singleton tier) — same + -- invariant as config_values.scope_id (migration 003). + scope_id UUID, + CONSTRAINT config_history_scope_id_matches_type CHECK ( + (scope_type = 'platform' AND scope_id IS NULL) OR + (scope_type <> 'platform' AND scope_id IS NOT NULL) + ), + action TEXT NOT NULL CHECK (action IN ('set', 'unset', 'lock', 'unlock', 'reveal')), + -- Both JSONB and nullable: NOT populated for every (action, redacted) + -- combination — see openapi.yaml ConfigHistoryEntry.oldValue/.newValue for + -- exactly which. Always NULL when `redacted` is true, regardless of action. + old_value JSONB, + new_value JSONB, + -- True when the key is `isSecret` at the time this entry was written — a + -- point-in-time copy (not re-derived from config_key_definitions later) so + -- history stays correct even if a key's isSecret flag is edited afterward. + redacted BOOLEAN NOT NULL DEFAULT FALSE, + actor_type TEXT NOT NULL CHECK (actor_type IN ('user', 'system')), + -- NULL exactly when actor_type = 'system' (openapi.yaml Actor.actorId). + actor_id UUID, + reason TEXT, + -- Self-referential: the history entry a revert replayed (openapi.yaml + -- ConfigOperation.revertOf / ConfigHistoryEntry.revertOf). No ON DELETE + -- action needed — history rows are never deleted by this service. + revert_of UUID REFERENCES config.config_history(id), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The `listConfigHistory` hot path: "every entry for one key at one exact +-- scope, newest first" (openapi.yaml). occurred_at DESC + id DESC gives a +-- stable total keyset order (ties on occurred_at, e.g. two entries written in +-- the same batch, are broken by id) — mirrors config_namespaces' listPage +-- keyset (migration 001 / namespace.repository.ts). +CREATE INDEX IF NOT EXISTS config_history_lookup_idx + ON config.config_history (namespace, key, scope_type, scope_id, occurred_at DESC, id DESC); + +-- Supports the FK join / cascade and any future "history for this +-- definition regardless of scope" query. +CREATE INDEX IF NOT EXISTS config_history_definition_idx + ON config.config_history (definition_id); diff --git a/services/config-service/src/repositories/history.repository.ts b/services/config-service/src/repositories/history.repository.ts new file mode 100644 index 000000000..b3e2cd1c7 --- /dev/null +++ b/services/config-service/src/repositories/history.repository.ts @@ -0,0 +1,223 @@ +/** + * The append-only change trail (FF-EPIC-18 / FFRNT-280): every `set`/`unset`/ + * `lock`/`unlock` (`PUT /v1/config`) and `reveal` + * (`POST /v1/config/secrets/reveal`) appends one row here; nothing is ever + * updated or deleted — see migrations/004_config_history.sql and + * openapi.yaml `ConfigHistoryEntry`. + * + * `append()` takes a `Pool` (or a `PoolClient` passed as one, matching + * `PgValueRepository`'s own convention — see src/routes/config.write.ts's + * `new PgValueRepository(client as unknown as Pool)`) so the write surface + * can record history INSIDE the same transaction as the value change it + * describes: a rolled-back write must never leave an orphan history entry + * behind, which "wrap the same client" gives for free. + */ + +import { Pool } from 'pg'; +import { assertRef, fromUuid, mintId, toUuid } from '@izzywdev/fuzefront-identity'; +import { + Actor, + ActorType, + ConfigHistoryAction, + ConfigHistoryEntry, + ConfigHistoryEntryId, + KeyDefinitionEntityId, + Scope, + ScopeType, +} from '../types'; +import { decodeCursor, encodeCursor, PageInfo } from '../pagination'; + +/** The entity type a non-platform scope tier's `scopeId` references — mirrors value.repository.ts. */ +const SCOPE_ENTITY_TYPE = { + portal: 'portal', + org: 'organization', + user: 'user', +} as const; + +function scopeIdToStorage(scope: Scope): string | null { + if (scope.scopeType === 'platform') return null; + const entityType = SCOPE_ENTITY_TYPE[scope.scopeType as 'portal' | 'org' | 'user']; + return toUuid(assertRef(entityType, scope.scopeId)); +} + +function scopeIdToWire(scopeType: ScopeType, storageScopeId: string | null): string | null { + if (scopeType === 'platform' || storageScopeId == null) return null; + const entityType = SCOPE_ENTITY_TYPE[scopeType as 'portal' | 'org' | 'user']; + return fromUuid(entityType, storageScopeId); +} + +export interface AppendHistoryInput { + definitionId: KeyDefinitionEntityId; + namespace: string; + key: string; + scope: Scope; + action: ConfigHistoryAction; + /** Ignored (stored as `null`) when `redacted` is true. */ + oldValue?: unknown; + /** Ignored (stored as `null`) when `redacted` is true. */ + newValue?: unknown; + /** True when the key is `isSecret` at write time — a point-in-time copy, not re-derived later. */ + redacted: boolean; + actor: Actor; + reason?: string | null; + revertOf?: ConfigHistoryEntryId | null; +} + +export interface ListHistoryArgs { + namespace: string; + key: string; + scope: Scope; + limit: number; + /** Opaque keyset cursor (previous page's nextCursor); undefined for page 1. */ + cursor?: string; +} + +export interface ListHistoryResult { + items: ConfigHistoryEntry[]; + pageInfo: PageInfo; +} + +export interface HistoryRepository { + /** Appends one immutable entry. Never updates or deletes an existing row. */ + append(input: AppendHistoryInput): Promise; + /** + * Cursor page for ONE key at ONE exact scope, newest first (openapi.yaml + * `listConfigHistory`). Keyset on (occurred_at, id) DESC, matching + * migration 004's index — both are on every row and `id` is unique, so the + * pair is a stable total order even when multiple entries share an + * `occurred_at` (e.g. two ops applied in the same PUT /v1/config batch). + */ + listPage(args: ListHistoryArgs): Promise; +} + +interface HistoryRow { + id: string; + namespace: string; + key: string; + scope_type: ScopeType; + scope_id: string | null; + action: ConfigHistoryAction; + old_value: unknown; + new_value: unknown; + redacted: boolean; + actor_type: ActorType; + actor_id: string | null; + reason: string | null; + revert_of: string | null; + occurred_at: Date; +} + +function mapRow(r: HistoryRow): ConfigHistoryEntry { + return { + id: fromUuid('configHistory', r.id), + namespace: r.namespace, + key: r.key, + scope: { scopeType: r.scope_type, scopeId: scopeIdToWire(r.scope_type, r.scope_id) }, + action: r.action, + oldValue: r.redacted ? null : (r.old_value ?? null), + newValue: r.redacted ? null : (r.new_value ?? null), + redacted: r.redacted, + actor: { actorType: r.actor_type, actorId: r.actor_id }, + reason: r.reason, + revertOf: r.revert_of ? fromUuid('configHistory', r.revert_of) : null, + occurredAt: r.occurred_at.toISOString(), + }; +} + +interface HistoryCursor { + occurredAt: string; + id: string; +} + +const SELECT_COLUMNS = ` + id, namespace, key, scope_type, scope_id, action, old_value, new_value, + redacted, actor_type, actor_id, reason, revert_of, occurred_at +`; + +export class PgHistoryRepository implements HistoryRepository { + constructor(private readonly pool: Pool) {} + + async append(input: AppendHistoryInput): Promise { + const id = mintId('configHistory'); + // Redaction wins over whatever old/new values were supplied — an isSecret + // key's plaintext must never land in this table, regardless of what the + // caller passed in (openapi.yaml: "oldValue/newValue are then always null + // regardless of action"). + const oldValue = input.redacted ? null : (input.oldValue ?? null); + const newValue = input.redacted ? null : (input.newValue ?? null); + + const res = await this.pool.query( + `INSERT INTO config.config_history ( + id, definition_id, namespace, key, scope_type, scope_id, action, + old_value, new_value, redacted, actor_type, actor_id, reason, revert_of + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8::jsonb, $9::jsonb, $10, $11, $12, $13, $14 + ) + RETURNING ${SELECT_COLUMNS}`, + [ + toUuid(id), + toUuid(input.definitionId), + input.namespace, + input.key, + input.scope.scopeType, + scopeIdToStorage(input.scope), + input.action, + JSON.stringify(oldValue), + JSON.stringify(newValue), + input.redacted, + input.actor.actorType, + // Stored verbatim, same as config_values.set_by_user_id + // (value.repository.ts) — the JWT `userId` claim is not run through + // assertRef()/toUuid() there either, so this stays consistent rather + // than applying a stricter check to one of the two id-shaped columns + // that record "who" and not the other. + input.actor.actorId ?? null, + input.reason ?? null, + input.revertOf ? toUuid(input.revertOf) : null, + ], + ); + return mapRow(res.rows[0]); + } + + async listPage(args: ListHistoryArgs): Promise { + const conds: string[] = ['namespace = $1', 'key = $2', 'scope_type = $3']; + const params: unknown[] = [args.namespace, args.key, args.scope.scopeType]; + + if (args.scope.scopeType === 'platform') { + conds.push('scope_id IS NULL'); + } else { + params.push(scopeIdToStorage(args.scope)); + conds.push(`scope_id = $${params.length}`); + } + + const cursor = args.cursor ? decodeCursor(args.cursor) : null; + if (cursor && cursor.occurredAt && cursor.id) { + params.push(cursor.occurredAt, cursor.id); + const i = params.length - 1; + // Row-value comparison on the same (occurred_at, id) DESC order as + // ORDER BY below — mirrors namespace.repository.ts's listPage. + conds.push(`(occurred_at, id) < ($${i}::timestamptz, $${i + 1}::uuid)`); + } + + params.push(args.limit + 1); + const limitParamIdx = params.length; + + const res = await this.pool.query( + `SELECT ${SELECT_COLUMNS} FROM config.config_history + WHERE ${conds.join(' AND ')} + ORDER BY occurred_at DESC, id DESC + LIMIT $${limitParamIdx}`, + params, + ); + + const hasNextPage = res.rows.length > args.limit; + const rows = hasNextPage ? res.rows.slice(0, args.limit) : res.rows; + const items = rows.map(mapRow); + const last = rows[rows.length - 1]; + const nextCursor = + hasNextPage && last ? encodeCursor({ occurredAt: last.occurred_at.toISOString(), id: last.id }) : null; + + return { items, pageInfo: { hasNextPage, nextCursor } }; + } +} diff --git a/services/config-service/src/routes/config-read.routes.ts b/services/config-service/src/routes/config-read.routes.ts index 94a1dd890..2df6ffa38 100644 --- a/services/config-service/src/routes/config-read.routes.ts +++ b/services/config-service/src/routes/config-read.routes.ts @@ -1,14 +1,17 @@ /** - * The GET half of the config-service HTTP surface (FFRNT-157 / FF-EPIC-17-S5): + * The GET half of the config-service HTTP surface (FFRNT-157 / FF-EPIC-17-S5, + * plus `listConfigHistory` from FF-EPIC-18 / FFRNT-280): * * GET /v1/namespaces * GET /v1/namespaces/{namespace}/keys * GET /v1/namespaces/{namespace}/keys/{key} * GET /v1/config + * GET /v1/config/history * - * Mutations (PUT /v1/config, POST/PUT /v1/namespaces*) are FFRNT-158's router, - * mounted separately at the same `app.use('/v1', ...)` extension point in - * src/app.ts — this module owns nothing but the four routes above, per + * Mutations (PUT /v1/config, POST/PUT /v1/namespaces*, and the reveal-once + * POST /v1/config/secrets/reveal) live in FFRNT-158's write router, mounted + * separately at the same `app.use('/v1', ...)`-adjacent extension point in + * src/app.ts — this module owns every GET route on the surface, per * services/config-service/openapi.yaml, the frozen contract. */ @@ -17,16 +20,18 @@ import { createHash } from 'crypto'; import { NamespaceRepository } from '../repositories/namespace.repository'; import { KeyDefinitionRepository } from '../repositories/key-definition.repository'; import { ValueRepository } from '../repositories/value.repository'; +import { HistoryRepository } from '../repositories/history.repository'; import { resolveEffectiveConfig } from '../resolver/resolve'; import { requireAuth } from '../middleware/auth'; import { CONFIG_CATALOG_RESOURCE, CONFIG_SCOPE_RESOURCE, checkAuthorization, requireConfigPermission } from '../middleware/authz'; import { parseLimit } from '../pagination'; -import { EffectiveConfigEntry, KeyDefinition, Scope, ScopeType } from '../types'; +import { ConfigHistoryEntry, EffectiveConfigEntry, KeyDefinition, Scope, ScopeType } from '../types'; export interface ConfigReadRouterDeps { namespaceRepo: NamespaceRepository; keyDefinitionRepo: KeyDefinitionRepository; valueRepo: ValueRepository; + historyRepo: HistoryRepository; } const VALID_SCOPE_TYPES: ScopeType[] = ['platform', 'portal', 'org', 'user']; @@ -105,15 +110,38 @@ function buildScopeChain(target: Scope): Scope[] { return [platform, target]; } -/** Best-effort authz tenant for a scope: an org scope IS its own tenant; other tiers fall back to the caller's own org context, or a fixed 'platform' tenant when none is known. */ -function deriveTenant(scope: Scope, req: Request): string { +/** + * Best-effort authz tenant for a scope: an org scope IS its own tenant; other + * tiers fall back to the caller's own org context, or a fixed 'platform' + * tenant when none is known. Exported so the reveal-once write route + * (src/routes/secrets.write.ts) derives a tenant the SAME way for its own + * `ConfigScope` check — one rule, not two that could silently drift apart. + */ +export function deriveTenant(scope: Scope, req: Request): string { if (scope.scopeType === 'org' && scope.scopeId) return scope.scopeId; return req.orgId ?? 'platform'; } +function serializeHistoryEntry(entry: ConfigHistoryEntry): Record { + return { + id: entry.id, + namespace: entry.namespace, + key: entry.key, + scope: entry.scope, + action: entry.action, + oldValue: entry.oldValue, + newValue: entry.newValue, + redacted: entry.redacted, + actor: entry.actor, + reason: entry.reason, + revertOf: entry.revertOf, + occurredAt: entry.occurredAt, + }; +} + export function createConfigReadRouter(deps: ConfigReadRouterDeps): Router { const router = Router(); - const { namespaceRepo, keyDefinitionRepo, valueRepo } = deps; + const { namespaceRepo, keyDefinitionRepo, valueRepo, historyRepo } = deps; // ── GET /v1/namespaces ────────────────────────────────────────────────── router.get( @@ -305,6 +333,121 @@ export function createConfigReadRouter(deps: ConfigReadRouterDeps): Router { } }); + // ── GET /v1/config/history ─────────────────────────────────────────────── + // openapi.yaml `listConfigHistory`: the append-only change trail for ONE + // key at ONE exact scope. Sibling of GET /v1/config above — same + // namespace/scopeType/scopeId validation, but additionally requires `key` + // and gates on a distinct 'audit' action (openapi.yaml Forbidden: "no + // **audit** grant... distinct from the write/read grants on the value + // itself"), never on the 'read'/'admin' actions the rest of this router + // uses. + router.get('/config/history', requireAuth, async (req: Request, res: Response) => { + const namespaceName = typeof req.query.namespace === 'string' ? req.query.namespace : undefined; + const scopeTypeRaw = typeof req.query.scopeType === 'string' ? req.query.scopeType : undefined; + const scopeIdRaw = typeof req.query.scopeId === 'string' ? req.query.scopeId : undefined; + const key = typeof req.query.key === 'string' ? req.query.key : undefined; + + if (!namespaceName) { + res.status(400).json( + errorBody('VALIDATION_ERROR', 'namespace is required.', { + details: [{ key: null, field: 'namespace', message: 'namespace is required.', allowedValues: null }], + }), + ); + return; + } + if (!scopeTypeRaw || !VALID_SCOPE_TYPES.includes(scopeTypeRaw as ScopeType)) { + res.status(400).json( + errorBody('VALIDATION_ERROR', 'scopeType must be one of platform, portal, org, user.', { + details: [ + { + key: null, + field: 'scopeType', + message: 'scopeType must be one of platform, portal, org, user.', + allowedValues: VALID_SCOPE_TYPES, + }, + ], + }), + ); + return; + } + const scopeType = scopeTypeRaw as ScopeType; + if (scopeType === 'platform' && scopeIdRaw) { + res.status(400).json( + errorBody('VALIDATION_ERROR', 'scopeId must be omitted when scopeType is platform.', { + details: [{ key: null, field: 'scopeId', message: 'must be omitted for platform', allowedValues: null }], + }), + ); + return; + } + if (scopeType !== 'platform' && !scopeIdRaw) { + res.status(400).json( + errorBody('VALIDATION_ERROR', 'scopeId is required unless scopeType is platform.', { + details: [{ key: null, field: 'scopeId', message: 'scopeId is required', allowedValues: null }], + }), + ); + return; + } + if (!key) { + res.status(400).json( + errorBody('VALIDATION_ERROR', 'key is required.', { + details: [{ key: null, field: 'key', message: 'key is required.', allowedValues: null }], + }), + ); + return; + } + + const targetScope: Scope = { scopeType, scopeId: scopeType === 'platform' ? null : (scopeIdRaw as string) }; + + // Authz BEFORE any existence check, same discipline as GET /v1/config + // above — a caller with no audit authority learns nothing about whether + // the namespace/key/scope exists. 'audit' is a DISTINCT action from + // 'read' (openapi.yaml: seeing/changing a setting does not imply seeing + // who changed it and why). + const allowed = await checkAuthorization( + req, + CONFIG_SCOPE_RESOURCE, + 'audit', + `${namespaceName}:${scopeType}:${scopeIdRaw ?? 'platform'}`, + deriveTenant(targetScope, req), + ); + if (!allowed) { + res.status(403).json( + errorBody('FORBIDDEN', 'No audit grant over the requested scope.'), + ); + return; + } + + const namespace = await namespaceRepo.findByName(namespaceName); + if (!namespace) { + res.status(404).json(errorBody('NOT_FOUND', `No such namespace '${namespaceName}'.`)); + return; + } + + const definition = await keyDefinitionRepo.findByKey(namespace.id, key); + // Hidden keys are 404 — same masking rule as getKeyDefinition/GET + // /v1/config above: this endpoint never confirms the existence of a key + // the caller may not otherwise see. + if (!definition || definition.isHidden) { + res.status(404).json(errorBody('NOT_FOUND', `No such key '${key}' in namespace '${namespaceName}'.`)); + return; + } + + const limit = parseLimit(req.query.limit); + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined; + + try { + const page = await historyRepo.listPage({ namespace: namespaceName, key, scope: targetScope, limit, cursor }); + res.status(200).json({ + items: page.items.map(serializeHistoryEntry), + pageInfo: page.pageInfo, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[GET /v1/config/history] error', err); + res.status(500).json(errorBody('VALIDATION_ERROR', 'Failed to list history.')); + } + }); + return router; } diff --git a/services/config-service/src/routes/config.write.ts b/services/config-service/src/routes/config.write.ts index 6c0cefdda..023a09fb8 100644 --- a/services/config-service/src/routes/config.write.ts +++ b/services/config-service/src/routes/config.write.ts @@ -31,6 +31,7 @@ import { PgValueRepository, ScopeNotAllowedError, } from '../repositories/value.repository'; +import { PgHistoryRepository } from '../repositories/history.repository'; import { validateValue } from '../validation/schema'; import { validateWriteRequestShape } from '../validation/requestShapes'; import { buildWriteChain, findAncestorLock, findRowAtTargetScope } from '../services/scope-chain'; @@ -257,9 +258,23 @@ export function createConfigWriteRouter(pool: Pool): Router { try { await client.query('BEGIN'); const txValues = new PgValueRepository(client as unknown as Pool); + // Same transaction as the value change it describes — a rolled-back + // write must never leave an orphan history entry, which sharing this + // client (matching txValues above) gives for free (FF-EPIC-18 / + // FFRNT-280, GET /v1/config/history's write side). + const txHistory = new PgHistoryRepository(client as unknown as Pool); + const actor = { actorType: 'user' as const, actorId: principal.userId }; for (const op of body.operations) { const def = byKey.get(op.key)!; + // The row at the EXACT target scope BEFORE this op — the `oldValue` + // every history entry (except a first-ever `set`) carries. Read from + // the batch's pre-write snapshot (`rowsByDefinition`, fetched in step + // 5 above), not re-queried mid-transaction: nothing else in this + // request can have changed it since. + const preRows = rowsByDefinition.get(def.id) ?? []; + const before = findRowAtTargetScope(chain, preRows); + switch (op.op) { case 'set': await txValues.setValue({ @@ -271,11 +286,35 @@ export function createConfigWriteRouter(pool: Pool): Router { lockReason: null, setByUserId: principal.userId, }); + await txHistory.append({ + definitionId: def.id, + namespace: body.namespace, + key: op.key, + scope: body.scope, + action: 'set', + oldValue: before?.value ?? null, + newValue: op.value, + redacted: def.isSecret, + actor, + reason: body.reason ?? null, + }); break; case 'unset': // Deliberately unsetValue(), NOT setValue() with the parent's // current value — see module doc. await txValues.unsetValue(def.id, body.scope); + await txHistory.append({ + definitionId: def.id, + namespace: body.namespace, + key: op.key, + scope: body.scope, + action: 'unset', + oldValue: before?.value ?? null, + newValue: null, + redacted: def.isSecret, + actor, + reason: body.reason ?? null, + }); break; case 'lock': await txValues.setValue({ @@ -287,25 +326,49 @@ export function createConfigWriteRouter(pool: Pool): Router { lockReason: op.lockReason ?? null, setByUserId: principal.userId, }); + await txHistory.append({ + definitionId: def.id, + namespace: body.namespace, + key: op.key, + scope: body.scope, + action: 'lock', + oldValue: before?.value ?? null, + newValue: op.value, + redacted: def.isSecret, + actor, + reason: body.reason ?? null, + }); break; case 'unlock': { // Un-pins the lock but PRESERVES whatever value is there — unlock // is "stop blocking descendants", not "remove my override" (that // is `unset`). - const rows = rowsByDefinition.get(def.id) ?? []; - const existing = findRowAtTargetScope(chain, rows); - if (!existing) { + if (!before) { throw new NothingToUnlockError(op.key); } await txValues.setValue({ definitionId: def.id, allowedScopes: def.allowedScopes, scope: body.scope, - value: existing.value, + value: before.value, isLocked: false, lockReason: null, setByUserId: principal.userId, }); + // No oldValue/newValue: unlock does not change the stored value, + // only its lock state — openapi.yaml describes oldValue/newValue + // as populated "for set/unset" and "for set/lock" respectively, + // neither of which names unlock. + await txHistory.append({ + definitionId: def.id, + namespace: body.namespace, + key: op.key, + scope: body.scope, + action: 'unlock', + redacted: def.isSecret, + actor, + reason: body.reason ?? null, + }); break; } } diff --git a/services/config-service/src/routes/secrets.write.ts b/services/config-service/src/routes/secrets.write.ts new file mode 100644 index 000000000..3da9475a4 --- /dev/null +++ b/services/config-service/src/routes/secrets.write.ts @@ -0,0 +1,306 @@ +/** + * POST /v1/config/secrets/reveal — `revealSecret` (FF-EPIC-18 / FFRNT-280). + * + * Reveal-once disclosure of an `isSecret` value's plaintext. This is + * deliberately its OWN high-privilege action, never a field on + * `EffectiveConfigEntry` or any GET (openapi.yaml `revealSecret`): + * + * - Authorization is a DISTINCT grant ('reveal') from both the ordinary + * read grant ('read', GET /v1/config) and the write grant ('write', + * PUT /v1/config) — an operator who may Replace a credential does not + * automatically get to Reveal it. Decided the SAME way every other + * scope-level check in this service is: `checkAuthorization()` against + * FuzeFront's Security API (never "the caller supplied the right + * namespace/scope/key" — an id, or an address built from ids, is NEVER a + * capability; see CLAUDE.md "Entity identifiers"). + * - FAIL CLOSED throughout: a denied/undecidable authz check is 403, same + * discipline as src/middleware/authz.ts's `checkAuthorization()`. + * - Throttled independently of ordinary reads/writes (429 RATE_LIMITED) — + * see `RevealRateLimiter` below. A successful call discloses a live + * credential, so this is deliberately throttled HARDER than an ordinary + * read, per openapi.yaml's `429` response. + * - Every attempt against a real, resolved secret — success or failure — + * writes its OWN `reveal` entry to `GET /v1/config/history` (the audit + * trail), never a formality: see the `recordReveal` calls below. + * + * NOT implemented in this PR, and named here rather than silently assumed: + * `isSecret` values are stored and read back as plaintext JSONB, same as + * every other value type (src/repositories/value.repository.ts) — this PR + * adds the reveal-once AUTHORIZATION/AUDIT contract, not encryption-at-rest. + * That gap PREDATES this PR (S6/FFRNT-158's PUT /v1/config already stores + * `isSecret` values this way) and is out of THIS PR's scope — encrypting at + * rest needs a key-management decision (KMS/vault integration, a migration + * for already-stored values) this change does not make. `decryptSecretValue` + * below is the seam a future encryption change hangs off: today it never + * throws, so the contract's `409 SECRET_UNAVAILABLE` path is wired but not + * yet reachable — same "documented limitation, not silently assumed" pattern + * src/services/scope-chain.ts already uses for its own known gap. + */ + +import { Router, Request, Response } from 'express'; +import { Pool } from 'pg'; +import { requireAuth } from '../middleware/auth'; +import { CONFIG_SCOPE_RESOURCE, checkAuthorization } from '../middleware/authz'; +import { deriveTenant } from './config-read.routes'; +import { PgNamespaceRepository } from '../repositories/namespace.repository'; +import { PgKeyDefinitionRepository } from '../repositories/key-definition.repository'; +import { PgValueRepository } from '../repositories/value.repository'; +import { PgHistoryRepository } from '../repositories/history.repository'; +import { validateRevealSecretRequestShape } from '../validation/requestShapes'; +import { sendError } from '../http/errors'; +import { Scope } from '../types'; + +interface RevealSecretRequestInput { + namespace: string; + scope: Scope; + key: string; + reason: string; +} + +/** + * Reveal-once decryption seam. A no-op today (values are stored as plain + * JSONB — see the module doc above) but kept as its OWN function, rather than + * inlined at the call site, so a future encryption-at-rest change has one + * place to make throw `SecretUnavailableError` — the exact shape + * `POST /v1/config/secrets/reveal`'s `409 SECRET_UNAVAILABLE` response + * expects. + */ +class SecretUnavailableError extends Error { + constructor() { + super('secret value could not be decrypted'); + this.name = 'SecretUnavailableError'; + } +} +function decryptSecretValue(stored: unknown): string { + // Secret-typed keys validate to `{ type: 'string' }` (src/validation/schema.ts + // baseSchemaFor('secret')), so a well-formed store always has a string here. + // Coerced defensively rather than assumed, so a value stored before its key + // was ever marked `isSecret` still reveals SOMETHING rather than crashing. + return typeof stored === 'string' ? stored : JSON.stringify(stored); +} + +/** + * In-memory sliding-window throttle, keyed per (subject, namespace, key, + * scope) — deliberately narrower than a per-caller-only limit, so hammering + * one credential is caught without also rate-limiting every OTHER secret the + * same operator is entitled to reveal in the same window. + * + * DOCUMENTED LIMITATION (same "best-effort, not silently assumed" discipline + * as src/services/scope-chain.ts's own chain-assembly gap): this state is + * per-process. A config-service deployment with more than one replica does + * not share this window, so the effective limit is `maxAttempts` PER POD, + * not per deployment. Closing that requires a shared store (Redis, or the + * Security API growing a rate-limit primitve of its own) this service does + * not have a dependency on today. Real throttling within one process is + * still strictly better than the alternative of not throttling at all. + */ +export class RevealRateLimiter { + private readonly attempts = new Map(); + + constructor( + private readonly maxAttempts = 5, + private readonly windowMs = 60_000, + ) {} + + /** Records one attempt and returns whether it is within the allowed window. */ + allow(key: string): boolean { + const now = Date.now(); + const cutoff = now - this.windowMs; + const recent = (this.attempts.get(key) ?? []).filter((t) => t > cutoff); + if (recent.length >= this.maxAttempts) { + this.attempts.set(key, recent); + return false; + } + recent.push(now); + this.attempts.set(key, recent); + return true; + } +} + +export function createSecretsWriteRouter(pool: Pool, rateLimiter: RevealRateLimiter = new RevealRateLimiter()): Router { + const namespaces = new PgNamespaceRepository(pool); + const keyDefs = new PgKeyDefinitionRepository(pool); + const values = new PgValueRepository(pool); + const history = new PgHistoryRepository(pool); + + const router = Router(); + + router.post('/v1/config/secrets/reveal', requireAuth, async (req: Request, res: Response) => { + // Express 4 does not catch a rejected promise from an async handler — + // an uncaught throw here would hang the request forever rather than + // 500ing (no response, ever). Wrapping the whole body is the same + // "recoverable failure gets a real response" discipline the write + // surface's own transaction step already applies (config.write.ts); + // reveal has no transaction step to hang the catch off of, so it wraps + // the whole handler instead. + try { + await handleReveal(req, res); + } catch (err) { + // eslint-disable-next-line no-console + console.error('[config-service] reveal failed', err); + res.status(500).json({ error: 'internal_error', message: 'Unexpected failure handling the reveal request.' }); + } + }); + + async function handleReveal(req: Request, res: Response): Promise { + const principal = req.principal!; + + // ── 1. Structural shape (400). ────────────────────────────────────────── + const shape = validateRevealSecretRequestShape(req.body); + if (!shape.valid) { + sendError(res, 400, { + code: 'VALIDATION_ERROR', + message: 'Malformed reveal request.', + details: shape.errors.map((m) => ({ message: m })), + }); + return; + } + const body = req.body as RevealSecretRequestInput; + + const scopeIdInvalid = + body.scope.scopeType === 'platform' ? body.scope.scopeId != null : body.scope.scopeId == null; + if (scopeIdInvalid) { + sendError(res, 400, { + code: 'VALIDATION_ERROR', + message: 'scopeId must be null exactly when scopeType is platform.', + details: [{ field: 'scope.scopeId', message: 'invalid for this scopeType' }], + }); + return; + } + + // ── 2. Authorization (403), BEFORE any existence check. ───────────────── + // Matches GET /v1/config's own ordering discipline (config-read.routes.ts): + // a caller with no 'reveal' grant learns nothing about whether the + // namespace/key/scope exists, or whether a secret is even stored there. + // 'reveal' is its OWN action, decided independently of 'read'/'write' — + // never derived from them, and never satisfied merely by the caller + // having supplied the right namespace/scope/key (openapi.yaml: "an + // operator who may Replace a credential does not automatically get to + // Reveal it"; CLAUDE.md: an id — or an address built from ids — is never + // a capability). The Security API decides 'reveal' on its own policy, the + // same way it decides every other action this service checks. + const resourceKey = `${body.namespace}:${body.scope.scopeType}:${body.scope.scopeId ?? 'platform'}`; + const allowed = await checkAuthorization(req, CONFIG_SCOPE_RESOURCE, 'reveal', resourceKey, deriveTenant(body.scope, req)); + if (!allowed) { + sendError(res, 403, { code: 'FORBIDDEN', message: 'No reveal grant over the requested scope.' }); + return; + } + + // ── 3. Namespace + key existence (404). ────────────────────────────────── + const namespace = await namespaces.findByName(body.namespace); + if (!namespace) { + sendError(res, 404, { code: 'NOT_FOUND', message: `no such namespace '${body.namespace}'` }); + return; + } + const definitions = await keyDefs.listByNamespace(namespace.id); + const definition = definitions.find((d) => d.key === body.key); + // Hidden keys 404 — the SAME masking rule every other lookup in this + // service applies (config-read.routes.ts `getKeyDefinition`/GET /v1/config): + // this endpoint never confirms a hidden key's existence either. + if (!definition || definition.isHidden) { + sendError(res, 404, { code: 'NOT_FOUND', message: `no such key '${body.key}' in namespace '${body.namespace}'` }); + return; + } + if (!definition.isSecret) { + // Not one of openapi.yaml's named reveal error cases (404/409/429): + // revealing a NON-secret key is a malformed request against a real, + // existing key — VALIDATION_ERROR, not "not found" (which would read + // as "no such key exists" when the key plainly does). + sendError(res, 400, { + code: 'VALIDATION_ERROR', + message: `key '${body.key}' is not isSecret; nothing to reveal.`, + details: [{ key: body.key, message: 'key.isSecret is false' }], + }); + return; + } + + // ── 4. Throttle (429), keyed per (subject, namespace, scope, key). ────── + // Runs only once the target secret is confirmed to exist — matches the + // "every attempt against a REAL secret" scope of the history write below; + // an attempt against a namespace/key that does not exist never reaches + // (or drains) this window. + const rateLimitKey = `${principal.userId}:${resourceKey}:${body.key}`; + if (!rateLimiter.allow(rateLimitKey)) { + await recordReveal(history, { definitionId: definition.id, namespace: body.namespace, key: body.key, scope: body.scope, actorId: principal.userId, reason: body.reason }); + sendError(res, 429, { + code: 'RATE_LIMITED', + message: 'Too many reveal attempts for this credential. Try again later.', + }); + return; + } + + // ── 5. The stored value at the EXACT target scope (404 if isSet: false). ─ + const rows = await values.listForDefinitions([definition.id], [body.scope]); + const stored = rows[0]; + if (!stored) { + await recordReveal(history, { definitionId: definition.id, namespace: body.namespace, key: body.key, scope: body.scope, actorId: principal.userId, reason: body.reason }); + sendError(res, 404, { + code: 'NOT_FOUND', + message: `no value is currently stored for key '${body.key}' at this exact scope`, + }); + return; + } + + // ── 6. Decrypt (409 SECRET_UNAVAILABLE on failure — see module doc). ──── + let plaintext: string; + try { + plaintext = decryptSecretValue(stored.value); + } catch (err) { + if (err instanceof SecretUnavailableError) { + await recordReveal(history, { definitionId: definition.id, namespace: body.namespace, key: body.key, scope: body.scope, actorId: principal.userId, reason: body.reason }); + sendError(res, 409, { + code: 'SECRET_UNAVAILABLE', + message: 'This secret cannot be decrypted right now. The value has not been deleted.', + }); + return; + } + throw err; + } + + // ── 7. Success — audit, then respond. Never cached, never re-servable. ── + const entry = await recordReveal(history, { + definitionId: definition.id, + namespace: body.namespace, + key: body.key, + scope: body.scope, + actorId: principal.userId, + reason: body.reason, + }); + + res.status(200).setHeader('Cache-Control', 'no-store').json({ + namespace: body.namespace, + scope: body.scope, + key: body.key, + value: plaintext, + revealedAt: entry.occurredAt, + historyEntryId: entry.id, + }); + } + + return router; +} + +/** + * Every reveal attempt against a resolved secret — success or not — writes + * its own `reveal` entry (openapi.yaml: "every call — success or not — + * writes its own reveal entry ... against the caller"). `oldValue`/`newValue` + * are never populated for `reveal` (it never changes the resolved value) and + * `redacted` is always true here (only an `isSecret` key ever reaches this + * call, by construction — step 3 above refuses any other key before this + * point). + */ +function recordReveal( + history: PgHistoryRepository, + args: { definitionId: import('../types').KeyDefinitionEntityId; namespace: string; key: string; scope: Scope; actorId: string; reason: string }, +) { + return history.append({ + definitionId: args.definitionId, + namespace: args.namespace, + key: args.key, + scope: args.scope, + action: 'reveal', + redacted: true, + actor: { actorType: 'user', actorId: args.actorId }, + reason: args.reason, + }); +} diff --git a/services/config-service/src/routes/write.router.ts b/services/config-service/src/routes/write.router.ts index 52616ee0f..fafccb280 100644 --- a/services/config-service/src/routes/write.router.ts +++ b/services/config-service/src/routes/write.router.ts @@ -1,11 +1,13 @@ /** - * FFRNT-158 (FF-EPIC-17-S6) write-surface aggregator. + * FFRNT-158 (FF-EPIC-17-S6) + FFRNT-280 (FF-EPIC-18) write-surface aggregator. * - * Composes every write route this story owns (`POST /v1/namespaces`, - * `PUT /v1/namespaces/{namespace}/keys`, `PUT /v1/config`) into ONE router so + * Composes every non-GET route on the surface (`POST /v1/namespaces`, + * `PUT /v1/namespaces/{namespace}/keys`, `PUT /v1/config`, and the + * reveal-once `POST /v1/config/secrets/reveal`) into ONE router so * `src/app.ts`'s EXTENSION POINT only ever needs a single line for this - * story's half of the surface — the sibling GET routes (FFRNT-157) are a - * separate aggregator mounted on their own line. + * half of the surface — the sibling GET routes (FFRNT-157 + FFRNT-280's own + * `GET /v1/config/history`) are a separate aggregator mounted on their own + * line. * * Self-contained: builds its own `Pool` from `DATABASE_URL` when one isn't * injected, so mounting it never requires changing `createApp()`'s @@ -19,6 +21,7 @@ import { createPool } from '../db'; import { createNamespacesWriteRouter } from './namespaces.write'; import { createKeyDefinitionsWriteRouter } from './keys.write'; import { createConfigWriteRouter } from './config.write'; +import { createSecretsWriteRouter } from './secrets.write'; export function createWriteRouter(pool?: Pool): Router { const resolvedPool = pool ?? createPool(loadConfig().databaseUrl ?? process.env.DATABASE_URL ?? ''); @@ -27,5 +30,6 @@ export function createWriteRouter(pool?: Pool): Router { router.use(createNamespacesWriteRouter(resolvedPool)); router.use(createKeyDefinitionsWriteRouter(resolvedPool)); router.use(createConfigWriteRouter(resolvedPool)); + router.use(createSecretsWriteRouter(resolvedPool)); return router; } diff --git a/services/config-service/src/types.ts b/services/config-service/src/types.ts index 2f47f5aba..0ceabae79 100644 --- a/services/config-service/src/types.ts +++ b/services/config-service/src/types.ts @@ -21,6 +21,8 @@ import type { EntityId } from '@izzywdev/fuzefront-identity'; export type NamespaceEntityId = EntityId<'namespace'>; /** Branded TypeID of a key definition (`ckd_…`). */ export type KeyDefinitionEntityId = EntityId<'keyDefinition'>; +/** Branded TypeID of an append-only change-history entry (`cvh_…`). */ +export type ConfigHistoryEntryId = EntityId<'configHistory'>; export type ScopeType = 'platform' | 'portal' | 'org' | 'user'; @@ -160,3 +162,48 @@ export interface EffectiveConfigEntry { warning: string | null; definition: KeyDefinition; } + +/** + * What kind of principal performed a change or reveal (openapi.yaml + * `ActorType`). Polymorphic — `Actor` always carries this alongside + * `actorId`, because not every history entry is attributable to a human + * caller (e.g. a platform-owned key reconciled by an automated process). + */ +export type ActorType = 'user' | 'system'; + +/** Who performed one recorded change or reveal (openapi.yaml `Actor`). */ +export interface Actor { + actorType: ActorType; + /** Null exactly when `actorType` is `system`. */ + actorId: string | null; +} + +/** + * What a history entry recorded (openapi.yaml `ConfigHistoryAction`). + * `reveal` is a read-time action — it never changes the resolved value. + */ +export type ConfigHistoryAction = 'set' | 'unset' | 'lock' | 'unlock' | 'reveal'; + +/** + * One append-only row in a key's change trail at one exact scope (openapi.yaml + * `ConfigHistoryEntry`). Entries are never edited or deleted — a revert or a + * reveal always adds a new entry rather than touching an existing one. + */ +export interface ConfigHistoryEntry { + id: ConfigHistoryEntryId; + namespace: string; + key: string; + scope: Scope; + action: ConfigHistoryAction; + /** Always `null` when `redacted` is `true`. See openapi.yaml for the per-action rules. */ + oldValue: unknown; + /** Always `null` when `redacted` is `true`. See openapi.yaml for the per-action rules. */ + newValue: unknown; + /** True when the key is `isSecret` — `oldValue`/`newValue` are then always `null`. */ + redacted: boolean; + actor: Actor; + reason: string | null; + /** The history entry a revert replayed, if this entry was written by one. */ + revertOf: ConfigHistoryEntryId | null; + occurredAt: string; +} diff --git a/services/config-service/src/validation/requestShapes.ts b/services/config-service/src/validation/requestShapes.ts index da2faaeef..221efdd51 100644 --- a/services/config-service/src/validation/requestShapes.ts +++ b/services/config-service/src/validation/requestShapes.ts @@ -168,3 +168,26 @@ const validateKeyDefinitionManifest: ValidateFunction = ajv.compile(KEY_DEFINITI export function validateKeyDefinitionManifestShape(body: unknown): ShapeValidationResult { return run(validateKeyDefinitionManifest, body); } + +// ── RevealSecretRequest (POST /v1/config/secrets/reveal) — FF-EPIC-18 (FFRNT-280) ── + +const REVEAL_SECRET_REQUEST_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['namespace', 'scope', 'key', 'reason'], + properties: { + namespace: { type: 'string', minLength: 1, maxLength: 200 }, + scope: SCOPE_SCHEMA, + key: { type: 'string', minLength: 1, maxLength: 200 }, + // Required, not optional — unlike ConfigWriteRequest.reason — a reveal is + // read access to a live secret, not a change a reviewer can reconstruct + // from a diff (openapi.yaml RevealSecretRequest.reason). + reason: { type: 'string', minLength: 1, maxLength: 500 }, + }, +}; + +const validateRevealSecretRequest: ValidateFunction = ajv.compile(REVEAL_SECRET_REQUEST_SCHEMA); + +export function validateRevealSecretRequestShape(body: unknown): ShapeValidationResult { + return run(validateRevealSecretRequest, body); +} diff --git a/services/config-service/tests/db.test.ts b/services/config-service/tests/db.test.ts index 2bbea7f44..59788be49 100644 --- a/services/config-service/tests/db.test.ts +++ b/services/config-service/tests/db.test.ts @@ -25,7 +25,12 @@ function withoutComments(sql: string): string { describe('migrations directory', () => { it('is ordered and every file is idempotent (no bare CREATE TABLE/INDEX without IF NOT EXISTS)', () => { const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); - expect(files.sort()).toEqual(['001_config_namespaces.sql', '002_config_key_definitions.sql', '003_config_values.sql']); + expect(files.sort()).toEqual([ + '001_config_namespaces.sql', + '002_config_key_definitions.sql', + '003_config_values.sql', + '004_config_history.sql', + ]); for (const file of files) { const code = withoutComments(readMigration(file)); @@ -44,7 +49,7 @@ describe('migrations directory', () => { // the schema, exactly as billing-service does (billing.customers). it('schema-qualifies every table reference (config.) — never bare', () => { const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')); - const tables = ['config_namespaces', 'config_key_definitions', 'config_values']; + const tables = ['config_namespaces', 'config_key_definitions', 'config_values', 'config_history']; for (const file of files) { const code = withoutComments(readMigration(file)); for (const table of tables) { @@ -178,3 +183,73 @@ describe('003_config_values.sql — S3 AC1 shape', () => { expect(sql).toMatch(/CREATE UNIQUE INDEX IF NOT EXISTS config_values_unique_scoped\s*\n\s*ON config\.config_values \(definition_id, scope_type, scope_id\)\s*\n\s*WHERE scope_type <> 'platform'/); }); }); + +describe('004_config_history.sql — FF-EPIC-18 (FFRNT-280) shape', () => { + const sql = readMigration('004_config_history.sql'); + + it('creates config_history idempotently', () => { + expect(sql).toMatch(/CREATE TABLE IF NOT EXISTS config\.config_history/i); + }); + + it('has every column the frozen contract\'s ConfigHistoryEntry needs', () => { + const required = [ + 'namespace', + 'key', + 'scope_type', + 'scope_id', + 'action', + 'old_value', + 'new_value', + 'redacted', + 'actor_type', + 'actor_id', + 'reason', + 'revert_of', + 'occurred_at', + ]; + for (const column of required) { + expect(sql).toMatch(new RegExp(`\\b${column}\\b`)); + } + }); + + it('has no id DEFAULT (ids are app-minted via mintId, never gen_random_uuid())', () => { + const table = sql.slice(sql.indexOf('CREATE TABLE'), sql.indexOf(');')); + expect(table).not.toMatch(/gen_random_uuid/i); + }); + + it('constrains action to the ConfigHistoryAction enum, including reveal', () => { + for (const a of ['set', 'unset', 'lock', 'unlock', 'reveal']) { + expect(sql).toMatch(new RegExp(`'${a}'`)); + } + }); + + it('constrains actor_type to user/system', () => { + expect(sql).toMatch(/'user'/); + expect(sql).toMatch(/'system'/); + }); + + it('references config_key_definitions(id) — intra-service FK', () => { + expect(sql).toMatch(/REFERENCES config\.config_key_definitions\(id\) ON DELETE CASCADE/); + }); + + it('has NO real FK on scope_id (polymorphic, cross-table — same reasoning as config_values)', () => { + const scopeIdLine = sql.split('\n').find((l) => /^\s*scope_id\s+UUID/.test(l)); + expect(scopeIdLine).toBeDefined(); + expect(scopeIdLine).not.toMatch(/REFERENCES/); + }); + + it('CHECKs scope_id is null iff scope_type is platform', () => { + expect(sql).toMatch(/scope_type = 'platform' AND scope_id IS NULL/); + expect(sql).toMatch(/scope_type <> 'platform' AND scope_id IS NOT NULL/); + }); + + it('is self-referential on revert_of (a past entry a revert replayed)', () => { + expect(sql).toMatch(/revert_of\s+UUID\s+REFERENCES config\.config_history\(id\)/); + }); + + it('indexes the listConfigHistory hot path: (namespace, key, scope_type, scope_id, occurred_at DESC, id DESC)', () => { + expect(sql).toMatch( + /ON config\.config_history \(namespace, key, scope_type, scope_id, occurred_at DESC, id DESC\)/, + ); + }); +}); diff --git a/services/config-service/tests/helpers/fakeDb.ts b/services/config-service/tests/helpers/fakeDb.ts index 8fe44b388..4dffa2528 100644 --- a/services/config-service/tests/helpers/fakeDb.ts +++ b/services/config-service/tests/helpers/fakeDb.ts @@ -66,10 +66,29 @@ interface ValueRow { updated_at: Date; } +interface HistoryRow { + id: string; + definition_id: string; + namespace: string; + key: string; + scope_type: string; + scope_id: string | null; + action: string; + old_value: unknown; + new_value: unknown; + redacted: boolean; + actor_type: string; + actor_id: string | null; + reason: string | null; + revert_of: string | null; + occurred_at: Date; +} + interface State { namespaces: NamespaceRow[]; keyDefs: KeyDefRow[]; values: ValueRow[]; + history: HistoryRow[]; } function clone(v: T): T { @@ -88,7 +107,7 @@ function nextId(prefix: string): string { } export class FakeDb { - private state: State = { namespaces: [], keyDefs: [], values: [] }; + private state: State = { namespaces: [], keyDefs: [], values: [], history: [] }; private snapshots: State[] = []; seedNamespace(row: Partial & { id: string; namespace: string }): void { @@ -147,6 +166,10 @@ export class FakeDb { get keyDefRows(): KeyDefRow[] { return this.state.keyDefs; } + /** Rows currently in config_history — for assertions after a route call. */ + get historyRows(): HistoryRow[] { + return this.state.history; + } private query = async (sql: string, params: unknown[] = []): Promise<{ rows: unknown[] }> => { const s = sql.trim(); @@ -166,11 +189,11 @@ export class FakeDb { } // ── config_namespaces ──────────────────────────────────────────────── - if (s.includes('FROM config_namespaces') && s.includes('WHERE namespace = $1')) { + if (s.includes('FROM config.config_namespaces') && s.includes('WHERE namespace = $1')) { const row = this.state.namespaces.find((n) => n.namespace === params[0]); return { rows: row ? [row] : [] }; } - if (s.includes('INSERT INTO config_namespaces')) { + if (s.includes('INSERT INTO config.config_namespaces')) { const [id, namespace, displayName, description, ownerAppId] = params as [string, string, string, string | null, string | null]; let row = this.state.namespaces.find((n) => n.namespace === namespace); let inserted = false; @@ -188,11 +211,11 @@ export class FakeDb { } // ── config_key_definitions ─────────────────────────────────────────── - if (s.startsWith('SELECT') && s.includes('FROM config_key_definitions') && s.includes('WHERE namespace_id = $1') && !s.includes('AND key')) { + if (s.startsWith('SELECT') && s.includes('FROM config.config_key_definitions') && s.includes('WHERE namespace_id = $1') && !s.includes('AND key')) { const rows = this.state.keyDefs.filter((d) => d.namespace_id === params[0]); return { rows }; } - if (s.includes('INSERT INTO config_key_definitions')) { + if (s.includes('INSERT INTO config.config_key_definitions')) { const [ id, namespaceId, key, displayName, description, helpUrl, category, sortOrder, tags, valueType, schema, enumValues, defaultValue, allowedScopes, @@ -210,7 +233,7 @@ export class FakeDb { this.state.keyDefs.push(row); return { rows: [row] }; } - if (s.includes('UPDATE config_key_definitions SET deprecated_at')) { + if (s.includes('UPDATE config.config_key_definitions SET deprecated_at')) { const ids = params[0] as string[]; for (const d of this.state.keyDefs) { if (ids.includes(d.id) && d.deprecated_at === null) { @@ -220,7 +243,7 @@ export class FakeDb { } return { rows: [] }; } - if (s.includes('UPDATE config_key_definitions SET')) { + if (s.includes('UPDATE config.config_key_definitions SET')) { const [ id, displayName, description, helpUrl, category, sortOrder, tags, valueType, schema, enumValues, defaultValue, allowedScopes, @@ -252,7 +275,7 @@ export class FakeDb { } // ── config_values ───────────────────────────────────────────────────── - if (s.includes('FROM config_values') && s.includes('definition_id = ANY($1::uuid[])')) { + if (s.includes('FROM config.config_values') && s.includes('definition_id = ANY($1::uuid[])')) { const definitionIds = params[0] as string[]; const wantsPlatform = s.includes("scope_type = 'platform'"); const pairs: [string, string][] = []; @@ -267,11 +290,11 @@ export class FakeDb { ); return { rows }; } - if (s.startsWith('SELECT') && s.includes('FROM config_values') && s.includes('WHERE definition_id = $1') && !s.includes('ANY(')) { + if (s.startsWith('SELECT') && s.includes('FROM config.config_values') && s.includes('WHERE definition_id = $1') && !s.includes('ANY(')) { const rows = this.state.values.filter((v) => v.definition_id === params[0]); return { rows }; } - if (s.includes('INSERT INTO config_values')) { + if (s.includes('INSERT INTO config.config_values')) { const [definitionId, scopeType, scopeId, valueJson, isLocked, lockReason, setByUserId] = params as any[]; let row = this.state.values.find( (v) => @@ -303,7 +326,7 @@ export class FakeDb { } return { rows: [row] }; } - if (s.includes('DELETE FROM config_values')) { + if (s.includes('DELETE FROM config.config_values')) { if (s.includes("scope_type = 'platform'")) { const [definitionId] = params as [string]; this.state.values = this.state.values.filter((v) => !(v.definition_id === definitionId && v.scope_type === 'platform')); @@ -316,6 +339,64 @@ export class FakeDb { return { rows: [] }; } + // ── config_history (append-only — INSERT only, never UPDATE/DELETE) ──── + if (s.includes('INSERT INTO config.config_history')) { + const [ + id, definitionId, namespace, key, scopeType, scopeId, action, + oldValueJson, newValueJson, redacted, actorType, actorId, reason, revertOf, + ] = params as any[]; + const row: HistoryRow = { + id, + definition_id: definitionId, + namespace, + key, + scope_type: scopeType, + scope_id: scopeType === 'platform' ? null : scopeId, + action, + old_value: JSON.parse(oldValueJson), + new_value: JSON.parse(newValueJson), + redacted, + actor_type: actorType, + actor_id: actorId, + reason, + revert_of: revertOf, + occurred_at: nextTimestamp(), + }; + this.state.history.push(row); + return { rows: [row] }; + } + if (s.startsWith('SELECT') && s.includes('FROM config.config_history')) { + // Positional, matching PgHistoryRepository.listPage's own param order + // EXACTLY (namespace, key, scopeType, [scopeId], [cursorOccurredAt, + // cursorId], limit) — see src/repositories/history.repository.ts. + let i = 0; + const namespace = params[i++] as string; + const key = params[i++] as string; + const scopeType = params[i++] as string; + const scopeId = scopeType === 'platform' ? null : (params[i++] as string); + let cursorOccurredAt: string | undefined; + let cursorId: string | undefined; + if (s.includes('(occurred_at, id) <')) { + cursorOccurredAt = params[i++] as string; + cursorId = params[i++] as string; + } + const limit = params[i++] as number; + + let rows = this.state.history.filter( + (h) => h.namespace === namespace && h.key === key && h.scope_type === scopeType && h.scope_id === scopeId, + ); + rows = [...rows].sort( + (a, b) => b.occurred_at.getTime() - a.occurred_at.getTime() || (a.id < b.id ? 1 : -1), + ); + if (cursorOccurredAt !== undefined && cursorId !== undefined) { + rows = rows.filter((h) => { + const t = h.occurred_at.toISOString(); + return t < cursorOccurredAt! || (t === cursorOccurredAt && h.id < cursorId!); + }); + } + return { rows: rows.slice(0, limit) }; + } + throw new Error(`FakeDb: unrecognised query: ${s}`); }; diff --git a/services/config-service/tests/middleware/authz.test.ts b/services/config-service/tests/middleware/authz.test.ts index 9593f6cb9..69077b24d 100644 --- a/services/config-service/tests/middleware/authz.test.ts +++ b/services/config-service/tests/middleware/authz.test.ts @@ -45,7 +45,7 @@ describe('requireConfigPermission', () => { _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: true }), bulkCheck: jest.fn(), - } as AuthzClient); + } as unknown as AuthzClient); const middleware = requireConfigPermission('ConfigScope', 'read'); const req = makeReq({ identity: { userId: 'usr_1', tenantId: null, roles: [], authMode: 'legacy-hs256' } } as any); const res = makeRes(); @@ -59,7 +59,7 @@ describe('requireConfigPermission', () => { it('403s (does not call next) when the Security API denies', async () => { const check = jest.fn().mockResolvedValue({ allow: false }); - _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as AuthzClient); + _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as unknown as AuthzClient); const middleware = requireConfigPermission('ConfigScope', 'read'); const req = makeReq({ identity: { userId: 'usr_1', tenantId: 'org_1', roles: [], authMode: 'legacy-hs256' }, @@ -91,7 +91,7 @@ describe('requireConfigPermission', () => { new AuthzError('DECISION_UNAVAILABLE', 'Security API request failed: timeout; denying.'), ), bulkCheck: jest.fn(), - } as AuthzClient); + } as unknown as AuthzClient); const middleware = requireConfigPermission('ConfigScope', 'read'); const req = makeReq({ identity: { userId: 'usr_1', tenantId: null, roles: [], authMode: 'legacy-hs256' } } as any); const res = makeRes(); @@ -105,7 +105,7 @@ describe('requireConfigPermission', () => { it('derives the resource-instance key via resourceKeyOf when supplied', async () => { const check = jest.fn().mockResolvedValue({ allow: true }); - _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as AuthzClient); + _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as unknown as AuthzClient); const middleware = requireConfigPermission('ConfigCatalog', 'read', (req) => (req.params as any).namespace); const req = makeReq({ identity: { userId: 'usr_1', tenantId: null, roles: [], authMode: 'legacy-hs256' }, @@ -130,7 +130,7 @@ describe('requireConfigPermission', () => { it('falls back to the "platform" tenant when the identity has no tenantId', async () => { const check = jest.fn().mockResolvedValue({ allow: true }); - _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as AuthzClient); + _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as unknown as AuthzClient); const middleware = requireConfigPermission('ConfigScope', 'read'); const req = makeReq({ identity: { userId: 'usr_1', tenantId: null, roles: [], authMode: 'legacy-hs256' } } as any); const res = makeRes(); diff --git a/services/config-service/tests/repositories/history.repository.test.ts b/services/config-service/tests/repositories/history.repository.test.ts new file mode 100644 index 000000000..de054aed2 --- /dev/null +++ b/services/config-service/tests/repositories/history.repository.test.ts @@ -0,0 +1,296 @@ +/** + * Unit tests for PgHistoryRepository (FF-EPIC-18 / FFRNT-280), mirroring + * tests/repositories/value.repository.test.ts's convention: a mocked + * `pg.Pool.query` capturing SQL/params, no real database. + */ + +import { randomUUID } from 'crypto'; +import { configureIdentity, mintId } from '@izzywdev/fuzefront-identity'; +import { PgHistoryRepository } from '../../src/repositories/history.repository'; +import { KeyDefinitionEntityId, Scope } from '../../src/types'; +import { decodeCursor, encodeCursor } from '../../src/pagination'; + +// Same widening as value.repository.test.ts — org/portal/user ids are not +// yet family-wide backfilled to the prefixed TypeID form. +beforeAll(() => { + configureIdentity({ legacyUuidTypes: new Set(['portal', 'organization', 'user']) }); +}); + +function fakePool(queryImpl?: jest.Mock) { + return { query: queryImpl ?? jest.fn() } as any; +} + +const NOW = new Date('2026-01-01T00:00:00.000Z'); +const DEFINITION_ID = mintId('keyDefinition') as KeyDefinitionEntityId; +const LEGACY_ORG_UUID = '11111111-1111-7111-8111-111111111111'; + +function historyRowFromInsertParams(params: unknown[]) { + const [id, definitionId, namespace, key, scopeType, scopeId, action, oldValueJson, newValueJson, redacted, actorType, actorId, reason, revertOf] = + params as any[]; + return { + id, + definition_id: definitionId, + namespace, + key, + scope_type: scopeType, + scope_id: scopeId, + action, + old_value: JSON.parse(oldValueJson), + new_value: JSON.parse(newValueJson), + redacted, + actor_type: actorType, + actor_id: actorId, + reason, + revert_of: revertOf, + occurred_at: NOW, + }; +} + +describe('PgHistoryRepository.append', () => { + it('mints its own id (cvh_ prefix) — never accepts a caller-supplied one', async () => { + let capturedParams: unknown[] = []; + const query = jest.fn(async (_sql: string, params: unknown[]) => { + capturedParams = params; + return { rows: [historyRowFromInsertParams(params)] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const entry = await repo.append({ + definitionId: DEFINITION_ID, + namespace: 'fuzefront.chat', + key: 'ui.theme.density', + scope: { scopeType: 'platform', scopeId: null }, + action: 'set', + oldValue: 'comfortable', + newValue: 'compact', + redacted: false, + actor: { actorType: 'user', actorId: 'usr_1' }, + reason: 'testing', + }); + + expect(entry.id).toMatch(/^cvh_/); + expect(capturedParams[6]).toBe('set'); + }); + + it('redaction wins: an isSecret entry stores null for old/new value regardless of what was passed in', async () => { + let capturedParams: unknown[] = []; + const query = jest.fn(async (_sql: string, params: unknown[]) => { + capturedParams = params; + return { rows: [historyRowFromInsertParams(params)] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const entry = await repo.append({ + definitionId: DEFINITION_ID, + namespace: 'fuzefront.chat', + key: 'api.token', + scope: { scopeType: 'platform', scopeId: null }, + action: 'set', + oldValue: 'old-secret-plaintext', + newValue: 'new-secret-plaintext', + redacted: true, + actor: { actorType: 'user', actorId: 'usr_1' }, + reason: 'rotation', + }); + + // Never even serialized to the params the query received. + expect(capturedParams[7]).toBe('null'); + expect(capturedParams[8]).toBe('null'); + expect(entry.oldValue).toBeNull(); + expect(entry.newValue).toBeNull(); + expect(entry.redacted).toBe(true); + }); + + it('a system actor carries a null actorId', async () => { + let capturedParams: unknown[] = []; + const query = jest.fn(async (_sql: string, params: unknown[]) => { + capturedParams = params; + return { rows: [historyRowFromInsertParams(params)] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const entry = await repo.append({ + definitionId: DEFINITION_ID, + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'platform', scopeId: null }, + action: 'set', + newValue: 'v', + redacted: false, + actor: { actorType: 'system', actorId: null }, + }); + + expect(capturedParams[11]).toBeNull(); + expect(entry.actor).toEqual({ actorType: 'system', actorId: null }); + }); + + it('scopeId is null exactly when scopeType is platform, and set otherwise', async () => { + let capturedParams: unknown[] = []; + const query = jest.fn(async (_sql: string, params: unknown[]) => { + capturedParams = params; + return { rows: [historyRowFromInsertParams(params)] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + await repo.append({ + definitionId: DEFINITION_ID, + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'org', scopeId: LEGACY_ORG_UUID }, + action: 'unset', + redacted: false, + actor: { actorType: 'user', actorId: 'usr_1' }, + }); + + expect(capturedParams[4]).toBe('org'); + expect(capturedParams[5]).toBe(LEGACY_ORG_UUID); + }); + + it('carries revertOf through when supplied, converted to storage form', async () => { + const revertOfId = mintId('configHistory'); + let capturedParams: unknown[] = []; + const query = jest.fn(async (_sql: string, params: unknown[]) => { + capturedParams = params; + return { rows: [historyRowFromInsertParams(params)] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const entry = await repo.append({ + definitionId: DEFINITION_ID, + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'platform', scopeId: null }, + action: 'set', + newValue: 'v', + redacted: false, + actor: { actorType: 'user', actorId: 'usr_1' }, + revertOf: revertOfId, + }); + + expect(capturedParams[13]).toBeTruthy(); + expect(entry.revertOf).toBe(revertOfId); + }); +}); + +describe('PgHistoryRepository.listPage', () => { + it('filters by namespace + key + exact scope, and paginates newest-first (occurred_at, id) DESC', async () => { + let capturedSql = ''; + let capturedParams: unknown[] = []; + const query = jest.fn(async (sql: string, params: unknown[]) => { + capturedSql = sql; + capturedParams = params; + return { rows: [] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const scope: Scope = { scopeType: 'org', scopeId: LEGACY_ORG_UUID }; + await repo.listPage({ namespace: 'fuzefront.chat', key: 'ui.theme.density', scope, limit: 20 }); + + expect(capturedSql).toMatch(/namespace = \$1/); + expect(capturedSql).toMatch(/key = \$2/); + expect(capturedSql).toMatch(/scope_type = \$3/); + expect(capturedSql).toMatch(/scope_id = \$4/); + expect(capturedSql).toMatch(/ORDER BY occurred_at DESC, id DESC/); + expect(capturedParams[0]).toBe('fuzefront.chat'); + expect(capturedParams[1]).toBe('ui.theme.density'); + expect(capturedParams[2]).toBe('org'); + expect(capturedParams[3]).toBe(LEGACY_ORG_UUID); + // limit + 1, to detect a further page without a second query. + expect(capturedParams[capturedParams.length - 1]).toBe(21); + }); + + it('matches scope_id IS NULL (not a param) for the platform singleton tier', async () => { + let capturedSql = ''; + let capturedParams: unknown[] = []; + const query = jest.fn(async (sql: string, params: unknown[]) => { + capturedSql = sql; + capturedParams = params; + return { rows: [] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + await repo.listPage({ namespace: 'fuzefront.chat', key: 'k', scope: { scopeType: 'platform', scopeId: null }, limit: 20 }); + + expect(capturedSql).toMatch(/scope_id IS NULL/); + // Only namespace, key, scopeType, and limit — no scope_id param. + expect(capturedParams).toHaveLength(4); + }); + + it('round-trips the cursor via a row-value comparison on (occurred_at, id)', async () => { + let capturedSql = ''; + let capturedParams: unknown[] = []; + const query = jest.fn(async (sql: string, params: unknown[]) => { + capturedSql = sql; + capturedParams = params; + return { rows: [] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + const cursor = encodeCursor({ occurredAt: '2026-01-01T00:00:00.000Z', id: 'cvh_x' }); + await repo.listPage({ + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'platform', scopeId: null }, + limit: 10, + cursor, + }); + + expect(capturedSql).toMatch(/\(occurred_at, id\) < \(\$4::timestamptz, \$5::uuid\)/); + expect(capturedParams[3]).toBe('2026-01-01T00:00:00.000Z'); + expect(capturedParams[4]).toBe('cvh_x'); + }); + + it('a malformed cursor degrades to page 1 rather than erroring', async () => { + let capturedSql = ''; + const query = jest.fn(async (sql: string) => { + capturedSql = sql; + return { rows: [] }; + }); + const repo = new PgHistoryRepository(fakePool(query)); + + await repo.listPage({ + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'platform', scopeId: null }, + limit: 10, + cursor: 'not-a-real-cursor', + }); + + expect(capturedSql).not.toMatch(/occurred_at, id\) { + const ids = Array.from({ length: 3 }, () => randomUUID()); + const rows = Array.from({ length: 3 }, (_, i) => ({ + id: ids[i], + namespace: 'fuzefront.chat', + key: 'k', + scope_type: 'platform', + scope_id: null, + action: 'set', + old_value: null, + new_value: 'v', + redacted: false, + actor_type: 'user', + actor_id: 'usr_1', + reason: null, + revert_of: null, + occurred_at: new Date(2026, 0, 1 + i), + })); + const query = jest.fn(async () => ({ rows })); + const repo = new PgHistoryRepository(fakePool(query)); + + const result = await repo.listPage({ + namespace: 'fuzefront.chat', + key: 'k', + scope: { scopeType: 'platform', scopeId: null }, + limit: 2, + }); + + expect(result.items).toHaveLength(2); + expect(result.pageInfo.hasNextPage).toBe(true); + expect(result.pageInfo.nextCursor).toBeTruthy(); + const decoded = decodeCursor<{ occurredAt: string; id: string }>(result.pageInfo.nextCursor!); + expect(decoded?.id).toBe(ids[1]); + }); +}); diff --git a/services/config-service/tests/routes/config-read.routes.test.ts b/services/config-service/tests/routes/config-read.routes.test.ts index db63c4fa1..04c40b060 100644 --- a/services/config-service/tests/routes/config-read.routes.test.ts +++ b/services/config-service/tests/routes/config-read.routes.test.ts @@ -12,6 +12,7 @@ import express from 'express'; import request from 'supertest'; import jwt from 'jsonwebtoken'; +import type { AuthzClient } from '@fuzefront/auth'; import { mintId } from '@izzywdev/fuzefront-identity'; import { createConfigReadRouter } from '../../src/routes/config-read.routes'; import { _setAuthzClientForTesting, makeNoOpProxy } from '../../src/middleware/authz'; @@ -22,7 +23,8 @@ import { ListKeyDefinitionsResult, } from '../../src/repositories/key-definition.repository'; import { ValueRepository, SetValueInput } from '../../src/repositories/value.repository'; -import { ConfigValue, KeyDefinition, Namespace, NamespaceEntityId, Scope } from '../../src/types'; +import { HistoryRepository, ListHistoryArgs, ListHistoryResult } from '../../src/repositories/history.repository'; +import { ConfigHistoryEntry, ConfigValue, KeyDefinition, Namespace, NamespaceEntityId, Scope } from '../../src/types'; import { decodeCursor, encodeCursor } from '../../src/pagination'; const JWT_SECRET = 'test-secret-ffrnt-157-routes'; @@ -133,6 +135,40 @@ class FakeValueRepository implements ValueRepository { } } +class FakeHistoryRepository implements HistoryRepository { + constructor(public entries: ConfigHistoryEntry[] = []) {} + + async append(): Promise { + throw new Error('not implemented — write surface owns appending history'); + } + async listPage(args: ListHistoryArgs): Promise { + const matching = this.entries + .filter( + (e) => + e.namespace === args.namespace && + e.key === args.key && + e.scope.scopeType === args.scope.scopeType && + e.scope.scopeId === args.scope.scopeId, + ) + .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || (b.id > a.id ? 1 : -1)); + + let startIndex = 0; + if (args.cursor) { + const c = decodeCursor<{ occurredAt: string; id: string }>(args.cursor); + if (c) { + startIndex = matching.findIndex((e) => e.occurredAt === c.occurredAt && e.id === c.id) + 1; + } + } + + const window = matching.slice(startIndex, startIndex + args.limit + 1); + const hasNextPage = window.length > args.limit; + const items = hasNextPage ? window.slice(0, args.limit) : window; + const last = items[items.length - 1]; + const nextCursor = hasNextPage && last ? encodeCursor({ occurredAt: last.occurredAt, id: last.id }) : null; + return { items, pageInfo: { hasNextPage, nextCursor } }; + } +} + // ─── Fixture builders ─────────────────────────────────────────────────────── let seq = 0; @@ -180,22 +216,43 @@ function makeDefinition(namespaceId: string, overrides: Partial = }; } +function makeHistoryEntry(overrides: Partial = {}): ConfigHistoryEntry { + seq += 1; + return { + id: mintId('configHistory'), + namespace: 'fuzefront.chat', + key: 'ui.theme.density', + scope: { scopeType: 'org', scopeId: 'org_1' }, + action: 'set', + oldValue: null, + newValue: 'compact', + redacted: false, + actor: { actorType: 'user', actorId: 'usr_1' }, + reason: null, + revertOf: null, + occurredAt: new Date(2026, 0, 1, 0, 0, seq).toISOString(), + ...overrides, + }; +} + function makeApp(deps: { namespaces?: Namespace[]; definitions?: KeyDefinition[]; values?: ConfigValue[]; + history?: ConfigHistoryEntry[]; }) { const namespaceRepo = new FakeNamespaceRepository(deps.namespaces ?? []); const keyDefinitionRepo = new FakeKeyDefinitionRepository(deps.definitions ?? []); const valueRepo = new FakeValueRepository(deps.values ?? []); + const historyRepo = new FakeHistoryRepository(deps.history ?? []); const app = express(); app.use(express.json()); - app.use('/v1', createConfigReadRouter({ namespaceRepo, keyDefinitionRepo, valueRepo })); - return { app, namespaceRepo, keyDefinitionRepo, valueRepo }; + app.use('/v1', createConfigReadRouter({ namespaceRepo, keyDefinitionRepo, valueRepo, historyRepo })); + return { app, namespaceRepo, keyDefinitionRepo, valueRepo, historyRepo }; } beforeEach(() => { - _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: true }), bulkCheck: jest.fn() }); + _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: true }), bulkCheck: jest.fn() } as unknown as AuthzClient); }); afterEach(() => { _setAuthzClientForTesting(makeNoOpProxy()); @@ -269,7 +326,7 @@ describe('GET /v1/namespaces', () => { }); it('403s when the Security API denies', async () => { - _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: false }), bulkCheck: jest.fn() }); + _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: false }), bulkCheck: jest.fn() } as unknown as AuthzClient); const { app } = makeApp({ namespaces: [makeNamespace()] }); const res = await request(app).get('/v1/namespaces').set('Authorization', `Bearer ${token()}`); @@ -308,7 +365,7 @@ describe('GET /v1/namespaces/:namespace/keys', () => { it('403s a non-admin caller passing includeHidden=true, without leaking the hidden keys', async () => { // First call ('read') allowed, second ('admin') denied. const check = jest.fn().mockResolvedValueOnce({ allow: true }).mockResolvedValueOnce({ allow: false }); - _setAuthzClientForTesting({ check, bulkCheck: jest.fn() }); + _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as unknown as AuthzClient); const ns = makeNamespace({ namespace: 'fuzefront.chat' }); const { app } = makeApp({ namespaces: [ns], definitions: [makeDefinition(ns.id, { isHidden: true })] }); @@ -320,7 +377,7 @@ describe('GET /v1/namespaces/:namespace/keys', () => { }); it('includes isHidden keys for an admin passing includeHidden=true', async () => { - _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: true }), bulkCheck: jest.fn() }); + _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: true }), bulkCheck: jest.fn() } as unknown as AuthzClient); const ns = makeNamespace({ namespace: 'fuzefront.chat' }); const hidden = makeDefinition(ns.id, { key: 'hidden.key', isHidden: true }); const { app } = makeApp({ namespaces: [ns], definitions: [hidden] }); @@ -432,7 +489,7 @@ describe('GET /v1/config', () => { }); it('403s BEFORE checking namespace existence — leaks nothing about whether the scope exists (S5 AC4)', async () => { - _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: false }), bulkCheck: jest.fn() }); + _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: false }), bulkCheck: jest.fn() } as unknown as AuthzClient); const { app } = makeApp({}); // no namespaces at all — would 404 if reached const res = await request(app) @@ -547,3 +604,200 @@ describe('GET /v1/config', () => { expect(res.status).toBe(200); }); }); + +// ─── GET /v1/config/history (FF-EPIC-18 / FFRNT-280) ─────────────────────── + +describe('GET /v1/config/history', () => { + it('401s with no credential', async () => { + const { app } = makeApp({}); + const res = await request(app).get( + '/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=k', + ); + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHENTICATED'); + }); + + it('400s when key is missing (the one param this endpoint requires beyond GET /v1/config)', async () => { + const { app } = makeApp({}); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=platform') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('400s when namespace is missing', async () => { + const { app } = makeApp({}); + const res = await request(app) + .get('/v1/config/history?scopeType=platform&key=k') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(400); + }); + + it('400s on an invalid scopeType', async () => { + const { app } = makeApp({}); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=galaxy&key=k') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(400); + }); + + it('400s when scopeId is missing for a non-platform tier', async () => { + const { app } = makeApp({}); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=org&key=k') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(400); + }); + + it("403s on a denied 'audit' grant BEFORE checking namespace existence — leaks nothing about whether the scope exists", async () => { + _setAuthzClientForTesting({ check: jest.fn().mockResolvedValue({ allow: false }), bulkCheck: jest.fn() } as unknown as AuthzClient); + const { app } = makeApp({}); // no namespaces at all — would 404 if reached + + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.does-not-exist&scopeType=org&scopeId=org_1&key=k') + .set('Authorization', `Bearer ${token()}`); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('FORBIDDEN'); + }); + + it("checks the 'audit' action, distinct from 'read'", async () => { + const check = jest.fn().mockResolvedValue({ allow: true }); + _setAuthzClientForTesting({ check, bulkCheck: jest.fn() } as unknown as AuthzClient); + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const def = makeDefinition(ns.id, { key: 'k' }); + const { app } = makeApp({ namespaces: [ns], definitions: [def] }); + + await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=k') + .set('Authorization', `Bearer ${token()}`); + + expect(check).toHaveBeenCalledWith( + expect.objectContaining({ action: 'audit', resource: { type: 'ConfigScope', key: 'fuzefront.chat:platform:platform' } }), + expect.any(String), + ); + }); + + it('404s when the namespace does not exist (and the caller IS authorized)', async () => { + const { app } = makeApp({}); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.nope&scopeType=platform&key=k') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(404); + }); + + it('404s when the key does not exist in that namespace', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const { app } = makeApp({ namespaces: [ns] }); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=no.such.key') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(404); + }); + + it('404s a hidden key — same masking as getKeyDefinition/GET /v1/config, never confirms existence', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const hidden = makeDefinition(ns.id, { key: 'hidden.key', isHidden: true }); + const { app } = makeApp({ namespaces: [ns], definitions: [hidden] }); + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=hidden.key') + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(404); + }); + + it('returns entries newest-first, scoped to the exact (namespace, key, scope) requested', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const def = makeDefinition(ns.id, { key: 'ui.theme.density' }); + const scope: Scope = { scopeType: 'org', scopeId: 'org_1' }; + const older = makeHistoryEntry({ namespace: 'fuzefront.chat', key: 'ui.theme.density', scope, occurredAt: '2026-01-01T00:00:00.000Z', newValue: 'comfortable' }); + const newer = makeHistoryEntry({ namespace: 'fuzefront.chat', key: 'ui.theme.density', scope, occurredAt: '2026-01-02T00:00:00.000Z', newValue: 'compact' }); + // A different key at the same scope, and the same key at a different + // scope — neither should leak into this key+scope's trail. + const otherKey = makeHistoryEntry({ namespace: 'fuzefront.chat', key: 'other.key', scope }); + const otherScope = makeHistoryEntry({ namespace: 'fuzefront.chat', key: 'ui.theme.density', scope: { scopeType: 'org', scopeId: 'org_2' } }); + const { app } = makeApp({ + namespaces: [ns], + definitions: [def], + history: [older, newer, otherKey, otherScope], + }); + + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=org&scopeId=org_1&key=ui.theme.density') + .set('Authorization', `Bearer ${token()}`); + + expect(res.status).toBe(200); + expect(res.body.items.map((e: any) => e.newValue)).toEqual(['compact', 'comfortable']); + expect(res.body.pageInfo).toEqual(expect.objectContaining({ hasNextPage: false })); + }); + + it('an empty page is not an error — the key has never changed at this exact scope', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const def = makeDefinition(ns.id, { key: 'k' }); + const { app } = makeApp({ namespaces: [ns], definitions: [def] }); + + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=k') + .set('Authorization', `Bearer ${token()}`); + + expect(res.status).toBe(200); + expect(res.body.items).toEqual([]); + }); + + it('redacts oldValue/newValue for an isSecret key, but still reports the action/actor/reason', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const secretDef = makeDefinition(ns.id, { key: 'api.token', isSecret: true, valueType: 'secret' }); + const scope: Scope = { scopeType: 'org', scopeId: 'org_1' }; + const entry = makeHistoryEntry({ + namespace: 'fuzefront.chat', + key: 'api.token', + scope, + action: 'reveal', + redacted: true, + oldValue: null, + newValue: null, + reason: 'rotating the credential', + }); + const { app } = makeApp({ namespaces: [ns], definitions: [secretDef], history: [entry] }); + + const res = await request(app) + .get('/v1/config/history?namespace=fuzefront.chat&scopeType=org&scopeId=org_1&key=api.token') + .set('Authorization', `Bearer ${token()}`); + + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + expect(res.body.items[0].redacted).toBe(true); + expect(res.body.items[0].oldValue).toBeNull(); + expect(res.body.items[0].newValue).toBeNull(); + expect(res.body.items[0].action).toBe('reveal'); + expect(res.body.items[0].reason).toBe('rotating the credential'); + }); + + it('walks the full set via nextCursor with no gaps or duplicates', async () => { + const ns = makeNamespace({ namespace: 'fuzefront.chat' }); + const def = makeDefinition(ns.id, { key: 'k' }); + const scope: Scope = { scopeType: 'platform', scopeId: null }; + const entries = Array.from({ length: 12 }, (_, i) => + makeHistoryEntry({ namespace: 'fuzefront.chat', key: 'k', scope, occurredAt: new Date(2026, 0, 1 + i).toISOString() }), + ); + const { app } = makeApp({ namespaces: [ns], definitions: [def], history: entries }); + + const seen: string[] = []; + let cursor: string | undefined; + for (let i = 0; i < 20; i++) { + const qs = cursor + ? `&limit=5&cursor=${encodeURIComponent(cursor)}` + : '&limit=5'; + const res = await request(app) + .get(`/v1/config/history?namespace=fuzefront.chat&scopeType=platform&key=k${qs}`) + .set('Authorization', `Bearer ${token()}`); + expect(res.status).toBe(200); + seen.push(...res.body.items.map((e: any) => e.id)); + if (!res.body.pageInfo.hasNextPage) break; + cursor = res.body.pageInfo.nextCursor; + } + + expect(seen).toHaveLength(12); + expect(new Set(seen).size).toBe(12); + }); +}); diff --git a/services/config-service/tests/routes/config.write.test.ts b/services/config-service/tests/routes/config.write.test.ts index 8c619e002..f5e248f67 100644 --- a/services/config-service/tests/routes/config.write.test.ts +++ b/services/config-service/tests/routes/config.write.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto'; import express from 'express'; import request from 'supertest'; -import { AuthzError } from '@fuzefront/auth'; +import { AuthzClient, AuthzError } from '@fuzefront/auth'; import { configureIdentity } from '@izzywdev/fuzefront-identity'; import { createConfigWriteRouter } from '../../src/routes/config.write'; import { FakeDb } from '../helpers/fakeDb'; @@ -87,7 +87,7 @@ describe('PUT /v1/config — auth', () => { }); it('403s when the Security API denies the write, and writes nothing', async () => { - _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] }); + _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] } as unknown as AuthzClient); const db = new FakeDb(); seedBase(db); const app = buildApp(db); @@ -108,7 +108,7 @@ describe('PUT /v1/config — auth', () => { throw new AuthzError('DECISION_UNAVAILABLE', 'Security API request failed: timeout; denying.'); }, bulkCheck: async () => [], - }); + } as unknown as AuthzClient); const db = new FakeDb(); seedBase(db); const app = buildApp(db); @@ -140,6 +140,20 @@ describe('PUT /v1/config — set', () => { expect(db.valueRows).toHaveLength(1); expect(db.valueRows[0].value).toBe('compact'); expect(db.valueRows[0].scope_type).toBe('org'); + + // FF-EPIC-18 (FFRNT-280): the write also appends a `set` history entry — + // GET /v1/config/history is worthless if PUT /v1/config never populates it. + expect(db.historyRows).toHaveLength(1); + const entry = db.historyRows[0]; + expect(entry.action).toBe('set'); + expect(entry.key).toBe('ui.theme.density'); + expect(entry.namespace).toBe(NAMESPACE); + expect(entry.scope_type).toBe('org'); + expect(entry.old_value).toBeNull(); // first-ever entry for this key at this scope + expect(entry.new_value).toBe('compact'); + expect(entry.redacted).toBe(false); + expect(entry.actor_type).toBe('user'); + expect(entry.actor_id).toBe('u1'); }); it('rejects a value that fails the key schema, VALIDATION_ERROR, nothing written', async () => { @@ -199,7 +213,7 @@ describe('PUT /v1/config — set', () => { _setAuthzClientForTesting({ check: async (check: { action: string }) => ({ allow: check.action !== 'write-system' }), bulkCheck: async () => [], - }); + } as unknown as AuthzClient); const db = new FakeDb(); seedBase(db); const app = buildApp(db); @@ -297,6 +311,9 @@ describe('PUT /v1/config — lock / unlock', () => { }); expect(lockRes.status).toBe(200); expect(db.valueRows.find((r) => r.scope_type === 'portal')?.is_locked).toBe(true); + expect(db.historyRows).toHaveLength(1); + expect(db.historyRows[0].action).toBe('lock'); + expect(db.historyRows[0].new_value).toBe('comfortable'); // An org admin BENEATH that portal (same JWT-carried portalId) tries to write. const orgAuth = bearer({ userId: 'org-admin', portalId }); @@ -310,6 +327,8 @@ describe('PUT /v1/config — lock / unlock', () => { expect(blockedRes.body.lockedBy).toEqual({ scopeType: 'portal', scopeId: portalId }); // Stored value at org scope is UNCHANGED (never existed) — the write was refused, not half-applied. expect(db.valueRows.filter((r) => r.scope_type === 'org')).toHaveLength(0); + // Still just the one `lock` entry from above — a refused write appends nothing. + expect(db.historyRows).toHaveLength(1); }); it('unlock preserves the pinned value but clears is_locked', async () => { @@ -336,6 +355,15 @@ describe('PUT /v1/config — lock / unlock', () => { const row = db.valueRows.find((r) => r.scope_type === 'platform'); expect(row?.is_locked).toBe(false); expect(row?.value).toBe('compact'); // value preserved, unlike unset + + expect(db.historyRows).toHaveLength(2); // the earlier `lock` + this `unlock` + const unlockEntry = db.historyRows.find((r) => r.action === 'unlock'); + expect(unlockEntry).toBeDefined(); + // unlock does not change the stored value — openapi.yaml describes + // oldValue/newValue as populated "for set/unset" and "for set/lock" + // respectively, neither of which names unlock. + expect(unlockEntry?.old_value).toBeNull(); + expect(unlockEntry?.new_value).toBeNull(); }); it('unlocking a scope with nothing set is a 400 VALIDATION_ERROR', async () => { @@ -373,6 +401,7 @@ describe('PUT /v1/config — atomic batch (S6 AC3)', () => { expect(res.status).toBe(422); expect(db.valueRows).toHaveLength(0); // the otherwise-valid first op was NOT applied either + expect(db.historyRows).toHaveLength(0); // atomicity extends to history — no orphan entry either }); it('rejects an unknown key with nothing applied', async () => { @@ -480,3 +509,108 @@ describe('PUT /v1/config — optimistic concurrency (expectedVersion)', () => { expect(res.status).toBe(200); }); }); + +describe('PUT /v1/config — history writes (FF-EPIC-18 / FFRNT-280)', () => { + const SECRET_DEF_ID = randomUUID(); + + function seedWithSecret(db: FakeDb) { + seedBase(db); + db.seedKeyDef({ + id: SECRET_DEF_ID, + namespace_id: NAMESPACE_ID, + key: 'api.secret', + value_type: 'secret', + default_value: null, + allowed_scopes: ['platform', 'org'], + is_secret: true, + }); + } + + it('unset records oldValue = the value that was there, newValue = null', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + const auth = bearer({ userId: 'u1' }); + + await request(app) + .put('/v1/config') + .set('Authorization', auth) + .send({ namespace: NAMESPACE, scope: ORG_SCOPE, operations: [{ key: 'ui.theme.density', op: 'set', value: 'compact' }] }); + const unsetRes = await request(app) + .put('/v1/config') + .set('Authorization', auth) + .send({ namespace: NAMESPACE, scope: ORG_SCOPE, operations: [{ key: 'ui.theme.density', op: 'unset' }] }); + + expect(unsetRes.status).toBe(200); + expect(db.historyRows).toHaveLength(2); + const unsetEntry = db.historyRows.find((r) => r.action === 'unset'); + expect(unsetEntry?.old_value).toBe('compact'); + expect(unsetEntry?.new_value).toBeNull(); + }); + + it('an isSecret key redacts oldValue/newValue in its history entry — the plaintext never lands in the trail', async () => { + const db = new FakeDb(); + seedWithSecret(db); + const app = buildApp(db); + + const res = await request(app) + .put('/v1/config') + .set('Authorization', bearer({ userId: 'u1' })) + .send({ + namespace: NAMESPACE, + scope: ORG_SCOPE, + operations: [{ key: 'api.secret', op: 'set', value: 'sk-live-abc123' }], + }); + + expect(res.status).toBe(200); + // The value itself is still stored (this PR does not add encryption at + // rest — see secrets.write.ts's module doc), but the AUDIT TRAIL for an + // isSecret key must never carry the plaintext. + expect(db.valueRows[0].value).toBe('sk-live-abc123'); + expect(db.historyRows).toHaveLength(1); + expect(db.historyRows[0].redacted).toBe(true); + expect(db.historyRows[0].old_value).toBeNull(); + expect(db.historyRows[0].new_value).toBeNull(); + expect(db.historyRows[0].action).toBe('set'); + }); + + it('a batch of several operations writes one history entry per applied op, in order', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .put('/v1/config') + .set('Authorization', bearer({ userId: 'u1' })) + .send({ + namespace: NAMESPACE, + scope: { scopeType: 'platform', scopeId: null }, + operations: [ + { key: 'ui.theme.density', op: 'set', value: 'compact' }, + { key: 'platform.retention-days', op: 'set', value: 14 }, + ], + }); + + expect(res.status).toBe(200); + expect(db.historyRows).toHaveLength(2); + expect(db.historyRows.map((r) => r.key)).toEqual(['ui.theme.density', 'platform.retention-days']); + }); + + it('records the batch reason on every entry it produces', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + await request(app) + .put('/v1/config') + .set('Authorization', bearer({ userId: 'u1' })) + .send({ + namespace: NAMESPACE, + scope: ORG_SCOPE, + reason: 'rolling out the new density default', + operations: [{ key: 'ui.theme.density', op: 'set', value: 'compact' }], + }); + + expect(db.historyRows[0].reason).toBe('rolling out the new density default'); + }); +}); diff --git a/services/config-service/tests/routes/keys.write.test.ts b/services/config-service/tests/routes/keys.write.test.ts index f5ea7307b..e3fcecc05 100644 --- a/services/config-service/tests/routes/keys.write.test.ts +++ b/services/config-service/tests/routes/keys.write.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto'; import express from 'express'; import request from 'supertest'; +import type { AuthzClient } from '@fuzefront/auth'; import { configureIdentity } from '@izzywdev/fuzefront-identity'; import { createKeyDefinitionsWriteRouter } from '../../src/routes/keys.write'; import { FakeDb } from '../helpers/fakeDb'; @@ -155,7 +156,7 @@ describe('PUT /v1/namespaces/{namespace}/keys', () => { }); it('403s when the Security API denies key registration for this namespace', async () => { - _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] }); + _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] } as unknown as AuthzClient); const db = new FakeDb(); seedNamespace(db); const app = buildApp(db); diff --git a/services/config-service/tests/routes/namespaces.write.test.ts b/services/config-service/tests/routes/namespaces.write.test.ts index f3d780578..b38c54255 100644 --- a/services/config-service/tests/routes/namespaces.write.test.ts +++ b/services/config-service/tests/routes/namespaces.write.test.ts @@ -1,5 +1,6 @@ import express from 'express'; import request from 'supertest'; +import type { AuthzClient } from '@fuzefront/auth'; import { createNamespacesWriteRouter } from '../../src/routes/namespaces.write'; import { FakeDb } from '../helpers/fakeDb'; import { bearer, TEST_JWT_SECRET } from '../helpers/authToken'; @@ -82,7 +83,7 @@ describe('POST /v1/namespaces', () => { }); it('403s when the Security API denies namespace registration', async () => { - _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] }); + _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] } as unknown as AuthzClient); const db = new FakeDb(); const app = buildApp(db); const res = await request(app) diff --git a/services/config-service/tests/routes/secrets.write.test.ts b/services/config-service/tests/routes/secrets.write.test.ts new file mode 100644 index 000000000..2ce3794c1 --- /dev/null +++ b/services/config-service/tests/routes/secrets.write.test.ts @@ -0,0 +1,429 @@ +/** + * Integration tests for POST /v1/config/secrets/reveal (FF-EPIC-18 / + * FFRNT-280) — the reveal-once secret disclosure endpoint. + * + * SECURITY-FOCUSED by design, per CLAUDE.md's "an id is never a capability" + * and the reveal-once contract: these tests exercise the fail-closed paths + * (denied/undecidable authz, rate limiting, non-secret keys, no stored + * value) at least as thoroughly as the happy path, and assert that + * authorization comes from `checkAuthorization()` (the Security API call), + * never merely from the caller having supplied a valid + * namespace/scope/key address. + */ + +import { randomUUID } from 'crypto'; +import express from 'express'; +import request from 'supertest'; +import { AuthzClient } from '@fuzefront/auth'; +import { configureIdentity } from '@izzywdev/fuzefront-identity'; +import { createSecretsWriteRouter, RevealRateLimiter } from '../../src/routes/secrets.write'; +import { FakeDb } from '../helpers/fakeDb'; +import { bearer, TEST_JWT_SECRET } from '../helpers/authToken'; +import { _setAuthzClientForTesting, makeNoOpProxy } from '../../src/middleware/authz'; + +beforeAll(() => { + // governance/identifier-standard.md §8 — same widening as config.write.test.ts + // /namespaces.write.test.ts/keys.write.test.ts: portal/organization/user ids + // are not yet family-wide backfilled to the prefixed TypeID form, so the + // scope_id values this suite seeds (bare UUIDs) need this to resolve. + configureIdentity({ legacyUuidTypes: new Set(['portal', 'organization', 'user']) }); + process.env.JWT_SECRET = TEST_JWT_SECRET; +}); + +afterEach(() => { + // Restore the default CI no-op (allow-everything) authz client between tests. + _setAuthzClientForTesting(makeNoOpProxy()); +}); + +function buildApp(db: FakeDb, rateLimiter?: RevealRateLimiter) { + const app = express(); + app.use(express.json()); + app.use(createSecretsWriteRouter(db.pool, rateLimiter)); + return app; +} + +const NAMESPACE_ID = randomUUID(); +const NAMESPACE = 'fuzefront.chat'; +const SECRET_DEF_ID = randomUUID(); +const PLAIN_DEF_ID = randomUUID(); +const HIDDEN_SECRET_DEF_ID = randomUUID(); + +function seedBase(db: FakeDb) { + db.seedNamespace({ id: NAMESPACE_ID, namespace: NAMESPACE }); + db.seedKeyDef({ + id: SECRET_DEF_ID, + namespace_id: NAMESPACE_ID, + key: 'api.token', + value_type: 'secret', + default_value: null, + allowed_scopes: ['platform', 'org'], + is_secret: true, + }); + db.seedKeyDef({ + id: PLAIN_DEF_ID, + namespace_id: NAMESPACE_ID, + key: 'ui.theme.density', + value_type: 'string', + default_value: 'comfortable', + allowed_scopes: ['platform', 'org'], + is_secret: false, + }); + db.seedKeyDef({ + id: HIDDEN_SECRET_DEF_ID, + namespace_id: NAMESPACE_ID, + key: 'hidden.secret', + value_type: 'secret', + default_value: null, + allowed_scopes: ['platform'], + is_secret: true, + is_hidden: true, + }); +} + +const ORG_SCOPE = { scopeType: 'org', scopeId: randomUUID() }; + +function revealBody(overrides: Record = {}) { + return { + namespace: NAMESPACE, + scope: ORG_SCOPE, + key: 'api.token', + reason: 'debugging a webhook failure', + ...overrides, + }; +} + +describe('POST /v1/config/secrets/reveal — auth', () => { + it('401s with no credential', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app).post('/v1/config/secrets/reveal').send(revealBody()); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHENTICATED'); + }); + + it('403s when the Security API denies the reveal grant', async () => { + _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] } as unknown as AuthzClient); + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-secret' }); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('FORBIDDEN'); + }); + + it('fails CLOSED (403, not 500 / not allowed) when the Security API is unreachable', async () => { + _setAuthzClientForTesting({ + check: async () => { + throw new Error('DECISION_UNAVAILABLE'); + }, + bulkCheck: async () => [], + } as unknown as AuthzClient); + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(res.status).toBe(403); + }); + + it('checks the reveal action as a DISTINCT grant, never derived from write authority, and never satisfied merely by the request naming a valid namespace/scope/key', async () => { + // The Security API is asked for 'reveal' — a caller who could pass a + // 'write' check is not asked here at all, and the mock below proves the + // action string reaching checkAuthorization is exactly 'reveal'. + const check = jest.fn().mockResolvedValue({ allow: true }); + _setAuthzClientForTesting({ check, bulkCheck: async () => [] } as unknown as AuthzClient); + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-secret' }); + const app = buildApp(db); + + await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(check).toHaveBeenCalledWith(expect.objectContaining({ action: 'reveal' }), expect.any(String)); + }); + + it('403s BEFORE checking namespace/key existence — a denial leaks nothing about whether the secret exists', async () => { + _setAuthzClientForTesting({ check: async () => ({ allow: false }), bulkCheck: async () => [] } as unknown as AuthzClient); + const db = new FakeDb(); // no namespace registered at all — would 404 if reached + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ namespace: 'fuzefront.does-not-exist' })); + + expect(res.status).toBe(403); + }); +}); + +describe('POST /v1/config/secrets/reveal — shape validation', () => { + it('400s a missing reason (required, unlike ConfigWriteRequest.reason)', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send({ namespace: NAMESPACE, scope: ORG_SCOPE, key: 'api.token' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('400s an unknown property (additionalProperties: false)', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ id: 'cvl_smuggled' })); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('400s scopeId supplied for the platform singleton tier', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ scope: { scopeType: 'platform', scopeId: 'nope' } })); + + expect(res.status).toBe(400); + }); +}); + +describe('POST /v1/config/secrets/reveal — resolution', () => { + it('404s when the namespace does not exist', async () => { + const db = new FakeDb(); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ namespace: 'fuzefront.nope' })); + + expect(res.status).toBe(404); + expect(res.body.code).toBe('NOT_FOUND'); + }); + + it('404s when the key does not exist in that namespace', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ key: 'no.such.key' })); + + expect(res.status).toBe(404); + }); + + it('404s a hidden secret key — same masking as every other lookup, never confirms existence', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ key: 'hidden.secret', scope: { scopeType: 'platform', scopeId: null } })); + + expect(res.status).toBe(404); + }); + + it('400s VALIDATION_ERROR when the key exists but is not isSecret', async () => { + const db = new FakeDb(); + seedBase(db); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ key: 'ui.theme.density' })); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('VALIDATION_ERROR'); + }); + + it('404s isSet:false — no value stored at this EXACT scope (a value at a DIFFERENT scope does not count)', async () => { + const db = new FakeDb(); + seedBase(db); + // Value stored at platform, but the request asks for the org scope. + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'platform', scope_id: null, value: 'sk-live-platform' }); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(res.status).toBe(404); + expect(res.body.code).toBe('NOT_FOUND'); + }); +}); + +describe('POST /v1/config/secrets/reveal — success', () => { + it('200s with the plaintext exactly once, Cache-Control: no-store, and a historyEntryId', async () => { + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-abc123' }); + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(res.status).toBe(200); + expect(res.body.value).toBe('sk-live-abc123'); + expect(res.body.namespace).toBe(NAMESPACE); + expect(res.body.key).toBe('api.token'); + expect(res.body.scope).toEqual(ORG_SCOPE); + expect(typeof res.body.revealedAt).toBe('string'); + expect(res.body.historyEntryId).toMatch(/^cvh_/); + expect(res.headers['cache-control']).toBe('no-store'); + }); + + it('writes a redacted `reveal` history entry against the caller, with the reason recorded', async () => { + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-abc123' }); + const app = buildApp(db); + + await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ reason: 'incident-4821' })); + + expect(db.historyRows).toHaveLength(1); + const entry = db.historyRows[0]; + expect(entry.action).toBe('reveal'); + expect(entry.redacted).toBe(true); + expect(entry.old_value).toBeNull(); + expect(entry.new_value).toBeNull(); + expect(entry.reason).toBe('incident-4821'); + expect(entry.actor_type).toBe('user'); + }); + + it('every attempt against a resolved secret writes its own history entry — even a 404 isSet:false one', async () => { + const db = new FakeDb(); + seedBase(db); + // No value seeded at all — isSet: false. + const app = buildApp(db); + + const res = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(res.status).toBe(404); + expect(db.historyRows).toHaveLength(1); + expect(db.historyRows[0].action).toBe('reveal'); + }); + + it('two reveals of the same secret each get their OWN history entry (never cached/re-served)', async () => { + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-abc123' }); + // A generous limiter — this test is about history entries, not throttling. + const app = buildApp(db, new RevealRateLimiter(10, 60_000)); + + const first = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + const second = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody()); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(first.body.historyEntryId).not.toBe(second.body.historyEntryId); + expect(db.historyRows.filter((r) => r.action === 'reveal')).toHaveLength(2); + }); +}); + +describe('POST /v1/config/secrets/reveal — throttling (fail-closed, not decoration)', () => { + it('429s after the configured attempt budget is exhausted for one (subject, scope, key)', async () => { + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-abc123' }); + const app = buildApp(db, new RevealRateLimiter(2, 60_000)); + + const first = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + const second = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + const third = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(third.status).toBe(429); + expect(third.body.code).toBe('RATE_LIMITED'); + }); + + it('a 429 still writes its own reveal history entry (an attempted disclosure is itself auditable)', async () => { + const db = new FakeDb(); + seedBase(db); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-live-abc123' }); + const app = buildApp(db, new RevealRateLimiter(1, 60_000)); + + await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + const blocked = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + + expect(blocked.status).toBe(429); + expect(db.historyRows.filter((r) => r.action === 'reveal')).toHaveLength(2); + }); + + it("throttles per (subject, namespace, scope, key) — a DIFFERENT secret for the SAME caller is not blocked by the first's window", async () => { + const db = new FakeDb(); + seedBase(db); + const otherSecretId = randomUUID(); + db.seedKeyDef({ + id: otherSecretId, + namespace_id: NAMESPACE_ID, + key: 'other.secret', + value_type: 'secret', + default_value: null, + allowed_scopes: ['org'], + is_secret: true, + }); + db.seedValue({ definition_id: SECRET_DEF_ID, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-a' }); + db.seedValue({ definition_id: otherSecretId, scope_type: 'org', scope_id: ORG_SCOPE.scopeId, value: 'sk-b' }); + const app = buildApp(db, new RevealRateLimiter(1, 60_000)); + + const first = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + const secondBlocked = await request(app).post('/v1/config/secrets/reveal').set('Authorization', bearer({ userId: 'u1' })).send(revealBody()); + const otherKey = await request(app) + .post('/v1/config/secrets/reveal') + .set('Authorization', bearer({ userId: 'u1' })) + .send(revealBody({ key: 'other.secret' })); + + expect(first.status).toBe(200); + expect(secondBlocked.status).toBe(429); + expect(otherKey.status).toBe(200); + }); +});