Give every project its own credentials on the shared services

Every project used the Postgres superuser, the RabbitMQ administrator, an
admin S3 identity and the Zitadel instance-admin PAT, so any one of them
could read or change the others' data.

A new one-shot, `provision` (runs on every `up`, idempotent), gives each
project in x-projects its own:

- Postgres role, owner of its database. No database is open to PUBLIC
  anymore, so a role can only connect to its own. An existing database
  and everything in it is handed over to the project's role.
- RabbitMQ user, with permissions on its own vhost only.
- Zitadel service account, PROJECT_OWNER of its own project only; its
  PAT is written to zitadel/.output/<project>/ for that project's
  zitadel-init to mount instead of the admin PAT.

SeaweedFS renders one S3 identity per project, limited to its bucket, and
refuses to start with a clear message on a malformed or repeated key.

The admin accounts stay with this stack. postgres-init is gone: provision
creates the databases. *.sh files are checked out with LF so they run in
the Linux containers from a Windows checkout.
This commit is contained in:
2026-09-24 17:05:12 +02:00
parent 668cdb17b0
commit 33bf75d245
8 changed files with 458 additions and 55 deletions
+51 -19
View File
@@ -8,8 +8,7 @@ provisioning, and keeping in sync) its own copy, they run once, here.
This repo doesn't assume anything about where the other projects are
checked out. Each one just needs a local path to this repo's checkout (see
"The Zitadel admin PAT" below), which you set once in that project's own
`.env`.
"The Zitadel PAT" below), which you set once in that project's own `.env`.
Each project keeps its own independent, fast `docker compose up` for the
things that actually are its own: API/worker/gateway containers, database
@@ -23,7 +22,7 @@ needs to know a consuming project exists.
| Here (shared) | Stays in each project |
|---|---|
| PostgreSQL (one instance, one database per project) | The project's own EF Core migrations, connection pooling config |
| PostgreSQL (one instance, one database and role per project) | The project's own EF Core migrations, connection pooling config |
| Zitadel (OIDC provider) | The project's own OIDC app registration (`zitadel-init`), JWT validation config |
| SeaweedFS (S3 storage) | The project's own bucket (`seaweedfs-init`), key layout |
| RabbitMQ (broker) | The project's own exchanges/queues/consumers |
@@ -83,7 +82,7 @@ docker exec infra-rabbitmq rabbitmqctl import_definitions /var/lib/rabbitmq/defs
| Service | Port | Notes |
|---|---|---|
| PostgreSQL | `5432` | one instance, one database per project |
| PostgreSQL | `5432` | one instance, one database and role per project |
| Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` |
| Zitadel login | `3900` | Login V2 UI at `/ui/v2/login` (under `8080`'s hostname in prod) |
| SeaweedFS | `8333` / `8888` / `9333` | S3 API / filer / master |
@@ -98,27 +97,60 @@ running (in Docker or from the IDE). No shared Docker network is needed in
development, which is what keeps every project's own `up` independent and
fast, with this stack as the only prerequisite.
### Shared secrets
### Per-project credentials
`POSTGRES_USER`/`POSTGRES_PASSWORD`, `S3_ACCESS_KEY`/`S3_SECRET_KEY`,
`RABBITMQ_DEFAULT_USER`/`RABBITMQ_DEFAULT_PASS` and `PUBLIC_AUTH_URL` (i.e.
`http://host.docker.internal:${ZITADEL_EXTERNAL_PORT}` in dev) are the values
every consuming project's own `.env.example` copies and documents as "must
match this repo's `.env`". This file is the source of truth for them.
No project gets an admin account. `provision` (a one-shot that runs on
every `up`, see `provision/`) gives each project listed in `compose.yml`'s
`x-projects` its own credentials, from the `<PROJECT>_*` settings in the env
file:
### The Zitadel admin PAT
| Service | What the project gets | Setting |
|---|---|---|
| PostgreSQL | role `<project>`, owner of database `<project>_db`; no other role can connect to it, and it can't connect to any other | `<PROJECT>_DB_PASSWORD` |
| RabbitMQ | user `<project>`, only on vhost `<project>` | `<PROJECT>_RABBITMQ_PASSWORD` |
| SeaweedFS | S3 identity limited to the project's bucket (it may create that bucket, nothing else) | `<PROJECT>_S3_ACCESS_KEY` / `_SECRET_KEY` |
| Zitadel | service account `<project>-provisioner`, owner of the project's own Zitadel project only; its PAT is in `zitadel/.output/<project>/zitadel.pat` | (the project name, in `compose.yml`) |
Zitadel itself writes a service-account personal access token to
`zitadel/.output/admin-sa.pat` when the instance is created. Every consuming project mounts that same folder read-only, via
its own `ZITADEL_ADMIN_PAT_DIR` setting, to call the Zitadel management API
and create its own project/apps.
An empty setting means that project doesn't use that service. Each
project's own `.env.example` copies its values from here and says so; this
repo's env file is the source of truth. Passwords are re-applied on every
`up`, so rotating one is: change it here and in the project, `up` both.
The admin accounts (`POSTGRES_*`, `RABBITMQ_DEFAULT_*`, `S3_ACCESS_KEY` /
`S3_SECRET_KEY`, `zitadel/.output/admin-sa.pat`) stay with this stack.
Two limits worth knowing: the Zitadel `PROJECT_OWNER` role can still read
the organization's users (every project's users are in the one
organization); and S3 keys must only use `A-Z a-z 0-9 _ + / = . -` (they go
into SeaweedFS's JSON config as they are).
**Adding a project**: add it to `x-projects`, add its `<PROJECT>_*` lines
to `provision`'s and `seaweedfs`'s environment in `compose.yml` (with its
bucket and Zitadel project name), put its values in `.env.example` /
`.env.prod.example`, and `up`.
**Moving an existing deployment over**: fill in the new `<PROJECT>_*`
values and `up`. `provision` hands over the existing databases (and every
table, sequence, type, view and function in them) to each project's role,
reuses the existing Zitadel projects and apps, and leaves the buckets as
they are. Then switch each project's env file to its own credentials. The
only thing that doesn't carry over is RabbitMQ: a project now uses its own
vhost, so drain its queues on `/` first; its consumers recreate their
exchanges and queues in the new vhost.
### The Zitadel PAT
Each project's `zitadel-init` mounts **its own** folder,
`zitadel/.output/<project>/`, read-only (via that project's
`ZITADEL_PAT_DIR` setting) and uses the PAT in it to create its OIDC apps in
its own Zitadel project. It can't see or change any other project's.
That setting has no universally correct value. It's a path (relative or
absolute) to wherever *you* cloned this repo on your machine, from the
consuming project's `deploy/` folder. Each project's `.env.example` ships
with an example value that assumes these repos happen to sit as sibling
folders. If yours don't, change it. Not versioned, and regenerated whenever
the Zitadel volume is reset.
folders. If yours don't, change it. Not versioned; `provision` issues a new
PAT whenever the one there stops working (e.g. after a Zitadel volume reset).
### The Zitadel login (Login V2)
@@ -208,6 +240,6 @@ Infrastructure/
├─ edge/ # shared public nginx + certbot (prod only): compose.yml, conf.d/, snippets/
├─ compose.dev.yml # dev overlay: publishes ports, adds pgAdmin
├─ .env.example / .env.prod.example
├─ postgres-init/ # creates the per-project application databases
└─ zitadel/.output/ # PAT written by Zitadel on init (not versioned)
├─ provision/ # per-project credentials: Postgres, RabbitMQ, Zitadel, S3 identities
└─ zitadel/.output/ # admin PAT + one folder per project with its own PAT (not versioned)
```