Skip to content

Commit 991d081

Browse files
committed
fix(sim-setup): persist a shell POSTGRES_PASSWORD and rotate the effective role
A password exported only in the shell is now written to .env, so a later run without the export does not fall back to the legacy value. Rotation steps use the install's POSTGRES_USER and ALTER ROLE CURRENT_USER.
1 parent 15ca249 commit 991d081

5 files changed

Lines changed: 120 additions & 77 deletions

File tree

‎apps/docs/content/docs/platform/self-hosting/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ The Compose files do not publish the `db` service to the host: `simstudio`, `rea
279279
Installs created from an earlier Compose file published the database on every interface of the host (`5432:5432`), and a `POSTGRES_PASSWORD` left unset fell back to `postgres`. A Docker `ports:` mapping writes its own iptables rules, so a host firewall that looks like it blocks 5432 usually does not. Update the Compose file, then check what your database was created with:
280280

281281
- **You set `POSTGRES_PASSWORD` before the first start.** Nothing else to do — updating the file closes the port.
282-
- **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD '<new password>'"`, set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out.
282+
- **You never set it.** Postgres applies `POSTGRES_PASSWORD` only when it creates the data volume, so the database still uses `postgres`. Set `POSTGRES_PASSWORD=postgres` in `.env` so the stack starts, then rotate it: run `docker compose -f docker-compose.prod.yml exec db psql -U postgres -c "ALTER ROLE CURRENT_USER PASSWORD '<new password>'"` (with your `POSTGRES_USER` in place of `postgres` if you set one), set `POSTGRES_PASSWORD` to the same value, and run `docker compose -f docker-compose.prod.yml up -d`. Setting a new value in `.env` alone does not change the database's password and locks the app out.
283283

284284
`npx sim-setup start` and `npx sim-setup update` write the right value for you and print the rotation steps.
285285
</Callout>

‎packages/sim-setup/src/compose-database.test.ts‎

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
55
import {
66
choosePostgresPassword,
77
composeFileRequiresPostgresPassword,
8-
configuredPostgresPassword,
98
LEGACY_POSTGRES_PASSWORD,
9+
postgresUser,
1010
} from './compose-database'
1111

1212
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
@@ -34,37 +34,57 @@ afterEach(() => {
3434
})
3535

