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
16 changes: 16 additions & 0 deletions .changeset/28633-datasource-ssl-key-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': patch
---

fix(spec): the datasource read redactor covers the declared `ssl` secret members

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no authored value is refused, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a read answer: the metadata read exits serve a datasource without `ssl.key` and name it in `redactedKeys`, the posture they already apply to `config` credentials. The stored row keeps the key for the connect path, and the generic write-path carry-forward keeps it on a save that omits it. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->

**BREAKING** (a read-answer narrowing), shipped as `patch`: a non-protocol narrowing takes `patch` and keeps this banner and its ADR-0087 disposition.

`DatasourceSchema.ssl.key` (the client private key) is now marked a credential value (`.meta({ credential: 'value' })`), and the built-in `datasource` read redactor applies the schema's credential markers beside its `config` rule. Every metadata read exit that resolves the `datasource` redactor (`/meta` item, list and layered reads, and the stored-row seam behind the `sys_metadata` data door) drops `ssl.key` from the served item and lists `ssl.key` in `redactedKeys`.

**Unchanged.** `ssl.enabled`, `ssl.rejectUnauthorized`, `ssl.ca` and `ssl.cert` are public material and stay served. A datasource with no `ssl` block, or with an empty `key`, is served exactly as stored. The stored row is never rewritten, and a save that sends back the served body keeps the stored key.
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,42 @@ describe('[#21086] the door follows the redactor registry, not a datasource rule
expect(JSON.parse(result.records[0].metadata)).toEqual({ name: 'all_tasks', label: 'All Tasks', type: 'grid' });
});
});

describe('the data door serves a sys_metadata datasource body without its declared ssl secret member', () => {
const SSL_KEY = 'TEST_PLACEHOLDER_SSL_KEY';
const SSL_PUBLIC = { enabled: true, ca: 'TEST_CA_PEM', cert: 'TEST_CERT_PEM' };
const TLS_BODY = { name: 'tls_pg', driver: 'postgres', config: { host: 'db.internal' }, ssl: { ...SSL_PUBLIC, key: SSL_KEY } };
const PUBLIC_TLS_BODY = { name: 'tls_public', driver: 'postgres', config: { host: 'db.internal' }, ssl: SSL_PUBLIC };
const rows = {
sys_metadata: [
{ id: 'm_tls', type: 'datasource', name: 'tls_pg', state: 'active', metadata: stored(TLS_BODY) },
{ id: 'm_tls_public', type: 'datasource', name: 'tls_public', state: 'active', metadata: stored(PUBLIC_TLS_BODY) },
],
sys_metadata_history: [
{ id: 'h_tls_1', type: 'datasource', name: 'tls_pg', version: 1, operation_type: 'create', metadata: stored(TLS_BODY) },
],
};

it('findData (list) and getData (by id) withhold ssl.key and keep the public members', async () => {
const { p } = makeProtocol(rows);
const list: any = await p.findData({ object: 'sys_metadata', query: {} });
expect(JSON.stringify(list.records)).not.toContain(SSL_KEY);
expect(JSON.parse(byId(list.records, 'm_tls').metadata).ssl).toEqual(SSL_PUBLIC);

const one: any = await p.getData({ object: 'sys_metadata', id: 'm_tls' });
expect(JSON.stringify(one.record)).not.toContain(SSL_KEY);
expect(JSON.parse(one.record.metadata).ssl).toEqual(SSL_PUBLIC);
});

it('a version snapshot withholds it too', async () => {
const { p } = makeProtocol(rows);
const history: any = await p.findData({ object: 'sys_metadata_history', query: {} });
expect(JSON.stringify(history.records)).not.toContain(SSL_KEY);
});

it('control: a body whose ssl block holds no key reaches the caller as its stored bytes', async () => {
const { p } = makeProtocol(rows);
const list: any = await p.findData({ object: 'sys_metadata', query: {} });
expect(byId(list.records, 'm_tls_public').metadata).toBe(stored(PUBLIC_TLS_BODY));
});
});
57 changes: 57 additions & 0 deletions packages/metadata-protocol/src/protocol.metadata-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1370,3 +1370,60 @@ describe('#20611 — the runtime authoring gate reads a withheld-and-stored secr
expect(redactedPathsCarriedForward('view', { name: 'v' }, { name: 'v', secret: 'x' })).toEqual([]);
});
});

