pgserve 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -8
- package/bin/pgserve-wrapper.cjs +19 -0
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +184 -0
- package/scripts/assemble-tarball.sh +191 -0
- package/scripts/build-binary.sh +213 -0
- package/scripts/fetch-postgres-bins.sh +234 -0
- package/scripts/postinstall.cjs +102 -18
- package/scripts/verify-published-artifacts.sh +211 -0
- package/src/cli-install.cjs +65 -3
- package/src/commands/doctor.js +465 -0
- package/src/commands/gc.js +276 -0
- package/src/commands/provision.js +396 -0
- package/src/commands/trust.js +187 -0
- package/src/cosign/trust-list.js +3 -3
- package/src/cosign/trust-store.js +250 -0
- package/src/gc/audit-log.js +150 -0
- package/src/gc/orphan-detection.js +190 -0
- package/src/gc/queries.js +193 -0
- package/src/lib/pg-query.js +145 -0
- package/src/provision/advisory-lock.js +91 -0
- package/src/provision/db-naming.js +130 -0
- package/src/provision/fingerprint.js +144 -0
- package/src/schema/pgserve-meta.js +120 -0
- package/src/security/blocked-versions.js +103 -0
- package/src/upgrade/steps/binary-cache-flush.js +2 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve trust` — manage the cosign trust list.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* Subverbs:
|
|
7
|
+
* pgserve trust list show hardcoded + user entries
|
|
8
|
+
* pgserve trust add <id> [flags] add a user entry
|
|
9
|
+
* pgserve trust remove <id> remove a user entry (refuses hardcoded)
|
|
10
|
+
*
|
|
11
|
+
* `add` flags (all required except where noted):
|
|
12
|
+
* --issuer <url> OIDC issuer URL
|
|
13
|
+
* --identity-regexp <regex> sigstore --certificate-identity-regexp value
|
|
14
|
+
* --publisher <name> package.json `pgserve.publisher` (optional)
|
|
15
|
+
* --description <text> human-readable summary (optional)
|
|
16
|
+
*
|
|
17
|
+
* Output modes:
|
|
18
|
+
* default human-readable table / status line
|
|
19
|
+
* --json emit a JSON object on stdout instead
|
|
20
|
+
*
|
|
21
|
+
* Exit codes:
|
|
22
|
+
* 0 success
|
|
23
|
+
* 1 user error (bad flags, unknown id, hardcoded id collision)
|
|
24
|
+
* 2 trust store on disk is malformed and must be repaired by hand
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { listAllTrust, addUserTrust, removeUserTrust } from '../cosign/trust-store.js';
|
|
28
|
+
|
|
29
|
+
const USAGE = `Usage: pgserve trust <list|add|remove> [args]
|
|
30
|
+
|
|
31
|
+
list show hardcoded + user entries
|
|
32
|
+
add <id> --issuer <url> --identity-regexp <re> [--publisher <name>] [--description <text>]
|
|
33
|
+
remove <id> remove a user entry (refuses hardcoded)
|
|
34
|
+
|
|
35
|
+
Common: --json emit JSON instead of human-readable output`;
|
|
36
|
+
|
|
37
|
+
function parseFlags(argv) {
|
|
38
|
+
const out = { positional: [], json: false, flags: {} };
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === '--json') {
|
|
42
|
+
out.json = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (a === '--help' || a === '-h') {
|
|
46
|
+
out.flags.help = true;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (a.startsWith('--')) {
|
|
50
|
+
const name = a.slice(2);
|
|
51
|
+
const next = argv[i + 1];
|
|
52
|
+
if (next === undefined || next.startsWith('--')) {
|
|
53
|
+
out.flags[name] = true;
|
|
54
|
+
} else {
|
|
55
|
+
out.flags[name] = next;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
out.positional.push(a);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emit(json, payload, humanLine) {
|
|
66
|
+
if (json) {
|
|
67
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
68
|
+
} else if (humanLine) {
|
|
69
|
+
process.stdout.write(humanLine + '\n');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emitErr(json, code, message) {
|
|
74
|
+
if (json) {
|
|
75
|
+
process.stdout.write(JSON.stringify({ ok: false, error: { code, message } }) + '\n');
|
|
76
|
+
} else {
|
|
77
|
+
process.stderr.write(`pgserve trust: ${message}\n`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cmdList(opts) {
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = listAllTrust();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
emitErr(opts.json, err.code || 'ETRUSTSTORE', err.message);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
if (opts.json) {
|
|
90
|
+
emit(true, { ok: true, entries }, null);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
if (entries.length === 0) {
|
|
94
|
+
emit(false, null, 'pgserve trust: no entries');
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
const widthId = Math.max(2, ...entries.map((e) => e.id.length));
|
|
98
|
+
const widthSrc = Math.max(6, ...entries.map((e) => e.source.length));
|
|
99
|
+
const widthPub = Math.max(9, ...entries.map((e) => (e.publisher || '').length));
|
|
100
|
+
const header = `${'id'.padEnd(widthId)} ${'source'.padEnd(widthSrc)} ${'publisher'.padEnd(widthPub)} identityRegexp`;
|
|
101
|
+
process.stdout.write(`${header}\n`);
|
|
102
|
+
process.stdout.write(`${'-'.repeat(header.length)}\n`);
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
process.stdout.write(
|
|
105
|
+
`${e.id.padEnd(widthId)} ${e.source.padEnd(widthSrc)} ${(e.publisher || '').padEnd(widthPub)} ${e.identityRegexp}\n`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cmdAdd(opts) {
|
|
112
|
+
const id = opts.positional[1]; // [0]='add', [1]=id
|
|
113
|
+
if (!id) {
|
|
114
|
+
emitErr(opts.json, 'EUSAGE', `add requires an <id> argument\n\n${USAGE}`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const issuer = opts.flags.issuer;
|
|
118
|
+
const identityRegexp = opts.flags['identity-regexp'];
|
|
119
|
+
if (typeof issuer !== 'string' || !issuer) {
|
|
120
|
+
emitErr(opts.json, 'EUSAGE', '--issuer <url> is required');
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
123
|
+
if (typeof identityRegexp !== 'string' || !identityRegexp) {
|
|
124
|
+
emitErr(opts.json, 'EUSAGE', '--identity-regexp <regex> is required');
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
const candidate = {
|
|
128
|
+
id,
|
|
129
|
+
issuer,
|
|
130
|
+
identityRegexp,
|
|
131
|
+
publisher: typeof opts.flags.publisher === 'string' ? opts.flags.publisher : '',
|
|
132
|
+
description: typeof opts.flags.description === 'string' ? opts.flags.description : '',
|
|
133
|
+
};
|
|
134
|
+
let entry;
|
|
135
|
+
try {
|
|
136
|
+
entry = addUserTrust(candidate);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
emitErr(opts.json, err.code || 'ETRUSTADD', err.message);
|
|
139
|
+
return err.code === 'ETRUSTSTORE' ? 2 : 1;
|
|
140
|
+
}
|
|
141
|
+
emit(opts.json, { ok: true, entry }, `pgserve trust: added "${entry.id}"`);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cmdRemove(opts) {
|
|
146
|
+
const id = opts.positional[1];
|
|
147
|
+
if (!id) {
|
|
148
|
+
emitErr(opts.json, 'EUSAGE', `remove requires an <id> argument\n\n${USAGE}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
let removed;
|
|
152
|
+
try {
|
|
153
|
+
removed = removeUserTrust(id);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
emitErr(opts.json, err.code || 'ETRUSTREMOVE', err.message);
|
|
156
|
+
return err.code === 'ETRUSTSTORE' ? 2 : 1;
|
|
157
|
+
}
|
|
158
|
+
if (!removed) {
|
|
159
|
+
emitErr(opts.json, 'ENOENT', `no user trust entry with id "${id}"`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
emit(opts.json, { ok: true, removed: id }, `pgserve trust: removed "${id}"`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function runTrust(argv = []) {
|
|
167
|
+
const opts = parseFlags(argv);
|
|
168
|
+
if (opts.flags.help || opts.positional.length === 0) {
|
|
169
|
+
process.stdout.write(USAGE + '\n');
|
|
170
|
+
return opts.flags.help ? 0 : 1;
|
|
171
|
+
}
|
|
172
|
+
const verb = opts.positional[0];
|
|
173
|
+
switch (verb) {
|
|
174
|
+
case 'list':
|
|
175
|
+
return cmdList(opts);
|
|
176
|
+
case 'add':
|
|
177
|
+
return cmdAdd(opts);
|
|
178
|
+
case 'remove':
|
|
179
|
+
case 'rm':
|
|
180
|
+
return cmdRemove(opts);
|
|
181
|
+
default:
|
|
182
|
+
emitErr(opts.json, 'EUSAGE', `unknown subverb "${verb}"\n\n${USAGE}`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const __testInternals = Object.freeze({ parseFlags });
|
package/src/cosign/trust-list.js
CHANGED
|
@@ -44,14 +44,14 @@ export const TRUSTED_IDENTITIES = Object.freeze([
|
|
|
44
44
|
id: 'automagik-omni-release',
|
|
45
45
|
publisher: '@automagik/omni',
|
|
46
46
|
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
47
|
-
identityRegexp: '^https://github.com/automagik/omni/.github/workflows/release.yml@refs/tags/v.*$',
|
|
47
|
+
identityRegexp: '^https://github.com/automagik-dev/omni/.github/workflows/release.yml@refs/tags/v.*$',
|
|
48
48
|
description: 'Namastex automagik omni release workflow (GitHub Actions OIDC)',
|
|
49
49
|
}),
|
|
50
50
|
Object.freeze({
|
|
51
51
|
id: 'automagik-pgserve-release',
|
|
52
|
-
publisher: '
|
|
52
|
+
publisher: 'pgserve',
|
|
53
53
|
issuer: SIGSTORE_GITHUB_ACTIONS_ISSUER,
|
|
54
|
-
identityRegexp: '^https://github.com/
|
|
54
|
+
identityRegexp: '^https://github.com/namastexlabs/pgserve/.github/workflows/release.yml@refs/tags/v.*$',
|
|
55
55
|
description: 'Namastex automagik pgserve release workflow (GitHub Actions OIDC)',
|
|
56
56
|
}),
|
|
57
57
|
]);
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-extensible cosign trust store at `~/.pgserve/trust/identities.json`.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
|
|
5
|
+
* (the `pgserve trust add/list/remove` CLI surface).
|
|
6
|
+
*
|
|
7
|
+
* Hardcoded trust roots live in `src/cosign/trust-list.js` and ship in the
|
|
8
|
+
* binary; operators cannot remove or override them. This module owns the
|
|
9
|
+
* separate, mutable layer where operators add their own publishers (e.g.
|
|
10
|
+
* a private fork of pgserve, an internal release workflow).
|
|
11
|
+
*
|
|
12
|
+
* File format (v1):
|
|
13
|
+
* {
|
|
14
|
+
* "schemaVersion": 1,
|
|
15
|
+
* "entries": [
|
|
16
|
+
* {
|
|
17
|
+
* "id": "<short-stable-id>",
|
|
18
|
+
* "publisher": "<package-json-pgserve-publisher>",
|
|
19
|
+
* "issuer": "<oidc-issuer-url>",
|
|
20
|
+
* "identityRegexp": "<sigstore-cert-identity-regexp>",
|
|
21
|
+
* "description": "<human-readable, optional>",
|
|
22
|
+
* "addedAt": "<iso-8601>"
|
|
23
|
+
* }
|
|
24
|
+
* ]
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* Write semantics: atomic via tmp-file + rename, file mode 0600.
|
|
28
|
+
* Read semantics: missing file or empty contents → `{ schemaVersion: 1, entries: [] }`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { TRUSTED_IDENTITIES, listHardcodedTrust } from './trust-list.js';
|
|
36
|
+
|
|
37
|
+
const SCHEMA_VERSION = 1;
|
|
38
|
+
const FILE_MODE = 0o600;
|
|
39
|
+
const DIR_MODE = 0o700;
|
|
40
|
+
|
|
41
|
+
const TRUST_FILE_NAME = 'identities.json';
|
|
42
|
+
|
|
43
|
+
export function getTrustDir(homeDir = os.homedir()) {
|
|
44
|
+
return path.join(homeDir, '.pgserve', 'trust');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getTrustFilePath(homeDir = os.homedir()) {
|
|
48
|
+
return path.join(getTrustDir(homeDir), TRUST_FILE_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function emptyStore() {
|
|
52
|
+
return { schemaVersion: SCHEMA_VERSION, entries: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the user trust store. Returns the parsed object on success, or an
|
|
57
|
+
* empty store if the file is missing. Throws on parse failure / bad shape.
|
|
58
|
+
*/
|
|
59
|
+
export function readTrustStore({ homeDir = os.homedir() } = {}) {
|
|
60
|
+
const file = getTrustFilePath(homeDir);
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code === 'ENOENT') return emptyStore();
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
if (!raw.trim()) return emptyStore();
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const e = new Error(`pgserve trust store at ${file} is not valid JSON: ${err.message}`);
|
|
74
|
+
e.code = 'ETRUSTSTORE';
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.entries)) {
|
|
78
|
+
const e = new Error(`pgserve trust store at ${file} is missing the entries array`);
|
|
79
|
+
e.code = 'ETRUSTSTORE';
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
if (parsed.schemaVersion !== SCHEMA_VERSION) {
|
|
83
|
+
const e = new Error(
|
|
84
|
+
`pgserve trust store schemaVersion ${parsed.schemaVersion} unsupported (expected ${SCHEMA_VERSION})`,
|
|
85
|
+
);
|
|
86
|
+
e.code = 'ETRUSTSTORE';
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
// Per-entry shape check. Without this, a manually-edited
|
|
90
|
+
// identities.json containing `{"entries":[{}]}` would slip past the
|
|
91
|
+
// store-level guard and crash the formatter in `pgserve trust list`
|
|
92
|
+
// with a generic TypeError on first field access — losing the
|
|
93
|
+
// documented exit-2 ("malformed store") path. Fail fast here with
|
|
94
|
+
// the same ETRUSTSTORE code so downstream callers (the CLI command,
|
|
95
|
+
// `pgserve verify`, future provisioner) can branch on it uniformly.
|
|
96
|
+
for (let i = 0; i < parsed.entries.length; i++) {
|
|
97
|
+
const e = parsed.entries[i];
|
|
98
|
+
if (!e || typeof e !== 'object'
|
|
99
|
+
|| typeof e.id !== 'string' || e.id.length === 0
|
|
100
|
+
|| typeof e.issuer !== 'string' || e.issuer.length === 0
|
|
101
|
+
|| typeof e.identityRegexp !== 'string' || e.identityRegexp.length === 0) {
|
|
102
|
+
const err = new Error(
|
|
103
|
+
`pgserve trust store at ${file}: entries[${i}] is missing required fields ` +
|
|
104
|
+
`(id, issuer, identityRegexp)`,
|
|
105
|
+
);
|
|
106
|
+
err.code = 'ETRUSTSTORE';
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Atomically write the trust store. Creates the directory if absent.
|
|
115
|
+
*/
|
|
116
|
+
export function writeTrustStore(store, { homeDir = os.homedir() } = {}) {
|
|
117
|
+
if (!store || typeof store !== 'object' || !Array.isArray(store.entries)) {
|
|
118
|
+
throw new Error('writeTrustStore: store must be { schemaVersion, entries }');
|
|
119
|
+
}
|
|
120
|
+
const dir = getTrustDir(homeDir);
|
|
121
|
+
const file = getTrustFilePath(homeDir);
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
123
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
124
|
+
const payload = JSON.stringify({ schemaVersion: SCHEMA_VERSION, entries: store.entries }, null, 2) + '\n';
|
|
125
|
+
fs.writeFileSync(tmp, payload, { mode: FILE_MODE });
|
|
126
|
+
fs.renameSync(tmp, file);
|
|
127
|
+
try {
|
|
128
|
+
fs.chmodSync(file, FILE_MODE);
|
|
129
|
+
} catch {
|
|
130
|
+
/* best-effort on platforms that ignore mode */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate a single user entry candidate. Throws on bad shape.
|
|
136
|
+
* Returns the normalized entry (trimmed strings, computed addedAt).
|
|
137
|
+
*/
|
|
138
|
+
export function validateEntry(candidate) {
|
|
139
|
+
if (!candidate || typeof candidate !== 'object') {
|
|
140
|
+
throw new Error('trust entry must be an object');
|
|
141
|
+
}
|
|
142
|
+
const required = ['id', 'issuer', 'identityRegexp'];
|
|
143
|
+
for (const key of required) {
|
|
144
|
+
const v = candidate[key];
|
|
145
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
146
|
+
throw new Error(`trust entry field "${key}" must be a non-empty string`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(candidate.id)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`trust entry id "${candidate.id}" must match /^[a-z0-9][a-z0-9._-]{0,63}$/i`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
// Normalize id to lowercase. The regex accepts upper-case (/i flag) so
|
|
155
|
+
// operators can paste pretty identifiers, but storage + lookup are
|
|
156
|
+
// case-insensitive — otherwise an entry typed "Foo" could shadow a
|
|
157
|
+
// hardcoded "foo" silently. Normalizing once on write keeps the
|
|
158
|
+
// hardcoded-shadow check simple and makes `trust remove FOO` idempotent
|
|
159
|
+
// with `trust add foo`.
|
|
160
|
+
const normalizedId = candidate.id.toLowerCase();
|
|
161
|
+
// Validate the identityRegexp parses as JS regex (cosign uses a similar
|
|
162
|
+
// RE2-ish dialect; this catches the obvious garbage while letting valid
|
|
163
|
+
// sigstore patterns through).
|
|
164
|
+
try {
|
|
165
|
+
new RegExp(candidate.identityRegexp);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
throw new Error(`trust entry identityRegexp is not a valid regex: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
id: normalizedId,
|
|
171
|
+
publisher: typeof candidate.publisher === 'string' ? candidate.publisher : '',
|
|
172
|
+
issuer: candidate.issuer,
|
|
173
|
+
identityRegexp: candidate.identityRegexp,
|
|
174
|
+
description: typeof candidate.description === 'string' ? candidate.description : '',
|
|
175
|
+
addedAt: typeof candidate.addedAt === 'string' && candidate.addedAt
|
|
176
|
+
? candidate.addedAt
|
|
177
|
+
: new Date().toISOString(),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isHardcodedId(id) {
|
|
182
|
+
// Compare lowercase-against-lowercase. validateEntry normalizes new
|
|
183
|
+
// user entries to lowercase; hardcoded ids in TRUSTED_IDENTITIES
|
|
184
|
+
// already use lowercase by convention, but we lowercase both sides to
|
|
185
|
+
// make the predicate symmetric and immune to a typo in the hardcoded
|
|
186
|
+
// table from leaking through.
|
|
187
|
+
const needle = id.toLowerCase();
|
|
188
|
+
return TRUSTED_IDENTITIES.some((e) => e.id.toLowerCase() === needle);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Add a user trust entry. Refuses to shadow a hardcoded id.
|
|
193
|
+
* Returns the normalized entry that was written.
|
|
194
|
+
*/
|
|
195
|
+
export function addUserTrust(candidate, opts = {}) {
|
|
196
|
+
const entry = validateEntry(candidate);
|
|
197
|
+
if (isHardcodedId(entry.id)) {
|
|
198
|
+
const e = new Error(
|
|
199
|
+
`cannot add "${entry.id}" — id collides with a hardcoded trust root and would shadow it`,
|
|
200
|
+
);
|
|
201
|
+
e.code = 'ETRUSTSHADOW';
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
const store = readTrustStore(opts);
|
|
205
|
+
const existing = store.entries.findIndex((x) => x.id === entry.id);
|
|
206
|
+
if (existing >= 0) {
|
|
207
|
+
store.entries[existing] = entry;
|
|
208
|
+
} else {
|
|
209
|
+
store.entries.push(entry);
|
|
210
|
+
}
|
|
211
|
+
writeTrustStore(store, opts);
|
|
212
|
+
return entry;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Remove a user trust entry by id. Refuses to remove hardcoded entries.
|
|
217
|
+
* Returns true on success, false if the id was not in the user store.
|
|
218
|
+
*/
|
|
219
|
+
export function removeUserTrust(id, opts = {}) {
|
|
220
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
221
|
+
throw new Error('removeUserTrust: id must be a non-empty string');
|
|
222
|
+
}
|
|
223
|
+
// Lowercase normalization mirrors validateEntry — `trust remove FOO`
|
|
224
|
+
// must find the entry that `trust add foo` (or `trust add Foo`) wrote.
|
|
225
|
+
const normalizedId = id.toLowerCase();
|
|
226
|
+
if (isHardcodedId(normalizedId)) {
|
|
227
|
+
const e = new Error(`cannot remove "${normalizedId}" — hardcoded trust roots are not removable`);
|
|
228
|
+
e.code = 'ETRUSTHARDCODED';
|
|
229
|
+
throw e;
|
|
230
|
+
}
|
|
231
|
+
const store = readTrustStore(opts);
|
|
232
|
+
const before = store.entries.length;
|
|
233
|
+
store.entries = store.entries.filter((x) => x.id !== normalizedId);
|
|
234
|
+
if (store.entries.length === before) return false;
|
|
235
|
+
writeTrustStore(store, opts);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Combined view: hardcoded entries followed by user entries, each tagged
|
|
241
|
+
* with `source` and `removable`. Used by `pgserve trust list`.
|
|
242
|
+
*/
|
|
243
|
+
export function listAllTrust(opts = {}) {
|
|
244
|
+
const hardcoded = listHardcodedTrust();
|
|
245
|
+
const store = readTrustStore(opts);
|
|
246
|
+
const user = store.entries.map((entry) => ({ ...entry, source: 'user', removable: true }));
|
|
247
|
+
return [...hardcoded, ...user];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const __testInternals = Object.freeze({ SCHEMA_VERSION, FILE_MODE, DIR_MODE });
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only audit log writer for `pgserve gc`.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* The wish acceptance criteria require gc to "audit-log every drop" so
|
|
7
|
+
* that an operator can answer "why did my database disappear?" days
|
|
8
|
+
* later. We keep the format intentionally boring: one JSON object per
|
|
9
|
+
* line at `~/.pgserve/audit/gc-<YYYY-MM-DD>.log`, opened with O_APPEND
|
|
10
|
+
* so multiple gc runs on the same day interleave cleanly.
|
|
11
|
+
*
|
|
12
|
+
* One JSON object per event so logs are streamable through tools that
|
|
13
|
+
* expect JSON-lines (jq, fluent-bit, vector, etc.) without a second
|
|
14
|
+
* parser. UTC date in the filename so log rotation across timezones is
|
|
15
|
+
* deterministic.
|
|
16
|
+
*
|
|
17
|
+
* Permissions: dir 0700, file 0600 — same posture as the cosign cache
|
|
18
|
+
* tokens. Audit logs may name databases that contain sensitive tenant
|
|
19
|
+
* identifiers; tightening file mode is cheap insurance.
|
|
20
|
+
*
|
|
21
|
+
* Pure-ish: filesystem I/O is the side effect. No postgres, no network.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
export const AUDIT_DIR_NAME = 'audit';
|
|
29
|
+
export const AUDIT_FILE_PREFIX = 'gc-';
|
|
30
|
+
export const AUDIT_FILE_MODE = 0o600;
|
|
31
|
+
export const AUDIT_DIR_MODE = 0o700;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} GcAuditEvent
|
|
35
|
+
* @property {string} ts ISO 8601 timestamp with ms.
|
|
36
|
+
* @property {'drop'|'skip'|'error'|'start'|'finish'} action
|
|
37
|
+
* @property {string=} fingerprint pgserve_meta.fingerprint
|
|
38
|
+
* @property {string=} database database name acted on
|
|
39
|
+
* @property {string=} role role name acted on
|
|
40
|
+
* @property {string=} reason finding.reason from orphan detection
|
|
41
|
+
* ('missing_db' | 'missing_path' | …)
|
|
42
|
+
* or a free-form skip / error reason.
|
|
43
|
+
* @property {string=} detail operator-facing detail line.
|
|
44
|
+
* @property {string=} dryRun present when --dry-run; the audit
|
|
45
|
+
* line then records what *would* have
|
|
46
|
+
* happened.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
export function getAuditDir({ homeDir = os.homedir() } = {}) {
|
|
50
|
+
return path.join(homeDir, '.pgserve', AUDIT_DIR_NAME);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the audit-file path for a given UTC date. Defaults to "today".
|
|
55
|
+
*/
|
|
56
|
+
export function getAuditFilePath({ homeDir = os.homedir(), date = new Date() } = {}) {
|
|
57
|
+
const yyyyMmDd = formatUtcDate(date);
|
|
58
|
+
return path.join(getAuditDir({ homeDir }), `${AUDIT_FILE_PREFIX}${yyyyMmDd}.log`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatUtcDate(date) {
|
|
62
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
63
|
+
throw new TypeError('formatUtcDate: date must be a valid Date');
|
|
64
|
+
}
|
|
65
|
+
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
|
66
|
+
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
67
|
+
const dd = String(date.getUTCDate()).padStart(2, '0');
|
|
68
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Append a single gc audit event. Returns the line that was written
|
|
73
|
+
* (without the trailing newline) so callers can mirror it to stdout.
|
|
74
|
+
*
|
|
75
|
+
* @param {GcAuditEvent} event
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {string} [opts.homeDir] override for tests
|
|
78
|
+
* @param {Date} [opts.date] override for tests; defaults to now
|
|
79
|
+
*/
|
|
80
|
+
export function writeGcAudit(event, opts = {}) {
|
|
81
|
+
if (!event || typeof event !== 'object') {
|
|
82
|
+
throw new TypeError('writeGcAudit: event must be an object');
|
|
83
|
+
}
|
|
84
|
+
if (typeof event.action !== 'string' || event.action.length === 0) {
|
|
85
|
+
throw new TypeError('writeGcAudit: event.action is required');
|
|
86
|
+
}
|
|
87
|
+
const dir = getAuditDir(opts);
|
|
88
|
+
const file = getAuditFilePath(opts);
|
|
89
|
+
fs.mkdirSync(dir, { recursive: true, mode: AUDIT_DIR_MODE });
|
|
90
|
+
// mkdirSync's `mode` only applies on creation. If the audit dir was
|
|
91
|
+
// previously created with a looser umask (older gc versions, manual
|
|
92
|
+
// mkdir -p, restored backup) it stays at whatever mode it had —
|
|
93
|
+
// tighten it to 0700 to match the file-side belt-and-suspenders.
|
|
94
|
+
try {
|
|
95
|
+
fs.chmodSync(dir, AUDIT_DIR_MODE);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort on platforms that ignore chmod */
|
|
98
|
+
}
|
|
99
|
+
// ts must be the canonical ISO 8601 string unless the caller supplied
|
|
100
|
+
// a non-empty string (correlation-id use case). The spread MUST come
|
|
101
|
+
// first so a stray `ts: undefined` / `ts: 0` / `ts: new Date()` from
|
|
102
|
+
// the caller cannot silently overwrite our generated value — JS
|
|
103
|
+
// object-spread precedence means later keys win. (Earlier shape had
|
|
104
|
+
// this inverted with a wrong comment claiming the spread "doesn't
|
|
105
|
+
// overwrite" — it does.)
|
|
106
|
+
const enriched = {
|
|
107
|
+
...event,
|
|
108
|
+
ts: typeof event.ts === 'string' && event.ts ? event.ts : new Date().toISOString(),
|
|
109
|
+
};
|
|
110
|
+
const line = JSON.stringify(enriched);
|
|
111
|
+
fs.appendFileSync(file, line + '\n', { mode: AUDIT_FILE_MODE });
|
|
112
|
+
// appendFileSync's `mode` only applies on file creation; chmod the
|
|
113
|
+
// existing file to be safe in case it was previously created with a
|
|
114
|
+
// looser umask (older gc versions, manual touches, etc.).
|
|
115
|
+
try {
|
|
116
|
+
fs.chmodSync(file, AUDIT_FILE_MODE);
|
|
117
|
+
} catch {
|
|
118
|
+
/* best-effort on platforms that ignore chmod */
|
|
119
|
+
}
|
|
120
|
+
return line;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read all events for a single UTC date. Returns parsed objects; lines
|
|
125
|
+
* that fail to parse are returned as `{ malformed: true, raw: <line> }`
|
|
126
|
+
* so a corrupt earlier write doesn't make the rest of the file
|
|
127
|
+
* unreadable. Missing file → empty array.
|
|
128
|
+
*/
|
|
129
|
+
export function readGcAuditDay({ homeDir = os.homedir(), date = new Date() } = {}) {
|
|
130
|
+
const file = getAuditFilePath({ homeDir, date });
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err.code === 'ENOENT') return [];
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const line of raw.split('\n')) {
|
|
140
|
+
if (line.length === 0) continue;
|
|
141
|
+
try {
|
|
142
|
+
out.push(JSON.parse(line));
|
|
143
|
+
} catch {
|
|
144
|
+
out.push({ malformed: true, raw: line });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const __testInternals = Object.freeze({ formatUtcDate });
|