Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,27 @@
# NGINX_PORT=8080
# NGINX_EXPOSE_PORT=8080
# NGINX_CLIENT_MAX_BODY_SIZE=50M

# The database and the role Zammad connects with. The role is not a superuser and
# owns nothing but its own database.
# POSTGRES_DB=zammad_production
# POSTGRES_PASS=zammad
# POSTGRES_USER=zammad
# POSTGRES_HOST=zammad-postgresql
# POSTGRES_PORT=5432
# POSTGRESQL_OPTIONS=?pool=50

# The administrative superuser of the bundled PostgreSQL server. Zammad never uses it,
# it only exists for maintenance tasks such as creating extensions or dumping globals.
# Applied when the database volume is created, changing it later has no effect.
# POSTGRES_SUPERUSER=postgres
# POSTGRES_SUPERUSER_PASS=zammad

# Let Zammad create its own database on startup. This is not needed for the bundled
# PostgreSQL server, which creates the database up front. Enable it only for an
# external server whose role has the CREATEDB attribute.
# POSTGRESQL_DB_CREATE=false

# ELASTICSEARCH_SCHEMA=http
# ELASTICSEARCH_HOST=zammad-elasticsearch
# ELASTICSEARCH_PORT=9200
Expand Down
5 changes: 4 additions & 1 deletion .github/tests/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ check_stack_start

print_heading "Check that restore folder was renamed after successful restore..."
docker compose exec zammad-backup sh -c "[ ! -d /var/tmp/zammad/restore ]"
print_heading "Restore folder was renamed after successful restore..."
print_heading "Restore folder was renamed after successful restore..."

# Backup and restore must work without any superuser privilege.
check_database_role_is_unprivileged
2 changes: 2 additions & 0 deletions .github/tests/default.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

check_stack_start

check_database_role_is_unprivileged

# Checking for external connectivity may not always be possible, e.g. in GitLab CI.
if [ -z "$DISABLE_EXTERNAL_TESTS" ]
then
Expand Down
27 changes: 27 additions & 0 deletions .github/tests/include/functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ railsserver_run_command() {
docker compose exec --env=AUTOWIZARD_RELATIVE_PATH=tmp/auto_wizard.json --env=DATABASE_URL=postgres://zammad:zammad@zammad-postgresql:5432/zammad_production zammad-railsserver "$@"
}

# Run a query as the administrative superuser of the database container. The query is
# piped in, so that its quoting survives, and the name of the role that Zammad uses
# is available to it as the psql variable ':role'.
postgresql_superuser_query() {
echo "$1" | docker compose exec --no-TTY zammad-postgresql \
sh -c 'psql --variable ON_ERROR_STOP=1 --tuples-only --no-align \
--username "$POSTGRES_USER" --variable role="$ZAMMAD_DB_USER"'
}

# Verify that the role Zammad connects with holds none of the privileges that would
# allow it to read or write files on the database server, or to escalate itself.
check_database_role_is_unprivileged() {
print_heading "Check that the Zammad database role is unprivileged…"

postgresql_superuser_query "SELECT rolsuper OR rolcreatedb OR rolcreaterole
OR rolreplication OR rolbypassrls
FROM pg_roles WHERE rolname = :'role'" | grep -x f

postgresql_superuser_query "SELECT bool_or(pg_has_role(:'role', oid, 'USAGE'))
FROM pg_roles
WHERE rolname IN ('pg_read_server_files',
'pg_write_server_files',
'pg_execute_server_program')" | grep -x f

print_heading "The Zammad database role is unprivileged :)"
}