describe('the read exits withhold the declared ssl secret member of a datasource', () => {
/** The stored client key, which no read exit may serve; the public members it sits beside. */
const SSL_KEY = 'TEST_PLACEHOLDER_SSL_KEY';
const SSL_PUBLIC = { enabled: true, rejectUnauthorized: true, ca: 'TEST_CA_PEM', cert: 'TEST_CERT_PEM' };

function seedTlsRow(rows: Map<string, Row>, ssl: Record<string, unknown>) {
const where = { type: 'datasource', name: 'tls_pg', organization_id: null, package_id: null, state: 'active' };
const body = { name: 'tls_pg', label: 'TLS PG', driver: 'postgres', config: { host: 'db.internal', database: 'app' }, ssl };
rows.set(keyOf(where), { id: 'r_tls', ...where, metadata: JSON.stringify(body), checksum: hashSpec(body), version: 1 } as Row);
}
const tlsBody = (rows: Map<string, Row>) =>
JSON.parse(Array.from(rows.values()).find((r) => r.name === 'tls_pg')!.metadata);

it('getMetaItems, getMetaItem and getMetaItemLayered serve ssl without its key', async () => {
const { engine, rows } = makeStubEngine();
seedTlsRow(rows, { ...SSL_PUBLIC, key: SSL_KEY });
const protocol = new ObjectStackProtocolImplementation(engine);

const list: any = await protocol.getMetaItems({ type: 'datasource' });
const listed = list.items.find((i: any) => i.name === 'tls_pg');
expect(listed.ssl).toEqual(SSL_PUBLIC);

const one: any = await protocol.getMetaItem({ type: 'datasource', name: 'tls_pg' });
expect(one.item.ssl).toEqual(SSL_PUBLIC);

const layered: any = await protocol.getMetaItemLayered({ type: 'datasource', name: 'tls_pg' });
expect(layered.overlay.ssl).toEqual(SSL_PUBLIC);
expect(layered.effective.ssl).toEqual(SSL_PUBLIC);

for (const served of [list, one, layered]) expect(allStrings(served)).not.toContain(SSL_KEY);
// A serving act only: the stored row keeps its key for the connect path.
expect(tlsBody(rows).ssl.key).toBe(SSL_KEY);
});

it('control: an ssl block with no key is served as stored', async () => {
const { engine, rows } = makeStubEngine();
seedTlsRow(rows, SSL_PUBLIC);
const protocol = new ObjectStackProtocolImplementation(engine);
const one: any = await protocol.getMetaItem({ type: 'datasource', name: 'tls_pg' });
expect(one.item.ssl).toEqual(SSL_PUBLIC);
});

it('the GET → edit → PUT round trip keeps the stored key', async () => {
const { engine, rows } = makeStubEngine();
seedTlsRow(rows, { ...SSL_PUBLIC, key: SSL_KEY });
const protocol = new ObjectStackProtocolImplementation(engine);

const served: any = (await protocol.getMetaItem({ type: 'datasource', name: 'tls_pg' })).item;
expect(served.ssl.key).toBeUndefined();
await protocol.saveMetaItem({ type: 'datasource', name: 'tls_pg', item: { ...served, label: 'TLS PG (edited)' } });

const stored = tlsBody(rows);
expect(stored.label).toBe('TLS PG (edited)');
expect(stored.ssl).toEqual({ ...SSL_PUBLIC, key: SSL_KEY });
});
});
16 changes: 16 additions & 0 deletions packages/metadata/src/metadata-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,22 @@ describe('MetadataManager — IMetadataService Contract', () => {
expect(JSON.stringify(await manager.getPublished('flow', 'inbound_hook'))).toContain(SECRET);
});
});

// The built-in `datasource` redactor (no stand-in): a code-registered
// datasource's declared ssl secret member is withheld where the body is served.
it('withholds the declared ssl secret member of a code-registered datasource', async () => {
const KEY = 'TEST_PLACEHOLDER_SSL_KEY';
const ssl = { enabled: true, ca: 'TEST_CA_PEM', cert: 'TEST_CERT_PEM' };
await manager.register('datasource', 'tls_code', {
name: 'tls_code', driver: 'postgres', config: { host: 'db.internal' }, ssl: { ...ssl, key: KEY },
});

const published = await manager.getPublished('datasource', 'tls_code');
expect(JSON.stringify(published)).not.toContain(KEY);
expect((published as any).ssl).toEqual(ssl);
// A serving act: the registered body keeps the key for the connect path.
expect(((await manager.get('datasource', 'tls_code')) as any).ssl.key).toBe(KEY);
});
});

