Skip to main content

pgAdmin

A database browser at http://localhost:5050, run by docker compose up alongside the rest of the stack. Development only.

There is no login screen: it runs in desktop mode, because a login wall in front of a database browser you already had to be on this host to reach buys nothing.

The two pre-registered connections

infra/pgadmin/servers.json registers the same database twice, and the difference between them is the whole point.

ConnectionRoleWhat it sees
MotorPH Payroll (owner)motorphEverything. Owners bypass row-level security, so every query returns every tenant's rows.
MotorPH Payroll (app_runtime — RLS applies)app_runtimeNothing, at first. This is the role the application connects as. RLS applies, and a fresh session has no tenant selected.

Passwords are POSTGRES_PASSWORD and APP_DB_PASSWORD from your .env (defaults motorph and app_runtime). pgAdmin asks once and offers to save them.

Use the owner connection for ordinary work — inspecting data, checking a migration, fixing a row. Use app_runtime when you want to see what the application sees.

Seeing what a tenant sees

Connected as app_runtime, most tables read empty:

SELECT count(*) FROM employee; -- 0

That is not an empty database. It is row-level security doing its job: the policy compares tenant_id against an app.tenant_id session variable that the application sets on every connection checkout, and which a hand-opened session has never set.

Set it yourself, then query:

SELECT set_config('app.tenant_id', '1', false);
SELECT count(*) FROM employee; -- 101, for the default tenant

Switch tenants by re-running set_config with a different id (SELECT tenant_id, slug FROM tenant as the owner to list them). This is the fastest way to check tenant isolation by hand — if a query returns another tenant's rows here, isolation is broken.

Note the session variable resets when pgAdmin reconnects, so an unexpected zero usually just means the connection dropped.

Why the owner sees everything

RLS is only enforced for roles that do not bypass it. The application therefore connects as app_runtime (APP_DB_USER), and Flyway keeps the owning role because a migration has to be able to see every tenant's rows. RowLevelSecurityCheck logs at startup which side of that line the backend is on — if it warns, isolation is not being enforced and the analytics views will serve one tenant another's numbers.

Things that will trip you up

  • servers.json is imported on first start only, when the config database is created. Editing it later changes nothing. To re-import, drop pgAdmin's volume — and only pgAdmin's; docker compose down -v would take the database with it:

    docker compose rm -sf motorph_payroll_pgadmin
    docker volume rm "$(docker volume ls -q --filter name=motorph_payroll_pgadmin_data)"
    docker compose up -d motorph_payroll_pgadmin

    The volume's real name carries a project prefix derived from your checkout's directory name, which is why the command looks it up rather than spelling it out.

  • The host is motorph_payroll_db, not localhost. Inside the compose network a container's localhost is itself. (Connecting from a tool on your host instead, it is localhost:5434.)

  • PGADMIN_EMAIL cannot be any address. pgAdmin validates it for real-world deliverability and rejects reserved TLDs, which is a restart loop rather than an error you would notice. The compose file sets PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS: "['local']" so the stack's usual .local addresses work; a different fake TLD needs adding there too.

  • servers.json hardcodes the default database and usernames. If you have customised POSTGRES_DB or POSTGRES_USER, edit the connection in the UI — pgAdmin does no environment substitution in that file.

Not for production

This is a full admin console on the database. It is in docker-compose.yml (the development stack) and deliberately not in docker-compose.client.template.yml, where no host ports are published at all. Reach a production database through an SSH tunnel instead — see ../deployment/vps-guide.md.