3636
describe('choosePostgresPassword', () => {
37-
it('leaves a configured password alone without looking for a volume', () => {
38-
const lookup = vi.fn(() => true)
39-
expect(choosePostgresPassword('already-set', 'sim-abc', lookup)).toBeNull()
40-
expect(lookup).not.toHaveBeenCalled()
37+
it('leaves a password already in .env alone without looking for a volume', () => {
38+
const hasDatabaseVolume = vi.fn(() => true)
39+
expect(
40+
choosePostgresPassword('in-env-file', 'sim-abc', {
41+
shellValue: 'in-shell',
42+
hasDatabaseVolume,
43+
})
44+
).toBeNull()
45+
expect(hasDatabaseVolume).not.toHaveBeenCalled()
46+
})
47+
48+
it('persists a shell-exported password, which is what Compose is using', () => {
49+
const hasDatabaseVolume = vi.fn(() => true)
50+
expect(
51+
choosePostgresPassword(undefined, 'sim-abc', { shellValue: 'in-shell', hasDatabaseVolume })
52+
).toEqual({ value: 'in-shell', source: 'environment' })
53+
expect(hasDatabaseVolume).not.toHaveBeenCalled()
4154
})
4255

4356
it('generates a password for a project with no database volume yet', () => {
44-
const lookup = vi.fn(() => false)
45-
const choice = choosePostgresPassword(undefined, 'sim-abc', lookup)
46-
expect(lookup).toHaveBeenCalledWith('sim-abc')
47-
expect(choice?.legacy).toBe(false)
57+
const hasDatabaseVolume = vi.fn(() => false)
58+
const choice = choosePostgresPassword('', 'sim-abc', { shellValue: '', hasDatabaseVolume })
59+
expect(hasDatabaseVolume).toHaveBeenCalledWith('sim-abc')
60+
expect(choice?.source).toBe('generated')
4861
expect(choice?.value).toMatch(/^[0-9a-f]{64}$/)
4962
})
5063

5164
it('keeps the legacy password for a volume created before it was required', () => {
52-
const choice = choosePostgresPassword(undefined, 'sim-abc', () => true)
53-
expect(choice).toEqual({ value: LEGACY_POSTGRES_PASSWORD, legacy: true })
65+
expect(
66+
choosePostgresPassword(undefined, 'sim-abc', {
67+
shellValue: '',
68+
hasDatabaseVolume: () => true,
69+
})
70+
).toEqual({ value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' })
5471
})
55-
})
5672

57-
describe('configuredPostgresPassword', () => {
58-
it('prefers the shell environment, which Compose interpolates over .env', () => {
59-
vi.stubEnv('POSTGRES_PASSWORD', 'from-shell')
60-
expect(configuredPostgresPassword('from-env-file')).toBe('from-shell')
73+
it('reads the shell environment by default', () => {
74+
vi.stubEnv('POSTGRES_PASSWORD', 'from-process')
75+
expect(choosePostgresPassword(undefined, 'sim-abc', { hasDatabaseVolume: () => true })).toEqual(
76+
{ value: 'from-process', source: 'environment' }
77+
)
6178
})
79+
})
6280

63-
it('falls back to .env and treats empty values as unset', () => {
64-
vi.stubEnv('POSTGRES_PASSWORD', '')
65-
expect(configuredPostgresPassword('from-env-file')).toBe('from-env-file')
66-
expect(configuredPostgresPassword('')).toBeUndefined()
67-
expect(configuredPostgresPassword(undefined)).toBeUndefined()
81+
describe('postgresUser', () => {
82+
it('prefers the shell, then .env, then the image default', () => {
83+
vi.stubEnv('POSTGRES_USER', 'from-shell')
84+
expect(postgresUser('from-env-file')).toBe('from-shell')
85+
vi.stubEnv('POSTGRES_USER', '')
86+
expect(postgresUser('from-env-file')).toBe('from-env-file')
87+
expect(postgresUser(undefined)).toBe('postgres')
6888
})
6989
})
7090

‎packages/sim-setup/src/compose-database.ts‎

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'
22
import { readFileSync } from 'node:fs'
33
import { generateSecret } from './env-files'
44
import { SetupError } from './errors'
5+
import * as p from './prompter'
56

67
/**
78
* The password Postgres was initialized with on a Compose install created while
@@ -23,37 +24,45 @@ export function composeFileRequiresPostgresPassword(composeFile: string): boolea
2324
return readFileSync(composeFile, 'utf8').includes('${POSTGRES_PASSWORD:?')
2425
}
2526

27+
/** Where a chosen `POSTGRES_PASSWORD` came from. */
28+
export type PostgresPasswordSource = 'environment' | 'generated' | 'legacy'
29+
2630
export interface PostgresPasswordChoice {
2731
value: string
28-
/** True when the value is the legacy password an existing volume was created with. */
29-
legacy: boolean
32+
source: PostgresPasswordSource
33+
}
34+
35+
interface ChooseOptions {
36+
/** `POSTGRES_PASSWORD` from the shell, which Compose interpolates over `.env`. */
37+
shellValue?: string
38+
hasDatabaseVolume?: (project: string) => boolean
3039
}
3140

3241
/**
33-
* Picks the `POSTGRES_PASSWORD` a Compose install's `.env` needs, or null when it
34-
* already has one. The production Compose file requires the variable, so an
35-
* install that never set it must gain one before Compose will start — and the
36-
* value must match what the data volume was created with: a fresh password for
37-
* a new volume, the legacy one for a volume that already exists. Guessing wrong
38-
* in the other direction would lock the app out of its own database.
42+
* Picks the `POSTGRES_PASSWORD` to write to a Compose install's `.env`, or null
43+
* when `.env` already has one. The production Compose file requires the
44+
* variable, and the value must match what the data volume was created with —
45+
* Postgres ignores `POSTGRES_PASSWORD` on an existing data directory, so a
46+
* wrong value locks the app out of its own database:
47+
*
48+
* - A value exported in the shell is what Compose is using, so it is persisted;
49+
* otherwise a later run without the export would fall back to a guess.
50+
* - With no value anywhere, an existing volume was created with the legacy
51+
* password, and a project with no volume yet gets a generated one.
3952
*/
4053
export function choosePostgresPassword(
41-
configured: string | undefined,
54+
envFileValue: string | undefined,
4255
project: string,
43-
hasDatabaseVolume: (project: string) => boolean = composeDatabaseVolumeExists
56+
{
57+
shellValue = process.env.POSTGRES_PASSWORD,
58+
hasDatabaseVolume = composeDatabaseVolumeExists,
59+
}: ChooseOptions = {}
4460
): PostgresPasswordChoice | null {
45-
if (configured) return null
61+
if (envFileValue) return null
62+
if (shellValue) return { value: shellValue, source: 'environment' }
4663
return hasDatabaseVolume(project)
47-
? { value: LEGACY_POSTGRES_PASSWORD, legacy: true }
48-
: { value: generateSecret(), legacy: false }
49-
}
50-
51-
/**
52-
* The value Compose will interpolate for `POSTGRES_PASSWORD`: the shell
53-
* environment wins over `.env`, so a value exported there is already in effect.
54-
*/
55-
export function configuredPostgresPassword(envFileValue: string | undefined): string | undefined {
56-
return process.env.POSTGRES_PASSWORD || envFileValue || undefined
64+
? { value: LEGACY_POSTGRES_PASSWORD, source: 'legacy' }
65+
: { value: generateSecret(), source: 'generated' }
5766
}
5867

5968
/** Whether a Compose project already has a Postgres data volume, found by Compose's own labels. */
@@ -107,15 +116,35 @@ function parseProjectName(stdout: string): string | null {
107116
}
108117

109118
/**
110-
* Explains why the legacy password was kept and how to rotate it. `compose` is
111-
* the pinned `docker compose -p … -f …` prefix for this install.
119+
* Reports what was written. `compose` is the pinned `docker compose -p … -f …`
120+
* prefix for the install, and `user` the effective `POSTGRES_USER`, both used
121+
* in the legacy rotation steps.
112122
*/
113-
export function legacyPostgresPasswordNote(compose: string): string {
114-
return [
115-
'This database was created with the password "postgres", so .env now sets',
116-
'POSTGRES_PASSWORD=postgres to keep it working. The database is not published',
117-
'to the host, so only the containers in this stack can reach it. To rotate it:',
118-
` ${compose} exec db psql -U postgres -c "ALTER ROLE postgres PASSWORD '<new password>'"`,
119-
' then set POSTGRES_PASSWORD=<new password> in .env and run: npx sim-setup start',
120-
].join('\n')
123+
export function reportPostgresPasswordChoice(
124+
choice: PostgresPasswordChoice,
125+
{ compose, user, envPath }: { compose: string; user: string; envPath: string }
126+
): void {
127+
if (choice.source === 'environment') {
128+
p.log.step(`Saved POSTGRES_PASSWORD from the shell environment to ${envPath}`)
129+
return
130+
}
131+
if (choice.source === 'generated') {
132+
p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`)
133+
return
134+
}
135+
p.note(
136+
[
137+
`This database was created with the password "${LEGACY_POSTGRES_PASSWORD}", so ${envPath}`,
138+
`now sets POSTGRES_PASSWORD=${LEGACY_POSTGRES_PASSWORD} to keep it working. The database is not`,
139+
'published to the host, so only the containers in this stack can reach it. To rotate it:',
140+
` ${compose} exec db psql -U ${user} -c "ALTER ROLE CURRENT_USER PASSWORD '<new password>'"`,
141+
' then set POSTGRES_PASSWORD=<new password> in .env and run: npx sim-setup start',
142+
].join('\n'),
143+
'Database password'
144+
)
145+
}
146+
147+
/** The Postgres role Compose initializes, which the shell overrides over `.env` like any variable. */
148+
export function postgresUser(envFileValue: string | undefined): string {
149+
return process.env.POSTGRES_USER || envFileValue || 'postgres'
121150
}

‎packages/sim-setup/src/lifecycle.ts‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { ensureProductionComposeFile } from './compose-asset'
55
import {
66
choosePostgresPassword,
77
composeFileRequiresPostgresPassword,
8-
configuredPostgresPassword,
9-
legacyPostgresPasswordNote,
8+
postgresUser,
9+
reportPostgresPasswordChoice,
1010
} from './compose-database'
1111
import { legacyComposeProjectName } from './compose-project'
1212
import { directoryOverride, resolveSetupContextAtRoot, SETUP_CONTEXT } from './context'
@@ -210,20 +210,15 @@ function ensureComposePostgresPassword(install: ComposeInstall): void {
210210
if (!composeFileRequiresPostgresPassword(install.file)) return
211211
const envPath = path.join(install.dir, '.env')
212212
const content = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
213-
const choice = choosePostgresPassword(
214-
configuredPostgresPassword(parseEnv(content).get('POSTGRES_PASSWORD')),
215-
install.project
216-
)
213+
const vars = parseEnv(content)
214+
const choice = choosePostgresPassword(vars.get('POSTGRES_PASSWORD'), install.project)
217215
if (!choice) return
218216
writeEnvFile(envPath, upsertEnv(content, 'POSTGRES_PASSWORD', choice.value))
219-
if (choice.legacy) {
220-
p.note(
221-
legacyPostgresPasswordNote(`docker compose -p ${install.project} -f ${install.file}`),
222-
'Database password'
223-
)
224-
} else {
225-
p.log.step(`Generated POSTGRES_PASSWORD in ${envPath}`)
226-
}
217+
reportPostgresPasswordChoice(choice, {
218+
compose: `docker compose -p ${install.project} -f ${install.file}`,
219+
user: postgresUser(vars.get('POSTGRES_USER')),
220+
envPath,
221+
})
227222
}
228223

229224
/** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */

‎packages/sim-setup/src/modes/compose.ts‎

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
choosePostgresPassword,
88
composeFileRequiresPostgresPassword,
99
composeProjectName,
10-
configuredPostgresPassword,
11-
legacyPostgresPasswordNote,
10+
postgresUser,
11+
reportPostgresPasswordChoice,
1212
} from '../compose-database'
1313
import { legacyComposeProjectName, standaloneComposeProjectName } from '../compose-project'
1414
import { SETUP_CONTEXT } from '../context'
@@ -188,7 +188,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
188188
}
189189
const postgresPassword = composeFileRequiresPostgresPassword(composeFile)
190190
? choosePostgresPassword(
191-
configuredPostgresPassword(root.vars.get('POSTGRES_PASSWORD')),
191+
root.vars.get('POSTGRES_PASSWORD'),
192192
composeProject ?? composeProjectName(composeFile, ROOT)
193193
)
194194
: null
@@ -232,13 +232,12 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
232232
for (const key of Object.keys(values)) remove.delete(key)
233233
reconcileEnvValues('root', [...remove], values)
234234
p.log.step('Wrote .env (compose reads it for variable substitution)')
235-
if (postgresPassword?.legacy) {
236-
p.note(
237-
legacyPostgresPasswordNote(composeCommand(composeFile, composeProject)),
238-
'Database password'
239-
)
240-
} else if (postgresPassword) {
241-
p.log.step('Generated POSTGRES_PASSWORD')
235+
if (postgresPassword) {
236+
reportPostgresPasswordChoice(postgresPassword, {
237+
compose: composeCommand(composeFile, composeProject),
238+
user: postgresUser(root.vars.get('POSTGRES_USER')),
239+
envPath: root.path,
240+
})
242241
}
243242

244243
const validation = spawnSync('docker', composeArgs(composeFile, composeProject, 'config'), {

0 commit comments

Comments
 (0)