describe('integration: edit → publish → edit → revert', () => {
Expand Down
8 changes: 7 additions & 1 deletion packages/spec/src/data/datasource.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,13 @@ export const DatasourceSchema = lazySchema(() => strictObject(
rejectUnauthorized: z.boolean().default(true).describe('Reject connections with invalid/self-signed certificates'),
ca: z.string().optional().describe('CA certificate (PEM format or path to file)'),
cert: z.string().optional().describe('Client certificate (PEM format or path to file)'),
key: z.string().optional().describe('Client private key (PEM format or path to file)'),
/**
* The client PRIVATE key: a credential VALUE (`.meta({ credential: 'value' })`),
* so every metadata read exit withholds it and names `ssl.key` in
* `redactedKeys` (`kernel/metadata-type-redaction.ts`). `ca` and `cert`
* are public material and stay served.
*/
key: z.string().optional().describe('Client private key (PEM format or path to file)').meta({ credential: 'value' }),
}).optional().describe('SSL/TLS configuration for secure database connections'),

/** Description */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const CREDENTIAL_WORDS = new Set([
'secret', 'secrets', 'password', 'passwords', 'passwd', 'token', 'tokens',
'signing', 'credential', 'credentials',
]);
const DESCRIPTION_RULE = /\b(secrets?|passwords?|passwd|tokens?|signing|credentials?|api[ _-]?keys?)\b/i;
const DESCRIPTION_RULE = /\b(secrets?|passwords?|passwd|passphrases?|tokens?|signing|credentials?|api[ _-]?keys?|private[ _-]?keys?)\b/i;
const CAN_HOLD_A_CREDENTIAL = new Set(['string', 'record', 'union', 'unknown', 'any', 'template_literal']);

const wordsOf = (key: string): string[] =>
Expand Down
61 changes: 61 additions & 0 deletions packages/spec/src/kernel/metadata-type-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,67 @@ describe('FAIL-CLOSED: the datasource redactor is a BUILT-IN, not a plugin regis
});
});

describe('the datasource built-in also withholds the credential members its schema marks (the declared ssl block)', () => {
const redact = (item: Record<string, unknown>) => getMetadataTypeRedactor('datasource')!(item);

it('ssl.key is dropped and named; the public ssl members stay', () => {
const stored = {
name: 'tls_pg',
driver: 'postgres',
config: { host: 'db.internal', database: 'app' },
ssl: { enabled: true, rejectUnauthorized: true, ca: 'CA_PEM', cert: 'CERT_PEM', key: 'TEST_PLACEHOLDER_KEY' },
};
const result = redact(stored);
expect(result.item.ssl).toEqual({ enabled: true, rejectUnauthorized: true, ca: 'CA_PEM', cert: 'CERT_PEM' });
expect(JSON.stringify(result.item)).not.toContain('TEST_PLACEHOLDER_KEY');
expect(result.redactedKeys).toEqual(['ssl.key']);
// Pure: the stored body keeps its key for the connect path.
expect(stored.ssl.key).toBe('TEST_PLACEHOLDER_KEY');
});

it('with no config object at all, ssl.key is still withheld', () => {
const result = redact({ name: 'x', driver: 'postgres', ssl: { enabled: true, key: 'TEST_PLACEHOLDER_KEY', cert: 'CERT_PEM' } });
expect(result.item.ssl).toEqual({ enabled: true, cert: 'CERT_PEM' });
expect(result.redactedKeys).toEqual(['ssl.key']);
});

it('config and ssl credentials together are both withheld, in one sorted list', () => {
const result = redact({
name: 'x',
driver: 'postgres',
config: { host: 'h', password: 'TEST_SECRET' },
ssl: { enabled: true, key: 'TEST_PLACEHOLDER_KEY' },
});
expect(result.item).toEqual({ name: 'x', driver: 'postgres', config: { host: 'h' }, ssl: { enabled: true } });
expect(result.redactedKeys).toEqual(['config.password', 'ssl.key']);
});

it('controls: no ssl block, and an ssl block with no secret member, are served unchanged by reference', () => {
const noSsl = { name: 'x', driver: 'postgres', config: { host: 'h' } };
expect(redact(noSsl)).toEqual({ item: noSsl, redactedKeys: [] });
expect(redact(noSsl).item).toBe(noSsl);
const publicSsl = { name: 'x', driver: 'postgres', config: { host: 'h' }, ssl: { enabled: true, ca: 'CA_PEM', cert: 'CERT_PEM' } };
expect(redact(publicSsl).item).toBe(publicSsl);
expect(redact(publicSsl).redactedKeys).toEqual([]);
});

it('the cleared form (an empty key) holds nothing, so it is served as written and not reported', () => {
const cleared = { name: 'x', driver: 'postgres', ssl: { enabled: true, key: '' } };
expect(redact(cleared)).toEqual({ item: cleared, redactedKeys: [] });
});

it('the stored-row seam withholds ssl.key from a serialized datasource body', () => {
const row = {
type: 'datasource',
name: 'tls_pg',
metadata: JSON.stringify({ name: 'tls_pg', driver: 'postgres', ssl: { enabled: true, key: 'TEST_PLACEHOLDER_KEY' } }),
};
const served = redactStoredMetadataRow('sys_metadata', row) as Record<string, unknown>;
expect(String(served.metadata)).not.toContain('TEST_PLACEHOLDER_KEY');
expect(JSON.parse(String(served.metadata)).ssl).toEqual({ enabled: true });
});
});

