fix(n8n): carry a rotated owner password into the account n8n already has

n8n accepts the owner password only at /rest/owner/setup, which answers
400 "Instance owner already setup" once an owner exists. After the CI
rotated owner_password between the passes, the probe got 401, setup got
400, the assert accepted "already setup", and the owner kept the old
password: the Playwright owner login failed in n8n#1 and the AI gateway
login in n8n#0 got 401 and 429 (run 34615516009, jobs 103327034581 and
103327034619).

Only in exactly that state, probe 401/403 plus "already setup", a node
script now runs inside the n8n container with n8n's own modules and
DB_POSTGRESDB_* settings: it compares the stored bcrypt hash with the
deployed password, writes a fresh bcryptjs hash when they differ, and
prints CHANGED or OK. A single confirming login then requires 200, so a
failed write stops the deploy with a clear status. Fresh hosts and deploys
whose password already matches never run the script. The pattern follows
web-app-flowise's identity_password.js.

The module path was measured in n8nio/n8n:1.100.1: bcryptjs and pg
resolve from /usr/local/lib/node_modules/n8n/node_modules, pg's
transitive modules load, and bcryptjs hashes as $2a$ like n8n.

no_log moves from the bootstrap include's apply to the four tasks that
carry the password, so the setup assert and the host and container
resolution stay readable when they fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-11 21:30:14 +02:00
1 parent a20883600b
commit 43e09ebf0d
4 files changed
+114 -1

No files matched your search

@@ -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))
-1
View File
@@ -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"
+51
View File
@@ -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 }}"
+1
View File
@@ -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 }}"