@dooer/dooer-test-env 1.0.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/bin/index.js +7 -0
- package/discovery-router/Dockerfile +18 -0
- package/discovery-router/README.md +99 -0
- package/discovery-router/package.json +13 -0
- package/discovery-router/registry.example.json +5 -0
- package/discovery-router/server.js +272 -0
- package/lib/account.js +120 -0
- package/lib/auth-dev-keys.js +12 -0
- package/lib/bankid.js +130 -0
- package/lib/cli.js +27 -0
- package/lib/command/bankid.js +45 -0
- package/lib/command/customer.js +108 -0
- package/lib/command/db.js +156 -0
- package/lib/command/env.js +114 -0
- package/lib/command/logs.js +143 -0
- package/lib/command/measure.js +81 -0
- package/lib/command/service.js +166 -0
- package/lib/command/setup.js +92 -0
- package/lib/command/shred.js +60 -0
- package/lib/compose/README.md +98 -0
- package/lib/compose/generate.js +375 -0
- package/lib/compose/manifests.js +108 -0
- package/lib/db/roles.js +118 -0
- package/lib/discovery/client.js +40 -0
- package/lib/engine/GUIDE.md +176 -0
- package/lib/engine/PROCESS.md +571 -0
- package/lib/engine/dbbuild.js +325 -0
- package/lib/engine/gen-schema-map.js +479 -0
- package/lib/engine/purge.js +137 -0
- package/lib/engine/schema-map.json +11016 -0
- package/lib/engine/seed.js +1045 -0
- package/lib/obc.js +72 -0
- package/lib/registry.js +123 -0
- package/lib/runtime.js +101 -0
- package/lib/service-token.js +40 -0
- package/lib/shred/README.md +118 -0
- package/lib/shred/audit.js +128 -0
- package/lib/shred/faker.js +545 -0
- package/lib/shred/index.js +126 -0
- package/lib/shred/scripts/base-partner-emails.sql +9 -0
- package/lib/shred/scripts/dev-accounts.sql +195 -0
- package/lib/shred/scripts/emails.sql +48 -0
- package/lib/shred/scripts/institution-browser.sql +3 -0
- package/lib/shred/scripts/notification-targets.sql +5 -0
- package/lib/shred/scripts/partners.sql +2 -0
- package/lib/shred/scripts/passwords.sql +8 -0
- package/lib/shred/scripts/personal-numbers.sql +177 -0
- package/lib/shred/scripts/phone-numbers.sql +22 -0
- package/lib/shred/scripts/salary-spec-reports.sql +5 -0
- package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
- package/lib/shred/scripts/service-core-objects.sql +19 -0
- package/lib/shred/scripts/service-event-stream.sql +2 -0
- package/lib/shred/scripts/service-integrations.sql +4 -0
- package/lib/shred/scripts/template.sql +4 -0
- package/lib/shred/scripts/x-service-billing.sql +34 -0
- package/lib/shred/scripts/xxx-history-tables.sql +25 -0
- package/lib/stub.js +8 -0
- package/local-postgres/Dockerfile +11 -0
- package/package.json +46 -0
- package/readme.md +92 -0
package/lib/obc.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// obc.js — access the base-artifact bucket provisioned by an ObjectBucketClaim (Rook-Ceph). An OBC yields
|
|
2
|
+
// a ConfigMap (BUCKET_NAME/BUCKET_HOST/BUCKET_PORT) and a Secret (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY).
|
|
3
|
+
// In-cluster the Job mounts these as env; a dev `db pull` reads them via kubectl from the OBC's namespace.
|
|
4
|
+
const { execFileSync } = require('child_process')
|
|
5
|
+
const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3')
|
|
6
|
+
|
|
7
|
+
function sh(file, args) {
|
|
8
|
+
return execFileSync(file, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// resolve OBC connection: prefer env (in-cluster), else kubectl-read the OBC ConfigMap+Secret.
|
|
12
|
+
function obcConfig({ obcName = 'dooer-test-env-base', namespace } = {}) {
|
|
13
|
+
let bucket = process.env.BUCKET_NAME
|
|
14
|
+
let host = process.env.BUCKET_HOST
|
|
15
|
+
let port = process.env.BUCKET_PORT
|
|
16
|
+
let ak = process.env.AWS_ACCESS_KEY_ID
|
|
17
|
+
let sk = process.env.AWS_SECRET_ACCESS_KEY
|
|
18
|
+
if (!(bucket && host && ak && sk)) {
|
|
19
|
+
if (!namespace) throw new Error('OBC not in env; pass --obc-namespace to read the OBC via kubectl')
|
|
20
|
+
const cm = JSON.parse(sh('kubectl', ['get', 'configmap', obcName, '-n', namespace, '-o', 'json']))
|
|
21
|
+
const sec = JSON.parse(sh('kubectl', ['get', 'secret', obcName, '-n', namespace, '-o', 'json']))
|
|
22
|
+
bucket = cm.data.BUCKET_NAME
|
|
23
|
+
host = cm.data.BUCKET_HOST
|
|
24
|
+
port = cm.data.BUCKET_PORT || '80'
|
|
25
|
+
ak = Buffer.from(sec.data.AWS_ACCESS_KEY_ID, 'base64').toString('utf8')
|
|
26
|
+
sk = Buffer.from(sec.data.AWS_SECRET_ACCESS_KEY, 'base64').toString('utf8')
|
|
27
|
+
}
|
|
28
|
+
// BUCKET_HOST is a k8s service name in-cluster; over VPN a dev resolves it to the rook-ceph ClusterIP
|
|
29
|
+
let endpoint = `http://${host}:${port || 80}`
|
|
30
|
+
if (namespace && !process.env.BUCKET_HOST) {
|
|
31
|
+
const svc = host.split('.')[0]
|
|
32
|
+
const ip = sh('kubectl', ['get', 'svc', svc, '-n', 'rook-ceph', '-o', 'jsonpath={.spec.clusterIP}'])
|
|
33
|
+
if (ip) endpoint = `http://${ip}:${port || 80}`
|
|
34
|
+
}
|
|
35
|
+
const client = new S3Client({
|
|
36
|
+
endpoint,
|
|
37
|
+
region: 'us-east-1',
|
|
38
|
+
forcePathStyle: true,
|
|
39
|
+
credentials: { accessKeyId: ak, secretAccessKey: sk },
|
|
40
|
+
})
|
|
41
|
+
return { client, bucket }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const PREFIX = 'base/'
|
|
45
|
+
|
|
46
|
+
async function upload(cfg, file, key) {
|
|
47
|
+
const fs = require('fs')
|
|
48
|
+
const Body = fs.readFileSync(file)
|
|
49
|
+
const Key = key || `${PREFIX}dooer-base-db-${file.replace(/.*dooer-base-db-/, '').replace(/\.dump.*/, '')}.dump`
|
|
50
|
+
await cfg.client.send(new PutObjectCommand({ Bucket: cfg.bucket, Key, Body }))
|
|
51
|
+
return Key
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function newestKey(cfg) {
|
|
55
|
+
const r = await cfg.client.send(new ListObjectsV2Command({ Bucket: cfg.bucket, Prefix: PREFIX }))
|
|
56
|
+
const items = (r.Contents || [])
|
|
57
|
+
.filter((o) => o.Key.endsWith('.dump'))
|
|
58
|
+
.sort((a, b) => new Date(b.LastModified) - new Date(a.LastModified))
|
|
59
|
+
if (!items.length) throw new Error(`no base artifact under ${cfg.bucket}/${PREFIX}`)
|
|
60
|
+
return items[0].Key
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function download(cfg, outFile, key) {
|
|
64
|
+
const fs = require('fs')
|
|
65
|
+
const Key = key || (await newestKey(cfg))
|
|
66
|
+
const obj = await cfg.client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key }))
|
|
67
|
+
const bytes = await obj.Body.transformToByteArray()
|
|
68
|
+
fs.writeFileSync(outFile, Buffer.from(bytes))
|
|
69
|
+
return { file: outFile, key: Key }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { obcConfig, upload, download, newestKey, PREFIX }
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// registry.js — make `docker pull` work for the two private registries WITHOUT any manual steps. The
|
|
2
|
+
// cluster already holds pull secrets (imagePullSecrets); we read them via kubectl, decode the
|
|
3
|
+
// dockerconfigjson, and `docker login` each registry. If kubectl/the secret isn't available we fall back
|
|
4
|
+
// to env vars and, on a TTY, an interactive prompt — so a dev is only ever asked when creds truly can't
|
|
5
|
+
// be found. Used by `setup` and implicitly by `up`.
|
|
6
|
+
const { execFileSync, spawnSync } = require('child_process')
|
|
7
|
+
const readline = require('readline')
|
|
8
|
+
|
|
9
|
+
// the imagePullSecrets the s-e032 manifests reference
|
|
10
|
+
const PULL_SECRETS = ['aws-ecr-secret', 'push-secret-robo10']
|
|
11
|
+
|
|
12
|
+
function sh(file, args, opts = {}) {
|
|
13
|
+
return execFileSync(file, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...opts }).trim()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// is the binary on PATH? "ran at all" (no ENOENT), regardless of exit code — `kubectl --version` is an
|
|
17
|
+
// invalid flag but still proves kubectl is installed.
|
|
18
|
+
function have(cmd) {
|
|
19
|
+
const r = spawnSync(cmd, ['--version'], { encoding: 'utf8' })
|
|
20
|
+
return !r.error
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// { host: { username, password } } parsed from a k8s dockerconfigjson secret (or null if unreadable)
|
|
24
|
+
function authsFromSecret(secretName, namespace) {
|
|
25
|
+
let b64
|
|
26
|
+
try {
|
|
27
|
+
b64 = sh('kubectl', ['get', 'secret', secretName, '-n', namespace, '-o', 'jsonpath={.data.\\.dockerconfigjson}'])
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
if (!b64) return null
|
|
32
|
+
let cfg
|
|
33
|
+
try {
|
|
34
|
+
cfg = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'))
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
const out = {}
|
|
39
|
+
for (const [host, entry] of Object.entries(cfg.auths || {})) {
|
|
40
|
+
let { username, password } = entry
|
|
41
|
+
if ((!username || !password) && entry.auth) {
|
|
42
|
+
const [u, ...p] = Buffer.from(entry.auth, 'base64').toString('utf8').split(':')
|
|
43
|
+
username = username || u
|
|
44
|
+
password = password || p.join(':')
|
|
45
|
+
}
|
|
46
|
+
if (username && password) out[host] = { username, password }
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function dockerLogin(host, username, password) {
|
|
52
|
+
const r = spawnSync('docker', ['login', host, '-u', username, '--password-stdin'], {
|
|
53
|
+
input: password,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
})
|
|
56
|
+
return r.status === 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function prompt(question, { silent } = {}) {
|
|
60
|
+
if (!process.stdin.isTTY) return null
|
|
61
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
|
|
62
|
+
const answer = await new Promise((resolve) => {
|
|
63
|
+
if (silent) {
|
|
64
|
+
// hide typed characters (password)
|
|
65
|
+
const onData = () => rl.output.write('\r' + question + '*'.repeat(0))
|
|
66
|
+
rl._writeToOutput = () => rl.output.write('')
|
|
67
|
+
rl.question(question, (a) => resolve(a))
|
|
68
|
+
rl.input.on('data', onData)
|
|
69
|
+
} else {
|
|
70
|
+
rl.question(question, (a) => resolve(a))
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
rl.close()
|
|
74
|
+
if (silent) process.stdout.write('\n')
|
|
75
|
+
return answer.trim()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Log docker into both private registries. Returns [{host, ok, via}]. `via` = kubectl | env | prompt.
|
|
79
|
+
async function loginAll({ namespace = 'dooer-staging', interactive = true } = {}) {
|
|
80
|
+
if (!have('docker')) throw new Error('docker is not installed / not on PATH')
|
|
81
|
+
const results = []
|
|
82
|
+
const merged = {}
|
|
83
|
+
for (const s of PULL_SECRETS) {
|
|
84
|
+
const a = authsFromSecret(s, namespace)
|
|
85
|
+
if (a) Object.assign(merged, a)
|
|
86
|
+
}
|
|
87
|
+
const hosts = Object.keys(merged)
|
|
88
|
+
if (hosts.length) {
|
|
89
|
+
for (const host of hosts) {
|
|
90
|
+
const ok = dockerLogin(host, merged[host].username, merged[host].password)
|
|
91
|
+
results.push({ host, ok, via: 'kubectl' })
|
|
92
|
+
}
|
|
93
|
+
return results
|
|
94
|
+
}
|
|
95
|
+
// kubectl/secret unavailable — try env, then prompt
|
|
96
|
+
const envPairs = [
|
|
97
|
+
{
|
|
98
|
+
host: process.env.DOOER_ECR_REGISTRY,
|
|
99
|
+
user: process.env.DOOER_ECR_USERNAME,
|
|
100
|
+
pass: process.env.DOOER_ECR_PASSWORD,
|
|
101
|
+
},
|
|
102
|
+
].filter((p) => p.host && p.user && p.pass)
|
|
103
|
+
if (envPairs.length) {
|
|
104
|
+
for (const p of envPairs) results.push({ host: p.host, ok: dockerLogin(p.host, p.user, p.pass), via: 'env' })
|
|
105
|
+
return results
|
|
106
|
+
}
|
|
107
|
+
if (interactive && process.stdin.isTTY) {
|
|
108
|
+
const host = await prompt('Registry host (e.g. …dkr.ecr.eu-west-1.amazonaws.com): ')
|
|
109
|
+
const user = await prompt('Username: ')
|
|
110
|
+
const pass = await prompt('Password/token: ', { silent: true })
|
|
111
|
+
if (host && user && pass) {
|
|
112
|
+
results.push({ host, ok: dockerLogin(host, user, pass), via: 'prompt' })
|
|
113
|
+
return results
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
throw new Error(
|
|
117
|
+
'could not obtain registry credentials: kubectl could not read the cluster pull secrets ' +
|
|
118
|
+
`(${PULL_SECRETS.join(', ')}) in "${namespace}", and no DOOER_ECR_* env vars were set. ` +
|
|
119
|
+
'Check VPN + kubectl context, or set DOOER_ECR_REGISTRY/USERNAME/PASSWORD.'
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { loginAll, authsFromSecret, dockerLogin, have, PULL_SECRETS }
|
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// runtime.js — shared plumbing for the env/service lifecycle: where generated files + state live, a
|
|
2
|
+
// docker-compose runner, the discovery-router URL, and a tiny JSON state store (local-mode PIDs, deploy
|
|
3
|
+
// overrides). Everything local; nothing here talks to k8s.
|
|
4
|
+
const { spawn, spawnSync } = require('child_process')
|
|
5
|
+
const fs = require('fs')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
const RUN_DIR = process.env.DOOER_TEST_ENV_HOME || path.join(os.homedir(), '.dooer-test-env')
|
|
10
|
+
const COMPOSE_FILE = path.join(RUN_DIR, 'docker-compose.yml')
|
|
11
|
+
const OVERRIDE_FILE = path.join(RUN_DIR, 'docker-compose.override.yml')
|
|
12
|
+
const STATE_FILE = path.join(RUN_DIR, 'state.json')
|
|
13
|
+
const REGISTRY_FILE = path.join(RUN_DIR, 'registry.json')
|
|
14
|
+
const ROUTER_URL = process.env.DOOER_TEST_ENV_ROUTER || 'http://localhost:8500'
|
|
15
|
+
const SERVICES_DIR =
|
|
16
|
+
process.env.DOOER_TEST_ENV_MANIFESTS ||
|
|
17
|
+
path.join(os.homedir(), 'dooer', 'new-infrastructure', 'kubernetes', 'environments', 's-e032-onprem')
|
|
18
|
+
|
|
19
|
+
function ensureRunDir() {
|
|
20
|
+
fs.mkdirSync(RUN_DIR, { recursive: true })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readState() {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
|
|
26
|
+
} catch (_) {
|
|
27
|
+
return { local: {}, deploy: {} }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeState(s) {
|
|
31
|
+
ensureRunDir()
|
|
32
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// build the argv for docker compose, including the override file when it exists
|
|
36
|
+
function composeArgs(rest) {
|
|
37
|
+
const files = ['-f', COMPOSE_FILE]
|
|
38
|
+
if (fs.existsSync(OVERRIDE_FILE)) files.push('-f', OVERRIDE_FILE)
|
|
39
|
+
return [...files, ...rest]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// run `docker compose …` inheriting stdio (interactive output). Returns exit code. `extraEnv` is merged
|
|
43
|
+
// into the child env — used to feed compose ${VAR} interpolation (e.g. bankid secrets read from the
|
|
44
|
+
// keychain at `up` time, so they never touch the generated yaml or disk).
|
|
45
|
+
function dc(rest, { profile, env: extraEnv } = {}) {
|
|
46
|
+
const args = composeArgs(rest)
|
|
47
|
+
const env = { ...process.env, ...extraEnv }
|
|
48
|
+
if (profile) env.COMPOSE_PROFILES = profile
|
|
49
|
+
const r = spawnSync('docker', ['compose', ...args], { stdio: 'inherit', env })
|
|
50
|
+
return r.status == null ? 1 : r.status
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// run `docker compose …` capturing stdout (for ps/parse). Returns { status, stdout }.
|
|
54
|
+
function dcCapture(rest, { profile, env: extraEnv } = {}) {
|
|
55
|
+
const args = composeArgs(rest)
|
|
56
|
+
const env = { ...process.env, ...extraEnv }
|
|
57
|
+
if (profile) env.COMPOSE_PROFILES = profile
|
|
58
|
+
const r = spawnSync('docker', ['compose', ...args], { encoding: 'utf8', env })
|
|
59
|
+
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// spawn a detached host process (for `service local`), return its pid. Logs to RUN_DIR/<name>.local.log.
|
|
63
|
+
function spawnDetached(name, command, cmdArgs, { cwd, env }) {
|
|
64
|
+
ensureRunDir()
|
|
65
|
+
const out = fs.openSync(path.join(RUN_DIR, `${name}.local.log`), 'a')
|
|
66
|
+
const child = spawn(command, cmdArgs, {
|
|
67
|
+
cwd,
|
|
68
|
+
env: { ...process.env, ...env },
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: ['ignore', out, out],
|
|
71
|
+
})
|
|
72
|
+
child.unref()
|
|
73
|
+
return child.pid
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAlive(pid) {
|
|
77
|
+
if (!pid) return false
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0)
|
|
80
|
+
return true
|
|
81
|
+
} catch (_) {
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
RUN_DIR,
|
|
88
|
+
COMPOSE_FILE,
|
|
89
|
+
OVERRIDE_FILE,
|
|
90
|
+
STATE_FILE,
|
|
91
|
+
REGISTRY_FILE,
|
|
92
|
+
ROUTER_URL,
|
|
93
|
+
SERVICES_DIR,
|
|
94
|
+
ensureRunDir,
|
|
95
|
+
readState,
|
|
96
|
+
writeState,
|
|
97
|
+
dc,
|
|
98
|
+
dcCapture,
|
|
99
|
+
spawnDetached,
|
|
100
|
+
isAlive,
|
|
101
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Mint @dooer service tokens for the local env.
|
|
2
|
+
//
|
|
3
|
+
// A service token is an RS256 JWT that @dooer/authentication verifies with { secret: <authPublicKey>,
|
|
4
|
+
// issuer, algorithms:['RS256'] } and whose payload is { type:'service', service_id:<name>, sub:null }
|
|
5
|
+
// (+ iss/iat/exp). Services present it as `Authorization: Bearer <token>` on inter-service calls.
|
|
6
|
+
//
|
|
7
|
+
// Why mint: the `local`/`environment-local` serviceToken baked into every service's config expired in
|
|
8
|
+
// 2019, so falling back to it 401s ("jwt expired"). Rather than edit 58 configs, we sign fresh tokens here
|
|
9
|
+
// with the LOCAL dev private key (the same key service-accounts signs user tokens with, and that every
|
|
10
|
+
// verifier now trusts via DOOER_AUTH_PUBLIC_KEY), giving them a far-future expiry.
|
|
11
|
+
|
|
12
|
+
const crypto = require('crypto')
|
|
13
|
+
|
|
14
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]+$/, '')
|
|
15
|
+
|
|
16
|
+
// Mint one service token. `nowSeconds` is injectable for deterministic tests.
|
|
17
|
+
function mint({
|
|
18
|
+
serviceId,
|
|
19
|
+
privateKeyPem,
|
|
20
|
+
issuer = 'https://example.com/',
|
|
21
|
+
ttlSeconds = 3650 * 24 * 3600,
|
|
22
|
+
nowSeconds,
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!serviceId) throw new Error('mint: serviceId required')
|
|
25
|
+
if (!privateKeyPem) throw new Error('mint: privateKeyPem required')
|
|
26
|
+
const iat = nowSeconds != null ? nowSeconds : Math.floor(Date.now() / 1000)
|
|
27
|
+
const header = { alg: 'RS256', typ: 'JWT' }
|
|
28
|
+
const payload = { type: 'service', service_id: serviceId, sub: null, iss: issuer, iat, exp: iat + ttlSeconds }
|
|
29
|
+
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`
|
|
30
|
+
const signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem)
|
|
31
|
+
return `${signingInput}.${b64url(signature)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Decode a JWT's payload (no verification) — used to report exp in the verify command.
|
|
35
|
+
function decodePayload(token) {
|
|
36
|
+
const part = token.split('.')[1]
|
|
37
|
+
return JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { mint, decodePayload }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# `lib/shred` — the SHRED (anonymization) engine
|
|
2
|
+
|
|
3
|
+
Anonymizes PII in a **local** Postgres. Ported from the old `cli-db-shredder` (a `.dump`-era snapshot
|
|
4
|
+
shredder), adapted for this CLI: no `psql` subprocess, no AWS/RDS/ZFS, deterministic where feasible,
|
|
5
|
+
emails always anonymized to the repo convention, plus a **PII audit** that is the safety backstop against
|
|
6
|
+
schema drift. See `ENVIRONMENT-PLAN.md` §1 (shredder), §5 (base build + Safety rails), §9.7.
|
|
7
|
+
|
|
8
|
+
The dev-facing `dooer-test-env shred` command (`lib/command/shred.js`) owns the localhost guard and the
|
|
9
|
+
DB connection; this module takes an already-connected `pg` Client.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const { shred } = require('./lib/shred')
|
|
13
|
+
await shred(pgClient, { execute: false, verbose: true }) // dry-run: lists + audits, writes nothing
|
|
14
|
+
await shred(pgClient, { execute: true, verbose: true }) // runs the rules
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## What it does & the ordering
|
|
18
|
+
|
|
19
|
+
Two rule layers run in a **fixed order** (the same order the original enforced via filename prefixes):
|
|
20
|
+
|
|
21
|
+
1. **JS faker pass** — `faker.js` `fakePersonalData()`. Runs **FIRST**. Rewrites names / addresses /
|
|
22
|
+
phones / company names and sets emails, across `service_accounts`, `service_employees(_legacy)`,
|
|
23
|
+
`service_salaries(_legacy)`, `service_billing`, `service_core_objects.payments`, `service_sales`,
|
|
24
|
+
`service_comments`, `service_customer_questions`. Deterministic per row (see below).
|
|
25
|
+
2. **SQL scripts** — `scripts/*.sql`, run in **filename order**. The `x-` / `xxx-` prefixes keep the
|
|
26
|
+
denormalized-JSON rebuild and the history truncations **last**:
|
|
27
|
+
- `dev-accounts.sql` — seeds known dev logins (`johan`, `osm`, … `jma`) + Luhn helper functions.
|
|
28
|
+
- `emails.sql` — hashes remaining email columns → `testcustomer+MD5(...)@dooer.com` (unique).
|
|
29
|
+
- `institution-browser.sql`, `notification-targets.sql`, `service-activity-tracker-data.sql`,
|
|
30
|
+
`service-event-stream.sql`, `service-integrations.sql` — truncate cookies / twilio / trackers /
|
|
31
|
+
events / integrations.
|
|
32
|
+
- `partners.sql` — makes `dooer-devteam` the default partner.
|
|
33
|
+
- `passwords.sql` — every user password → bcrypt of a known dev password; truncates users history.
|
|
34
|
+
- `personal-numbers.sql` — Luhn-generated personnummer with dates forced to **18xx** (can't collide
|
|
35
|
+
with a real person), fake org numbers, `users.id_number` emptied, BankID sessions truncated.
|
|
36
|
+
- `phone-numbers.sql` — `+46555…` phone numbers.
|
|
37
|
+
- `salary-spec-reports.sql`, `service-core-objects.sql` — rebuild report descriptions / redact
|
|
38
|
+
report JSON legalName.
|
|
39
|
+
- `x-service-billing.sql` — rebuilds denormalized `invoice.frozenCustomerDetails` from the
|
|
40
|
+
(already-faked) customer/contact/companyInfo rows. **Must run after the faker pass** — hence `x-`.
|
|
41
|
+
- `xxx-history-tables.sql` — truncates the `*_history` tables **last**.
|
|
42
|
+
|
|
43
|
+
Then the **PII audit** runs (read-only) and its result is returned/printed.
|
|
44
|
+
|
|
45
|
+
### Email formats
|
|
46
|
+
|
|
47
|
+
- **Faker pass**: `testcustomer+<pkhex>@dooer.com`, where `<pkhex>` is the row's primary key with
|
|
48
|
+
hyphens stripped (unique per row).
|
|
49
|
+
- **`emails.sql`**: `testcustomer+<MD5(original)>@dooer.com` (the original kept `MD5(...)` for
|
|
50
|
+
uniqueness; only the domain `@d-e023.com`→`@dooer.com` and prefix `redacted+`→`testcustomer+` changed).
|
|
51
|
+
|
|
52
|
+
### Determinism
|
|
53
|
+
|
|
54
|
+
The faker pass uses a **dependency-free** seeded PRNG (`xmur3` → `mulberry32`) seeded from each row's pk
|
|
55
|
+
hex — same pk ⇒ identical output, different pk ⇒ different. No `faker` npm dep. (The original used
|
|
56
|
+
`faker.seed(parseInt(pk,16))`, which lost precision on full uuids; this hashes the whole key.)
|
|
57
|
+
The SQL scripts still use `random()` for personnummer/phones (ported verbatim, non-deterministic) — the
|
|
58
|
+
ENVIRONMENT-PLAN §10 note about converting those to hash-based generation is a follow-up, not done here.
|
|
59
|
+
|
|
60
|
+
### Protection predicates (preserved from the original)
|
|
61
|
+
|
|
62
|
+
- Companies tagged internal are spared: `'test-company' = any(tags)`.
|
|
63
|
+
- Internal user emails are spared (kept real so dev logins work): `@dooer.com` / `@voitto.se` /
|
|
64
|
+
`@steffner.nu` (and `@example.com` in the personnummer allow-list).
|
|
65
|
+
|
|
66
|
+
## The PII audit (`audit.js`)
|
|
67
|
+
|
|
68
|
+
`auditPii(pgClient)` scans `information_schema.columns` in the `service_*` schemas (excluding
|
|
69
|
+
`*_history`) for **PII-shaped column names**:
|
|
70
|
+
|
|
71
|
+
| category | matches |
|
|
72
|
+
|---|---|
|
|
73
|
+
| email | column name contains `email` |
|
|
74
|
+
| personnummer | `personal_number`, `id_number`, or contains `personnummer` (incl. `sePersonnummer`) |
|
|
75
|
+
| phone | column name contains `phone` (incl. `cell_phone`, `cellPhone`) |
|
|
76
|
+
|
|
77
|
+
Each match is cross-checked against an **explicit covered-set** (`COVERED_COLUMNS` in `audit.js`) — the
|
|
78
|
+
email/personnummer/phone columns the vendored rules actually touch. Anything matched but **not** in the
|
|
79
|
+
covered-set is returned as `uncovered`:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
auditPii(pgClient) → { uncovered: [{schema,table,column,category}], covered: N, scanned: M }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`shred` prints this. The Phase-1 **`db build` MUST fail** when `uncovered` is non-empty (ENVIRONMENT-PLAN
|
|
86
|
+
§5 step 4). Dev-facing `shred` only reports it.
|
|
87
|
+
|
|
88
|
+
### Covered columns (email / personnummer / phone)
|
|
89
|
+
|
|
90
|
+
Emails: `service_accounts.users.email`, `.contactEmail`; `service_accounts.incoming_email_whitelists.email`;
|
|
91
|
+
`service_accounts.invites.email`; `service_employees.employee.email`;
|
|
92
|
+
`service_employees_legacy.employee.email`; `service_billing.customerContact.email`;
|
|
93
|
+
`service_billing.companyInformation.email`; `service_outgoing_email.email.toEmail`;
|
|
94
|
+
`service_invoices.invoice.companyEmail`, `.billingEmail`; `service_orders.contact.email`;
|
|
95
|
+
`service_salaries.salarySpecification.employeeEmail`.
|
|
96
|
+
Personnummer: `service_employees.employee.sePersonnummer`;
|
|
97
|
+
`service_employees_legacy.employee.sePersonnummer`; `service_accounts.users.id_number`.
|
|
98
|
+
Phone: `service_accounts.users.phone`, `.cell_phone`; `service_employees.employee.phone`;
|
|
99
|
+
`service_employees_legacy.employee.phone`; `service_orders.contact.phone`;
|
|
100
|
+
`service_billing.companyInformation.phone`; `service_billing.customerContact.cellPhone`.
|
|
101
|
+
|
|
102
|
+
(The faker pass also rewrites many name/address columns; the audit only tracks the three PII-shaped
|
|
103
|
+
name categories above — names/addresses aren't reliably detectable by column name.)
|
|
104
|
+
|
|
105
|
+
## KNOWN caveat — vendored rules can be stale vs the current schema
|
|
106
|
+
|
|
107
|
+
The SQL scripts and the covered-set were captured from a point-in-time schema. A **renamed/dropped
|
|
108
|
+
column** makes the owning script fail; a **new service or column** is not shredded until a rule is added.
|
|
109
|
+
Two things catch this:
|
|
110
|
+
|
|
111
|
+
1. Each SQL script runs in its **own transaction** and errors are **collected, not fatal** — the runner
|
|
112
|
+
reports exactly which scripts failed (schema drift) and continues, rather than aborting silently.
|
|
113
|
+
2. The **PII audit** flags any PII-shaped column not in the covered-set — the automated replacement for
|
|
114
|
+
the old "each team adds its own `.sql`" gap. When it flags something: add the column to the relevant
|
|
115
|
+
SQL script / faker block **and** to `COVERED_COLUMNS`.
|
|
116
|
+
|
|
117
|
+
The faker pass is also best-effort per statement (a missing legacy table is a collected warning, not a
|
|
118
|
+
crash), for the same drift-tolerance reason.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// audit.js — the PII audit that is the safety backstop against schema drift / uncovered columns.
|
|
2
|
+
//
|
|
3
|
+
// The old shredder's coverage was opt-in (each team added its own .sql), so a NEW service or a renamed
|
|
4
|
+
// column could silently leak PII. This scans `information_schema.columns` for email / personnummer /
|
|
5
|
+
// phone-shaped column names in the live `service_*` schemas (excluding `*_history`) and cross-checks them
|
|
6
|
+
// against the explicit set of columns the vendored rules (SQL scripts + faker.js) actually touch. Any
|
|
7
|
+
// PII-shaped column NOT in the covered set is returned as `uncovered` — `db build` FAILS the build if
|
|
8
|
+
// that list is non-empty (ENVIRONMENT-PLAN.md §5 step 4 / §10).
|
|
9
|
+
|
|
10
|
+
// ── PII-shaped column-name matchers ──────────────────────────────────────────────
|
|
11
|
+
// email: any column whose name contains "email" (email, toEmail, companyEmail, contactEmail…)
|
|
12
|
+
// personnummer: personal_number / id_number / anything containing "personnummer" (incl. sePersonnummer)
|
|
13
|
+
// phone: any column whose name contains "phone" (phone, cell_phone, cellPhone…)
|
|
14
|
+
// Exclude name shapes that CONTAIN email/phone but are NOT an address/number: ids/refs (…Id, …_pk,
|
|
15
|
+
// …MessageID), flags (email_verified), settings (…ViaEmail/…ByEmail), and non-value text (…Name,
|
|
16
|
+
// …Signature, emailService/…ServiceId). These caused false positives in the base audit.
|
|
17
|
+
const NOT_A_VALUE = /(_pk|id|messageid|verified|service|serviceid|name|signature|viaemail|byemail)$/i
|
|
18
|
+
const MATCHERS = [
|
|
19
|
+
{ category: 'email', test: (name) => /email/i.test(name) && !NOT_A_VALUE.test(name) },
|
|
20
|
+
{
|
|
21
|
+
category: 'personnummer',
|
|
22
|
+
test: (name) => /personnummer/i.test(name) || /^(personal_number|id_number)$/i.test(name),
|
|
23
|
+
},
|
|
24
|
+
{ category: 'phone', test: (name) => /phone/i.test(name) && !NOT_A_VALUE.test(name) },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function matchCategory(columnName) {
|
|
28
|
+
for (const m of MATCHERS) if (m.test(columnName)) return m.category
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── explicit covered-set: every email/personnummer/phone-shaped column the vendored rules touch ──────
|
|
33
|
+
// Keyed "schema.table.column" (compared case-insensitively). Kept explicit (not parsed from the SQL) so
|
|
34
|
+
// it is auditable and stable; the README lists these and the KNOWN caveat that they can drift vs schema.
|
|
35
|
+
const COVERED_COLUMNS = [
|
|
36
|
+
// emails — faker.js pass
|
|
37
|
+
'service_accounts.users.email',
|
|
38
|
+
'service_employees.employee.email',
|
|
39
|
+
'service_employees_legacy.employee.email',
|
|
40
|
+
'service_billing.customerContact.email',
|
|
41
|
+
// emails — SQL scripts (emails.sql)
|
|
42
|
+
'service_accounts.users.contactEmail',
|
|
43
|
+
'service_accounts.incoming_email_whitelists.email',
|
|
44
|
+
'service_accounts.invites.email',
|
|
45
|
+
'service_outgoing_email.email.toEmail',
|
|
46
|
+
'service_invoices.invoice.companyEmail',
|
|
47
|
+
'service_invoices.invoice.billingEmail',
|
|
48
|
+
'service_orders.contact.email',
|
|
49
|
+
'service_billing.companyInformation.email',
|
|
50
|
+
'service_salaries.salarySpecification.employeeEmail',
|
|
51
|
+
// personnummer — personal-numbers.sql (+ users.id_number emptied there)
|
|
52
|
+
'service_employees.employee.sePersonnummer',
|
|
53
|
+
'service_employees_legacy.employee.sePersonnummer',
|
|
54
|
+
'service_accounts.users.id_number',
|
|
55
|
+
// phones — faker.js pass + phone-numbers.sql
|
|
56
|
+
'service_accounts.users.phone',
|
|
57
|
+
'service_accounts.users.cell_phone',
|
|
58
|
+
'service_employees.employee.phone',
|
|
59
|
+
'service_employees_legacy.employee.phone',
|
|
60
|
+
'service_orders.contact.phone',
|
|
61
|
+
'service_billing.companyInformation.phone',
|
|
62
|
+
'service_billing.customerContact.cellPhone',
|
|
63
|
+
// base-build additions — anonymized by base-partner-emails.sql
|
|
64
|
+
'service_accounts.partner.contactEmailAddress',
|
|
65
|
+
'service_accounts.partnerInvite.email',
|
|
66
|
+
// base-build ACCEPTED (Jimmy 2026-09-01): dooer-internal / reference, not customer PII — left as-is
|
|
67
|
+
'service_orders.consultant.email',
|
|
68
|
+
'service_orders.consultant.phone',
|
|
69
|
+
'service_suppliers.scamSupplier.phoneNumber',
|
|
70
|
+
'service_voice.redirection.phoneNumber',
|
|
71
|
+
'service_voice.switchboard.phoneNumber',
|
|
72
|
+
'service_voice.userPhoneNumber.phoneNumber',
|
|
73
|
+
'service_voice.userPhoneNumber.redirectPhoneNumber',
|
|
74
|
+
'service_voice.userPhoneNumber.voipPhoneNumber',
|
|
75
|
+
].map((s) => s.toLowerCase())
|
|
76
|
+
|
|
77
|
+
const COVERED_SET = new Set(COVERED_COLUMNS)
|
|
78
|
+
|
|
79
|
+
const isCovered = (schema, table, column) => COVERED_SET.has(`${schema}.${table}.${column}`.toLowerCase())
|
|
80
|
+
|
|
81
|
+
// Scan the live DB for PII-shaped columns not covered by a rule.
|
|
82
|
+
// Returns { uncovered: [{schema,table,column,category}], covered: N, scanned: M }.
|
|
83
|
+
async function auditPii(client) {
|
|
84
|
+
const res = await client.query(
|
|
85
|
+
`SELECT table_schema AS schema, table_name AS table, column_name AS column
|
|
86
|
+
FROM information_schema.columns
|
|
87
|
+
WHERE table_schema LIKE 'service\\_%'
|
|
88
|
+
AND table_schema NOT LIKE '%\\_history'
|
|
89
|
+
ORDER BY table_schema, table_name, column_name`
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const q = (s) => `"${String(s).replace(/"/g, '""')}"`
|
|
93
|
+
const uncovered = []
|
|
94
|
+
let covered = 0
|
|
95
|
+
let scanned = 0
|
|
96
|
+
for (const row of res.rows) {
|
|
97
|
+
const category = matchCategory(row.column)
|
|
98
|
+
if (!category) continue
|
|
99
|
+
scanned++
|
|
100
|
+
if (isCovered(row.schema, row.table, row.column)) {
|
|
101
|
+
covered++
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
// DATA-AWARE: only flag an uncovered column that actually HOLDS data. In the base (no organizations)
|
|
105
|
+
// most PII-shaped columns live in empty org tables (schema present, 0 rows) — those are not a leak.
|
|
106
|
+
let hasData = true
|
|
107
|
+
try {
|
|
108
|
+
const r = await client.query(
|
|
109
|
+
`SELECT 1 FROM ${q(row.schema)}.${q(row.table)} WHERE ${q(row.column)} IS NOT NULL AND ${q(
|
|
110
|
+
row.column
|
|
111
|
+
)}::text <> '' LIMIT 1`
|
|
112
|
+
)
|
|
113
|
+
hasData = r.rowCount > 0
|
|
114
|
+
} catch (_) {
|
|
115
|
+
hasData = true // if we can't check, be conservative and flag it
|
|
116
|
+
}
|
|
117
|
+
if (hasData) uncovered.push({ schema: row.schema, table: row.table, column: row.column, category })
|
|
118
|
+
}
|
|
119
|
+
return { uncovered, covered, scanned }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
auditPii,
|
|
124
|
+
matchCategory,
|
|
125
|
+
isCovered,
|
|
126
|
+
COVERED_COLUMNS,
|
|
127
|
+
MATCHERS,
|
|
128
|
+
}
|