describe('FAIL-CLOSED: the connector redactor is a BUILT-IN as well', () => {
// A connector row reaches the metadata save door with or without any
// connector plugin mounted — the same fail-open shape as `datasource` — and
Expand Down
47 changes: 31 additions & 16 deletions packages/spec/src/kernel/metadata-type-redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,27 +105,42 @@ export interface MetadataRedactionResult {
export type MetadataTypeRedactor = (item: Record<string, unknown>) => MetadataRedactionResult;

/**
* The built-in `datasource` redactor: redacts the driver `config` through the
* one credential-key definition in `data/datasource-credential-redaction.ts`
* (derived from each driver's `z.never()` contract + the pre-#8078 alias list
* + turso's retired `encryptionKey`, which a legacy stored row may still
* carry), and leaves every other key of the item —
* `_diagnostics` included — byte-for-byte.
* The built-in `datasource` redactor, in two halves:
*
* - the driver `config`, through the one credential-key definition in
* `data/datasource-credential-redaction.ts` (derived from each driver's
* `z.never()` contract + the pre-#8078 alias list + turso's retired
* `encryptionKey`, which a legacy stored row may still carry);
* - every OTHER position the datasource schema marks as a credential
* (`.meta({ credential: … })`, today `ssl.key`), through the same
* derivation the third resolution uses. The built-in wins the lookup, so
* without this half a marker on `DatasourceSchema` would never be read.
*
* Every other key of the item — `_diagnostics` included — is left
* byte-for-byte, and an item with nothing to withhold is returned by
* reference.
*/
function redactDatasourceItem(item: Record<string, unknown>): MetadataRedactionResult {
let served = item;
const redactedKeys: string[] = [];

const config = item.config;
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return { item, redactedKeys: [] };
if (config && typeof config === 'object' && !Array.isArray(config)) {
const redacted = redactDatasourceConfig(item.driver, config as Record<string, unknown>);
if (redacted.redactedKeys.length > 0) {
served = { ...served, config: redacted.config };
redactedKeys.push(...redacted.redactedKeys.map((key) => `config.${key}`));
}
}
const { config: redacted, redactedKeys } = redactDatasourceConfig(
item.driver,
config as Record<string, unknown>,
);

const marked = derivedCredentialRedactorFor(getMetadataTypeSchema('datasource'))?.(served);
if (marked && marked.redactedKeys.length > 0) {
served = marked.item;
redactedKeys.push(...marked.redactedKeys);
}

if (redactedKeys.length === 0) return { item, redactedKeys: [] };
return {
item: { ...item, config: redacted },
redactedKeys: redactedKeys.map((key) => `config.${key}`),
};
return { item: served, redactedKeys: redactedKeys.sort() };
}

/**
Expand Down
Loading