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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/identity-py/fuzefront_identity/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)

Expand Down
5 changes: 5 additions & 0 deletions packages/identity/dist/registry.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
19 changes: 19 additions & 0 deletions packages/identity/dist/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])));
Expand Down
6 changes: 6 additions & 0 deletions packages/identity/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/gate_identifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions services/config-service/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions services/config-service/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions services/config-service/src/middleware/authz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthzDecision> => ({ allow: true }),
bulkCheck: async (checks: AuthzCheck[]): Promise<AuthzDecision[]> => checks.map(() => ({ allow: true })),
grant: async (): Promise<never> => {
throw new Error('makeNoOpProxy: grant() is not used by config-service');
},
revoke: async (): Promise<never> => {
throw new Error('makeNoOpProxy: revoke() is not used by config-service');
},
listGrants: async (): Promise<never> => {
throw new Error('makeNoOpProxy: listGrants() is not used by config-service');
},
};
}

Expand Down
75 changes: 75 additions & 0 deletions services/config-service/src/migrations/004_config_history.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading