@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.
Files changed (60) hide show
  1. package/bin/index.js +7 -0
  2. package/discovery-router/Dockerfile +18 -0
  3. package/discovery-router/README.md +99 -0
  4. package/discovery-router/package.json +13 -0
  5. package/discovery-router/registry.example.json +5 -0
  6. package/discovery-router/server.js +272 -0
  7. package/lib/account.js +120 -0
  8. package/lib/auth-dev-keys.js +12 -0
  9. package/lib/bankid.js +130 -0
  10. package/lib/cli.js +27 -0
  11. package/lib/command/bankid.js +45 -0
  12. package/lib/command/customer.js +108 -0
  13. package/lib/command/db.js +156 -0
  14. package/lib/command/env.js +114 -0
  15. package/lib/command/logs.js +143 -0
  16. package/lib/command/measure.js +81 -0
  17. package/lib/command/service.js +166 -0
  18. package/lib/command/setup.js +92 -0
  19. package/lib/command/shred.js +60 -0
  20. package/lib/compose/README.md +98 -0
  21. package/lib/compose/generate.js +375 -0
  22. package/lib/compose/manifests.js +108 -0
  23. package/lib/db/roles.js +118 -0
  24. package/lib/discovery/client.js +40 -0
  25. package/lib/engine/GUIDE.md +176 -0
  26. package/lib/engine/PROCESS.md +571 -0
  27. package/lib/engine/dbbuild.js +325 -0
  28. package/lib/engine/gen-schema-map.js +479 -0
  29. package/lib/engine/purge.js +137 -0
  30. package/lib/engine/schema-map.json +11016 -0
  31. package/lib/engine/seed.js +1045 -0
  32. package/lib/obc.js +72 -0
  33. package/lib/registry.js +123 -0
  34. package/lib/runtime.js +101 -0
  35. package/lib/service-token.js +40 -0
  36. package/lib/shred/README.md +118 -0
  37. package/lib/shred/audit.js +128 -0
  38. package/lib/shred/faker.js +545 -0
  39. package/lib/shred/index.js +126 -0
  40. package/lib/shred/scripts/base-partner-emails.sql +9 -0
  41. package/lib/shred/scripts/dev-accounts.sql +195 -0
  42. package/lib/shred/scripts/emails.sql +48 -0
  43. package/lib/shred/scripts/institution-browser.sql +3 -0
  44. package/lib/shred/scripts/notification-targets.sql +5 -0
  45. package/lib/shred/scripts/partners.sql +2 -0
  46. package/lib/shred/scripts/passwords.sql +8 -0
  47. package/lib/shred/scripts/personal-numbers.sql +177 -0
  48. package/lib/shred/scripts/phone-numbers.sql +22 -0
  49. package/lib/shred/scripts/salary-spec-reports.sql +5 -0
  50. package/lib/shred/scripts/service-activity-tracker-data.sql +4 -0
  51. package/lib/shred/scripts/service-core-objects.sql +19 -0
  52. package/lib/shred/scripts/service-event-stream.sql +2 -0
  53. package/lib/shred/scripts/service-integrations.sql +4 -0
  54. package/lib/shred/scripts/template.sql +4 -0
  55. package/lib/shred/scripts/x-service-billing.sql +34 -0
  56. package/lib/shred/scripts/xxx-history-tables.sql +25 -0
  57. package/lib/stub.js +8 -0
  58. package/local-postgres/Dockerfile +11 -0
  59. package/package.json +46 -0
  60. 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 }
@@ -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
+ }