diff --git a/roles/web-app-n8n/files/javascript/reconcile/owner_password.js b/roles/web-app-n8n/files/javascript/reconcile/owner_password.js new file mode 100644 index 0000000000..c822860919 --- /dev/null +++ b/roles/web-app-n8n/files/javascript/reconcile/owner_password.js @@ -0,0 +1,62 @@ +/** + * Prints CHANGED when it rewrote the owner's hash, OK when it already matched or + * the owner does not exist yet. + * + * Environment: + * N8N_OWNER_EMAIL: the owner account's email. + * N8N_OWNER_PASSWORD: the password the deployment wants it to have. + * DB_TYPE, DB_POSTGRESDB_HOST, DB_POSTGRESDB_PORT, DB_POSTGRESDB_DATABASE, + * DB_POSTGRESDB_USER, DB_POSTGRESDB_PASSWORD: n8n's own connection settings. + */ + +// nocheck: mirrored-unit-test - resolves bcryptjs and pg from the n8n container's +// own node_modules and writes the hash straight into the live user row; outside that +// container the requires do not resolve + +const bcrypt = require('bcryptjs') +const { Client } = require('pg') + +const email = process.env.N8N_OWNER_EMAIL +const password = process.env.N8N_OWNER_PASSWORD + +function fail(message) { + process.stderr.write(`FAILED ${message}\n`) + process.exit(1) +} + +async function main() { + if (!email || !password) { + fail('N8N_OWNER_EMAIL and N8N_OWNER_PASSWORD are required') + } + if (process.env.DB_TYPE !== 'postgresdb') { + fail(`unsupported DB_TYPE ${process.env.DB_TYPE}`) + } + + const client = new Client({ + host: process.env.DB_POSTGRESDB_HOST, + port: parseInt(process.env.DB_POSTGRESDB_PORT || '5432', 10), + database: process.env.DB_POSTGRESDB_DATABASE, + user: process.env.DB_POSTGRESDB_USER, + password: process.env.DB_POSTGRESDB_PASSWORD + }) + await client.connect() + try { + const found = await client.query('SELECT password FROM "user" WHERE email = lower($1)', [email]) + if (found.rowCount === 0) { + console.log('OK') + return + } + const stored = found.rows[0].password + if (stored && bcrypt.compareSync(password, stored)) { + console.log('OK') + return + } + const hash = bcrypt.hashSync(password, 10) + await client.query('UPDATE "user" SET password = $1 WHERE email = lower($2)', [hash, email]) + console.log('CHANGED') + } finally { + await client.end() + } +} + +main().catch((error) => fail(error.message)) diff --git a/roles/web-app-n8n/tasks/00_core.yml b/roles/web-app-n8n/tasks/00_core.yml index e49dfbd6b8..e81fb776d4 100644 --- a/roles/web-app-n8n/tasks/00_core.yml +++ b/roles/web-app-n8n/tasks/00_core.yml @@ -43,7 +43,6 @@ include_tasks: file: 01_bootstrap.yml apply: - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" run_once: true - name: "🤖 Bind the native AI surface to the platform gateway" diff --git a/roles/web-app-n8n/tasks/01_bootstrap.yml b/roles/web-app-n8n/tasks/01_bootstrap.yml index 46e4527223..bd06b4a9da 100644 --- a/roles/web-app-n8n/tasks/01_bootstrap.yml +++ b/roles/web-app-n8n/tasks/01_bootstrap.yml @@ -29,6 +29,7 @@ until: "'json' in (n8n_owner_probe.content_type | default(''))" retries: 4 delay: 30 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - name: "🔧 Initialise n8n owner account (first-time only)" uri_retry: @@ -44,6 +45,7 @@ return_content: true register: n8n_owner_setup when: n8n_owner_probe.status != 200 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - name: "✅ Assert n8n owner setup succeeded or was already done" ansible.builtin.assert: @@ -53,3 +55,52 @@ or (n8n_owner_setup.status in [200, 201]) or ('already setup' in (n8n_owner_setup.content | default(''))) fail_msg: "n8n owner setup returned {{ n8n_owner_setup.status | default('n/a') }}: {{ n8n_owner_setup.content | default('') }}" + +- name: "🔐 Converge a rotated owner password n8n still rejects" + when: + - n8n_owner_probe.status in [401, 403] + - "'already setup' in (n8n_owner_setup.content | default(''))" + block: + - name: "🔍 Resolve {{ application_id }} hosting node + container id" + ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}" + vars: + resolve_service_key: "{{ entity_name }}" + resolve_output_prefix: N8N + resolve_compose_cid: "{{ N8N_CONTAINER }}" + + - name: "🔐 Write the deployed owner password into n8n's user row" + ansible.builtin.command: + argv: + - container + - exec + - -i + - -e + - "NODE_PATH={{ N8N_NODE_MODULES }}" + - -e + - "N8N_OWNER_EMAIL={{ N8N_OWNER_EMAIL }}" + - -e + - "N8N_OWNER_PASSWORD={{ N8N_OWNER_PASSWORD }}" + - "{{ N8N_LOCAL_CID }}" # nocheck: container-exec-resolver + - node + - "-" + stdin: "{{ lookup('file', 'javascript/reconcile/owner_password.js') }}" + register: n8n_owner_password + retries: 30 + delay: 10 + until: n8n_owner_password.rc == 0 + changed_when: n8n_owner_password.stdout.startswith('CHANGED') + delegate_to: "{{ N8N_HOST_NODE }}" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + + - name: "🔎 Confirm the owner signs in with the deployed password" + uri_retry: + url: "{{ N8N_INTERNAL_API_URL }}/rest/login" + method: POST + body_format: json + body: + emailOrLdapLoginId: "{{ lookup('users', 'administrator').email }}" + password: "{{ N8N_OWNER_PASSWORD }}" + status_code: [200] + return_content: false + retries: 0 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-n8n/vars/main.yml b/roles/web-app-n8n/vars/main.yml index fec5820819..d196121c95 100644 --- a/roles/web-app-n8n/vars/main.yml +++ b/roles/web-app-n8n/vars/main.yml @@ -10,6 +10,7 @@ N8N_HOSTNAME: "{{ lookup('domain', application_id) }}" N8N_WEB_PROTOCOL: "{{ lookup('tls', application_id, 'protocols.web') }}" N8N_ENCRYPTION_KEY: "{{ lookup('config', application_id, 'secrets.credentials.encryption_key') }}" N8N_OWNER_PASSWORD: "{{ lookup('config', application_id, 'secrets.credentials.owner_password') }}" +N8N_NODE_MODULES: /usr/local/lib/node_modules/n8n/node_modules N8N_INTERNAL_API_URL: "http://{{ DOCKER_REACH_HOST }}:{{ N8N_HTTP_PORT }}"