Skip to content

security: Bootstrap the admin account without a config-supplied password, and un-flatten .env #107

Description

@ebouchut

Why

The /admin/** area needs an ADMIN account to be usable, but there is no bootstrap path: self-registration only ever grants the STUDENT role, and the only seeded data is the roles themselves (V20260623105538-seed-roles.sql). Today the only way to get an admin in the dev environment is to hand-insert a row into user_roles with psql.

The obvious fix — put LEARNDEV_ADMIN_USERNAME / _EMAIL / _PASSWORD in .env and seed from it — turns out to be the wrong default, and investigating it surfaced a structural weakness in how secrets are shared. This issue records both.

What is already sound (please don't "fix" these)

  • .env is a gitignored FIFO (named pipe) filled by an external secrets manager, not a plaintext file. No secret is at rest on disk, and it does not survive a reboot. Multiple readers (Docker Compose, the JVM, make mpd) are fine: the pipe stays open for a window after it is first opened.
  • Actuator exposes only health, so there is no /env or /configprops endpoint leaking property values.
  • The application already uses a least-privilege database user (LEARNDEV_DB_USER) distinct from the Postgres superuser.

Security issues found

  1. A single flat .env is shared by every consumer, so every consumer sees every secret. This is the main finding.

    • docker-compose.yaml gives the postgres service env_file: .env, so the database container's environment receives SMTP_PASSWORD — and would receive an admin password too.
    • spring-dotenv feeds the JVM the whole file, so the application process holds POSTGRES_PASSWORD (the superuser) and MONGO_ROOT_PASSWORD, neither of which it has any business being able to read.

    Least privilege is defeated in both directions. Adding one more variable makes it marginally worse; the flatness is the actual defect.

  2. Environment variables leak further than files. They are inherited by every child process, appear in heap and crash dumps, and are visible via ps eww for one's own processes.

  3. A shared team admin credential has no attribution and no per-person revocation. Rotating it means telling everyone.

  4. Highest risk: a config-driven admin bootstrap is one profile-guard bug away from a known-credential admin in production. This is the classic admin/admin catastrophe, and it dwarfs every other item on this list.

  5. MONGO_ROOT_PASSWORD guards a service nothing uses. There is no MongoDB dependency in pom.xml, no @Document, and no repository — only a Compose service and a config placeholder. A live credential for an unused service is pure liability with zero upside.

Why not a Liquibase migration (this was the original question)

It is technically possible and still the wrong tool:

  • .env values can reach Liquibase — spring-dotenv exposes them as Spring properties, which can be forwarded as changelog parameters via spring.liquibase.parameters.* and used as ${param} inside formatted SQL. Keeping it out of production is also solved: the project already runs contexts: dev / contexts: prod, so a context:dev changeset would never execute in prod.
  • But users.password stores a BCrypt hash, which SQL cannot produce without enabling the pgcrypto extension — and that would push the plaintext password through SQL statements (and therefore the database's statement logs).
  • And Liquibase computes a changeset's checksum after parameter substitution. Changing the password in .env changes the checksum, so the next startup fails validation. Surviving that requires runOnChange:true plus an idempotent upsert — fragile, for no benefit.

Seeding in Java reuses the existing BCrypt PasswordEncoder bean (identical hashing to registration), needs no extension, puts no plaintext in SQL, and has no checksum semantics at all. Rejected: Liquibase. Chosen: a profile-guarded Java runner.

Proposed solutions

Production: never seed an admin from configuration

Document a runbook instead:

The operator registers through the normal signup form (so the password goes through the real BCrypt path and is known only to them), then a one-shot out-of-band command promotes that account to ADMIN.

Zero admin secrets in config, and no known-credential window. Precedents: Jenkins prints a one-time initialAdminPassword to the log; Grafana ships admin/admin but forces a change on first login; GitLab uses a one-time setup token.

Development: seed, but make the password optional

A dev-only seeder that:

  • is annotated @Profile("dev") and refuses to start if the active profile includes prod (belt and braces against issue 🔵 Frontend – Epic #4 above);
  • reads LEARNDEV_ADMIN_USERNAME and LEARNDEV_ADMIN_EMAIL from .env — these are not secrets;
  • uses LEARNDEV_ADMIN_PASSWORD if it is set, and otherwise generates a random password and logs it once at startup;
  • is idempotent: it does nothing when the account already exists, so a pinned password is not re-randomised on every boot.

Making the password optional is what makes this safe: a fresh clone with no secrets manager still boots with a working admin, nobody is forced to invent and store a credential, and there is no default password to forget about.

Un-flatten the secrets

  • Replace env_file: .env on the postgres service with an explicit environment: map listing only the variables that service needs, so database containers stop receiving SMTP and application secrets.
  • Stop feeding superuser and Mongo credentials to the JVM.

Remove MongoDB and its root password

Until a feature actually needs it. (Tracked separately if preferred.)

Production hardening (longer term)

  • Prefer mounted secret files (Docker/Kubernetes secrets, Vault, a cloud secrets manager) over environment variables; files can be permissioned and re-read, env vars leak downward into every child process.
  • Optionally split the database roles: a migration role with DDL rights used by Liquibase, and a DML-only runtime role used by the application, so an injection foothold in the app cannot alter the schema.

Trade-offs

  • Treating the dev admin credential as non-secret is defensible — the dev database is disposable, holds only fake data, and binds to 127.0.0.1 — but it does mean the credential must be structurally incapable of existing outside dev. Hence the profile guard and the explicit prod refusal.
  • A random password per boot is safer but less convenient (it changes on every restart). Mitigated by only generating one when the account does not yet exist; pinning LEARNDEV_ADMIN_PASSWORD remains available for stable logins, and in this project it is protected by the secrets-manager-backed FIFO anyway.
  • Splitting the Compose environment adds a little duplication between .env and docker-compose.yaml, in exchange for a real least-privilege win.
  • Splitting DDL/DML database roles is arguably overkill for a capstone, and it adds a second credential to manage; it is listed as optional for that reason.

Suggested order of work

  1. Un-flatten .env (explicit environment: map for the DB service; keep superuser credentials away from the JVM).
  2. Drop MongoDB and MONGO_ROOT_PASSWORD.
  3. Dev admin seeder with an optional, random-by-default password and a prod refusal guard.
  4. Document the production admin runbook (register, then promote).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions