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:
@@ -0,0 +1,227 @@
|
||||
#!/bin/sh
|
||||
# Gives every consuming project its own credentials on the shared services,
|
||||
# so no project holds another's data or the admin accounts:
|
||||
#
|
||||
# Postgres role <project>, owner of database <project>_db, which no
|
||||
# other role can connect to
|
||||
# RabbitMQ user <project> with permissions only on vhost <project>
|
||||
# Zitadel service account <project>-provisioner, PROJECT_OWNER of the
|
||||
# project's own Zitadel project only; its PAT is written to
|
||||
# /output/<project>/zitadel.pat for that project's zitadel-init
|
||||
#
|
||||
# (S3 identities are rendered into SeaweedFS's own config when it starts, see
|
||||
# seaweedfs-s3-config.sh.)
|
||||
#
|
||||
# Idempotent: runs on every `up`. A service is only provisioned for a project
|
||||
# when its setting is non-empty (no MUSIFY_RABBITMQ_PASSWORD, no RabbitMQ
|
||||
# user for Musify). Passwords are re-applied on every run, so changing one
|
||||
# in the env file and running `up` again rotates it.
|
||||
set -eu -o pipefail
|
||||
|
||||
apk add --no-cache curl jq >/dev/null
|
||||
|
||||
# Value of the <PROJECT>_<suffix> setting, empty when unset.
|
||||
setting() { eval "printf '%s' \"\${$(echo "$1" | tr '[:lower:]' '[:upper:]')_$2:-}\""; }
|
||||
|
||||
# --- Postgres ----------------------------------------------------------------
|
||||
export PGHOST=postgres PGUSER="$POSTGRES_USER" PGPASSWORD="$POSTGRES_PASSWORD"
|
||||
|
||||
# By default every role may connect to every database (PUBLIC has CONNECT),
|
||||
# including `zitadel` and `postgres`. Only the owner, roles granted it
|
||||
# explicitly (Zitadel's own user on `zitadel`) and the superuser keep it.
|
||||
lock_down_postgres() {
|
||||
psql -q -v ON_ERROR_STOP=1 -d postgres <<'SQL'
|
||||
SELECT format('REVOKE CONNECT, TEMPORARY ON DATABASE %I FROM PUBLIC', datname)
|
||||
FROM pg_database WHERE NOT datistemplate \gexec
|
||||
SQL
|
||||
echo " postgres: no database open to PUBLIC"
|
||||
}
|
||||
|
||||
provision_postgres() {
|
||||
local project=$1 password=$2 db="$1_db"
|
||||
psql -q -v ON_ERROR_STOP=1 -d postgres -v role="$project" -v pw="$password" -v db="$db" <<'SQL'
|
||||
SELECT format('CREATE ROLE %I LOGIN', :'role')
|
||||
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'role') \gexec
|
||||
ALTER ROLE :"role" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD :'pw';
|
||||
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db') \gexec
|
||||
ALTER DATABASE :"db" OWNER TO :"role";
|
||||
REVOKE CONNECT, TEMPORARY ON DATABASE :"db" FROM PUBLIC;
|
||||
SQL
|
||||
|
||||
# A database from before this (created by the superuser, as were the
|
||||
# tables its migrations made) is handed over: the project's migrations
|
||||
# have to own what they alter.
|
||||
psql -q -v ON_ERROR_STOP=1 -d "$db" -v role="$project" <<'SQL' >/dev/null
|
||||
SELECT set_config('provision.role', :'role', false);
|
||||
DO $$
|
||||
DECLARE
|
||||
target text := current_setting('provision.role');
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT n.nspname FROM pg_namespace n
|
||||
WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'
|
||||
AND pg_get_userbyid(n.nspowner) NOT IN (target, 'pg_database_owner')
|
||||
AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_namespace'::regclass AND d.objid = n.oid AND d.deptype = 'e')
|
||||
LOOP
|
||||
EXECUTE format('ALTER SCHEMA %I OWNER TO %I', r.nspname, target);
|
||||
END LOOP;
|
||||
|
||||
-- Indexes and the sequences behind serial/identity columns follow their
|
||||
-- table; extension members stay with the extension.
|
||||
FOR r IN
|
||||
SELECT c.oid::regclass AS obj, c.relkind FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'
|
||||
AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
|
||||
AND pg_get_userbyid(c.relowner) <> target
|
||||
AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype IN ('a', 'i', 'e'))
|
||||
LOOP
|
||||
EXECUTE format('ALTER %s %s OWNER TO %I',
|
||||
CASE r.relkind WHEN 'v' THEN 'VIEW' WHEN 'm' THEN 'MATERIALIZED VIEW'
|
||||
WHEN 'S' THEN 'SEQUENCE' WHEN 'f' THEN 'FOREIGN TABLE' ELSE 'TABLE' END,
|
||||
r.obj, target);
|
||||
END LOOP;
|
||||
|
||||
-- Enums, domains, composite and range types (not a table's row type nor
|
||||
-- array types, which follow their element type).
|
||||
FOR r IN
|
||||
SELECT t.oid::regtype AS obj, t.typtype FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'
|
||||
AND t.typtype IN ('e', 'd', 'c', 'r', 'm') AND t.typelem = 0
|
||||
AND (t.typrelid = 0 OR (SELECT relkind FROM pg_class WHERE oid = t.typrelid) = 'c')
|
||||
AND pg_get_userbyid(t.typowner) <> target
|
||||
AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_type'::regclass AND d.objid = t.oid AND d.deptype IN ('i', 'e'))
|
||||
LOOP
|
||||
EXECUTE format('ALTER %s %s OWNER TO %I', CASE r.typtype WHEN 'd' THEN 'DOMAIN' ELSE 'TYPE' END, r.obj, target);
|
||||
END LOOP;
|
||||
|
||||
FOR r IN
|
||||
SELECT p.oid::regprocedure AS obj FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'
|
||||
AND pg_get_userbyid(p.proowner) <> target
|
||||
AND NOT EXISTS (SELECT FROM pg_depend d WHERE d.classid = 'pg_proc'::regclass AND d.objid = p.oid AND d.deptype = 'e')
|
||||
LOOP
|
||||
EXECUTE format('ALTER ROUTINE %s OWNER TO %I', r.obj, target);
|
||||
END LOOP;
|
||||
END $$;
|
||||
SQL
|
||||
echo " postgres: role $project owns database $db"
|
||||
}
|
||||
|
||||
# --- RabbitMQ ----------------------------------------------------------------
|
||||
rabbitmq_put() {
|
||||
curl -fsS -o /dev/null -X PUT "http://rabbitmq:15672/api$1" \
|
||||
-u "$RABBITMQ_DEFAULT_USER:$RABBITMQ_DEFAULT_PASS" \
|
||||
-H "Content-Type: application/json" --data-raw "$2"
|
||||
}
|
||||
|
||||
provision_rabbitmq() {
|
||||
local project=$1 password=$2
|
||||
rabbitmq_put "/vhosts/$project" '{}'
|
||||
rabbitmq_put "/users/$project" "$(jq -n --arg pw "$password" '{password: $pw, tags: ""}')"
|
||||
rabbitmq_put "/permissions/$project/$project" '{"configure": ".*", "write": ".*", "read": ".*"}'
|
||||
echo " rabbitmq: user $project, only on vhost $project"
|
||||
}
|
||||
|
||||
# --- Zitadel -----------------------------------------------------------------
|
||||
# zitadel <token> <method> <path> [body]: prints the response body; on a
|
||||
# non-2xx answer also reports it on stderr and returns non-zero. It calls
|
||||
# Zitadel on infra-net, so it presents the public Host/scheme for Zitadel to
|
||||
# find the instance (same as zitadel-login).
|
||||
zitadel() {
|
||||
local response code
|
||||
response=$(curl -sS -w '\n%{http_code}' -X "$2" "http://zitadel:8080$3" \
|
||||
-H "Authorization: Bearer $1" -H "Content-Type: application/json" \
|
||||
-H "Host: $ZITADEL_EXTERNAL_DOMAIN" -H "X-Forwarded-Proto: $ZITADEL_PUBLIC_SCHEME" \
|
||||
${4+--data-raw "$4"})
|
||||
code=$(printf '%s\n' "$response" | tail -n1)
|
||||
response=$(printf '%s\n' "$response" | sed '$d')
|
||||
printf '%s\n' "$response"
|
||||
case "$code" in
|
||||
2??) return 0 ;;
|
||||
*) printf 'zitadel: %s %s -> %s %s\n' "$2" "$3" "$code" "$response" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Stops the run when a lookup or creation didn't produce a value.
|
||||
need() { [ -n "$2" ] && [ "$2" != null ] || { echo "zitadel: got no $1" >&2; exit 1; }; }
|
||||
|
||||
provision_zitadel() {
|
||||
local project=$1 zitadel_project=$2 username="$1-provisioner"
|
||||
local project_id user_id member_roles pat_file token state
|
||||
|
||||
project_id=$(zitadel "$ADMIN_PAT" POST /management/v1/projects/_search \
|
||||
"$(jq -n --arg n "$zitadel_project" '{queries: [{nameQuery: {name: $n, method: "TEXT_QUERY_METHOD_EQUALS"}}]}')" \
|
||||
| jq -r '.result // [] | first | .id // empty')
|
||||
if [ -z "$project_id" ]; then
|
||||
project_id=$(zitadel "$ADMIN_PAT" POST /management/v1/projects "$(jq -n --arg n "$zitadel_project" '{name: $n}')" | jq -r .id)
|
||||
fi
|
||||
need "project id for $zitadel_project" "$project_id"
|
||||
|
||||
user_id=$(zitadel "$ADMIN_PAT" POST /management/v1/users/_search \
|
||||
"$(jq -n --arg u "$username" '{queries: [{userNameQuery: {userName: $u, method: "TEXT_QUERY_METHOD_EQUALS"}}]}')" \
|
||||
| jq -r '.result // [] | first | .id // empty')
|
||||
if [ -z "$user_id" ]; then
|
||||
user_id=$(zitadel "$ADMIN_PAT" POST /management/v1/users/machine \
|
||||
"$(jq -n --arg u "$username" --arg p "$zitadel_project" \
|
||||
'{userName: $u, name: ($p + " provisioner"), description: ("zitadel-init of " + $p + ": manages that project only"), accessTokenType: "ACCESS_TOKEN_TYPE_BEARER"}')" \
|
||||
| jq -r .userId)
|
||||
fi
|
||||
need "user id for $username" "$user_id"
|
||||
|
||||
# Owner of this one project (its apps, roles and grants) and nothing else.
|
||||
member_roles=$(zitadel "$ADMIN_PAT" POST "/management/v1/projects/$project_id/members/_search" \
|
||||
"$(jq -n --arg u "$user_id" '{queries: [{userIdQuery: {userId: $u}}]}')" \
|
||||
| jq -c '.result // [] | first | .roles // empty')
|
||||
if [ -z "$member_roles" ]; then
|
||||
zitadel "$ADMIN_PAT" POST "/management/v1/projects/$project_id/members" \
|
||||
"$(jq -n --arg u "$user_id" '{userId: $u, roles: ["PROJECT_OWNER"]}')" >/dev/null
|
||||
elif [ "$member_roles" != '["PROJECT_OWNER"]' ]; then
|
||||
zitadel "$ADMIN_PAT" PUT "/management/v1/projects/$project_id/members/$user_id" '{"roles": ["PROJECT_OWNER"]}' >/dev/null
|
||||
fi
|
||||
|
||||
# Keep a PAT that still works. A new one (they can't be read back) is only
|
||||
# issued when there is none, or it was revoked or lost with a volume reset.
|
||||
pat_file="/output/$project/zitadel.pat"
|
||||
mkdir -p "/output/$project"
|
||||
chmod 700 "/output/$project"
|
||||
if [ -s "$pat_file" ] \
|
||||
&& zitadel "$(tr -d '\r\n' <"$pat_file")" GET /auth/v1/users/me 2>/dev/null \
|
||||
| jq -e --arg u "$username" '.user.userName == $u' >/dev/null 2>&1; then
|
||||
state="kept"
|
||||
else
|
||||
token=$(zitadel "$ADMIN_PAT" POST "/management/v1/users/$user_id/pats" '{"expirationDate": "2099-01-01T00:00:00Z"}' | jq -r .token)
|
||||
need "PAT for $username" "$token"
|
||||
(umask 077 && printf '%s\n' "$token" >"$pat_file.tmp")
|
||||
mv "$pat_file.tmp" "$pat_file"
|
||||
state="issued"
|
||||
fi
|
||||
echo " zitadel: $username owns project \"$zitadel_project\", PAT $state"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
echo "==> waiting for Zitadel's admin PAT"
|
||||
deadline=$(( $(date +%s) + 180 ))
|
||||
until [ -s /output/admin-sa.pat ]; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || { echo "no /output/admin-sa.pat; check 'docker logs infra-zitadel'" >&2; exit 1; }
|
||||
sleep 3
|
||||
done
|
||||
ADMIN_PAT=$(tr -d '\r\n' </output/admin-sa.pat)
|
||||
|
||||
echo "==> shared"
|
||||
lock_down_postgres
|
||||
|
||||
for project in $PROJECTS; do
|
||||
echo "==> $project"
|
||||
value=$(setting "$project" DB_PASSWORD)
|
||||
[ -z "$value" ] || provision_postgres "$project" "$value"
|
||||
value=$(setting "$project" RABBITMQ_PASSWORD)
|
||||
[ -z "$value" ] || provision_rabbitmq "$project" "$value"
|
||||
value=$(setting "$project" ZITADEL_PROJECT)
|
||||
[ -z "$value" ] || provision_zitadel "$project" "$value"
|
||||
done
|
||||
echo "==> done"
|
||||
Reference in New Issue
Block a user