check_stack_start() {
start_stack_logs_capture
print_heading "wait for zammad to be ready…"
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,32 @@ has a bright and sustainable future, consider becoming a Zammad customer!

For upgrading instructions, see our [Releases](https://github.com/zammad/zammad-docker-compose/releases).

## PostgreSQL privileges

Zammad connects to PostgreSQL with the `zammad` role, which is a plain login role: it owns the `zammad_production` database and nothing else, and holds none of `SUPERUSER`, `CREATEDB`, `CREATEROLE`, `REPLICATION` or `BYPASSRLS`. This matches the role that the packaged Linux installation creates. Administrative access to the server is available through the separate `postgres` superuser, configurable via `POSTGRES_SUPERUSER` and `POSTGRES_SUPERUSER_PASS`.

The role is created while the `postgresql-data` volume is initialised, so this applies to new installations only. Installations created before this change connect with the bootstrap role of the `postgres` image, which is a superuser. That is not a vulnerability - reaching those privileges requires valid database credentials and network access to the database in the first place - so migrating is optional. PostgreSQL does not allow the bootstrap role to be demoted, so the migration goes through a backup and restore into a fresh volume:

```sh
# 1. Create a backup of the running installation.
docker compose run --rm --env BACKUP_ONCE=true zammad-backup

# 2. Stop the stack. Do not pass --volumes here, it would delete the backup as well.
docker compose down

# 3. Stage that backup for the restore.
docker compose run --rm --no-deps zammad-backup sh -c "mkdir /var/tmp/zammad/restore && cp /var/tmp/zammad/*gz /var/tmp/zammad/restore/"

# 4. Discard the database volume, so that it gets initialised with the new role layout.
# It is named after your compose project, by default the name of this directory.
docker volume ls --filter name=postgresql-data
docker volume rm <volume from the list above>

# 5. Start the stack again. The staged backup is restored into the new database
# before Zammad starts up.
docker compose up --detach
```

## Running without Elasticsearch

Elasticsearch is an optional, but strongly recommended dependency for Zammad. More details can be found in the [documentation](https://docs.zammad.org/en/latest/prerequisites/software.html#elasticsearch-optional). There are however certain scenarios when running without Elasticsearch may be desired, e.g. for very small teams, for teams with limited budget or as a temporary solution for an unplanned Elasticsearch downtime or planned cluster upgrade.
Expand Down
89 changes: 84 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ x-shared:
POSTGRESQL_PASS: ${POSTGRES_PASS:-zammad}
POSTGRESQL_PORT: ${POSTGRES_PORT:-5432}
POSTGRESQL_OPTIONS: ${POSTGRESQL_OPTIONS:-?pool=50}
POSTGRESQL_DB_CREATE:
# The bundled zammad-postgresql service creates the database up front, and its
# application role is deliberately not allowed to create databases. Set this to
# 'true' if you point Zammad at an external server and want it to create the
# database itself - the configured role then needs the CREATEDB attribute.
POSTGRESQL_DB_CREATE: ${POSTGRESQL_DB_CREATE:-false}

# Redis standalone
REDIS_URL: ${REDIS_URL:-redis://zammad-redis:6379}
Expand Down Expand Up @@ -164,15 +168,43 @@ services:

zammad-postgresql:
environment:
POSTGRES_DB: ${POSTGRES_DB:-zammad_production}
POSTGRES_USER: ${POSTGRES_USER:-zammad}
POSTGRES_PASSWORD: ${POSTGRES_PASS:-zammad}
# The bootstrap role of the postgres image is always a superuser, so it is kept
# separate from the role Zammad connects with and only used for administration.
POSTGRES_USER: ${POSTGRES_SUPERUSER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASS:-zammad}
# Consumed by the initdb hook below, to provision Zammad's unprivileged role
# and the database it owns.
ZAMMAD_DB: ${POSTGRES_DB:-zammad_production}
ZAMMAD_DB_USER: ${POSTGRES_USER:-zammad}
ZAMMAD_DB_PASS: ${POSTGRES_PASS:-zammad}
image: postgres:${POSTGRES_VERSION:-17.11-alpine}
restart: ${RESTART:-always}
volumes:
- postgresql-data:/var/lib/postgresql/data
configs:
- source: postgresql-initdb
target: /docker-entrypoint-initdb.d/10-create-zammad-role.sh

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not work with portainer scenarios.
I'm not sure if complicating the docker stack like this is worth it in the end.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, you are right and I fixed it in b8561e4 — the external file is gone and the hook is now inlined as configs.content, so docker-compose.yml is self-contained again and can be deployed as-is.

I verified this by copying only docker-compose.yml into an empty directory and starting it there: Compose materialises the script in the container, the $$ escapes are resolved to the container's own variables, and the role and database get created. That covers the Portainer web editor and anyone who just copies the file, not only the repository build method.

This also removes the upgrade burden I had originally written into the release note draft — there is no extra file for existing deployments to pick up anymore.

On "is it worth it in the end": your call, and I am happy to drop it. My case for keeping it, briefly:

  • The net cost is now ~25 lines inside docker-compose.yml, no new file, no new service, no new container.
  • It removes the last place where our Docker deployment is less hardened than the packaged Linux install, which already creates a plain login role.
  • Nothing changes for existing installations - they keep the role layout their volume was created with, and migrating is optional.

The real cost I would like to name honestly: the hook is no longer covered by shellcheck now that it lives in YAML, and the POSTGRESQL_DB_CREATE default change affects people running against an external database server. If either of those tips the balance for you, say so and I will close this in favour of leaving the stack as it is.

mode: 0555
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
# Refuse to become healthy if Zammad's role is the bootstrap role, which is always
# a superuser. The initdb hook cannot enforce this on its own, because the
# restart policy would bring the container back up with the role left uncreated.
#
# Connect over TCP as Zammad's own role rather than using pg_isready, which
# reports success for an unknown role or database, and would also accept the
# socket-only server that runs while the data directory is still initialising.
# Dependent services wait for this healthcheck, so it has to prove that the
# role and the database the initdb hook provisions are actually usable.
test:
- CMD-SHELL
- |-
if [ "$${ZAMMAD_DB_USER}" = "$${POSTGRES_USER}" ]; then
echo "POSTGRES_USER and POSTGRES_SUPERUSER must differ, the latter is a superuser."
exit 1
fi
psql --no-password --quiet --output /dev/null --variable ON_ERROR_STOP=1 \
--host 127.0.0.1 --username "$${ZAMMAD_DB_USER}" \
--dbname "$${ZAMMAD_DB}" --command "SELECT 1"
interval: 10s
timeout: 5s
start_period: 60s
Expand Down Expand Up @@ -208,6 +240,53 @@ services:
<<: *zammad-service
command: ["zammad-websocket"]

configs:
postgresql-initdb:
# Provisions the database that Zammad uses, owned by a dedicated login role.
#
# The postgres image unconditionally makes its bootstrap role a superuser. Zammad
# does not need any superuser capability - it only owns its own database and
# relies on the built-in plpgsql extension - so the application role is created
# here as a plain login role, rather than reusing the bootstrap role.
#
# This runs only while an empty data directory is initialised. Installations whose
# volume already exists keep the role layout they were created with, see the
# README for how to migrate those.
#
# The content is inlined so that this file stays self-contained and can be
# deployed as-is, for example by pasting it into Portainer. '$$' escapes Compose
# interpolation, so those variables are expanded inside the container instead.
content: |
#!/bin/bash

set -o errexit
set -o pipefail

# The healthcheck rejects this too, which is what actually keeps the stack down.
if [ "$${ZAMMAD_DB_USER}" = "$${POSTGRES_USER}" ]; then
echo "POSTGRES_USER and POSTGRES_SUPERUSER must differ, the latter is a superuser." >&2
exit 1
fi

echo "Creating the '$${ZAMMAD_DB_USER}' role and the '$${ZAMMAD_DB}' database…"

# Identifiers and the password are quoted by format() with %I / %L, so that no
# shell quoting can leak into the generated statements.
psql --variable ON_ERROR_STOP=1 \
--username "$${POSTGRES_USER}" \
--dbname "$${POSTGRES_DB}" \
--variable role="$${ZAMMAD_DB_USER}" \
--variable pass="$${ZAMMAD_DB_PASS}" \
--variable db="$${ZAMMAD_DB}" <<'EOSQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'pass')
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role')\gexec

SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db')\gexec

GRANT ALL PRIVILEGES ON DATABASE :"db" TO :"role";
EOSQL

volumes:
elasticsearch-data:
driver: local
Expand Down