mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
fix(credentials): finish the platform administrator migration in three roles
3c72c2002amoved applications onto platform_administrator, but matomo, pixelfed and openbao still named the host account, and the rule that forbids it matched [a-z_]*user_key only, so three uppercase Ansible vars went unseen. matomo is why 16 of the 19 failing jobs of run 37823049978 are red: it created its superuser and stored its API token under administrator, while meta/users.yml declares that token on platform_administrator, 03_install_path.yml gates the bootstrap on it, playwright.env.j2 signs in with it, and sys-front-inj-matomo gates every tracker injection on it being non-empty. Read at9bea4c3bb6, the commit the run built. The key is declared once now, as IAM_PLATFORM_ADMIN_USER_KEY beside IAM_USERNAME_MAX_LENGTH, with IAM_HOST_ADMIN_USER_KEY for the owner role that legitimately consumes the host account. Each role reads the variable where it looks the user up instead of keeping a pass-through copy of it, and the rule reads both keys from the same file, so it cannot drift from what the roles resolve. openbao moves the token store slot its recovery key lives in onto the user it declares. A deployment initialised earlier keeps that token under the old account, so the block that rebuilds the AppRole from it stops running until the token is moved, which TODO.md records. A deployed matomo likewise re-runs its bootstrap against an install whose superuser is still the old account, and fails loudly rather than silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e83cbcdffd
commit
5366788ab1
10 files changed
+49
-23
No files matched your search
@@ -16,6 +16,8 @@ STORAGE_CONSTRAINED: false # Storage constraint means that Infinito.Nexus prefer
|
||||
MASK_CREDENTIALS_IN_LOGS: true
|
||||
|
||||
IAM_USERNAME_MAX_LENGTH: 20 # Discourse refuses a longer one with ActiveRecord::RecordInvalid, and it is the tightest consumer
|
||||
IAM_PLATFORM_ADMIN_USER_KEY: "platform_administrator" # the account every application administers itself with, as declared in roles/user-administrator/meta/users.yml
|
||||
IAM_HOST_ADMIN_USER_KEY: "administrator" # the host account; only roles/user-administrator may consume it
|
||||
|
||||
HOST_CURRENCY: "EUR"
|
||||
HOST_TIMEZONE: "UTC"
|
||||
|
||||
@@ -41,6 +41,6 @@
|
||||
include_role:
|
||||
name: user
|
||||
vars:
|
||||
user_key: "administrator"
|
||||
user_key: "{{ IAM_HOST_ADMIN_USER_KEY }}"
|
||||
|
||||
- include_tasks: utils/once/flag.yml
|
||||
@@ -32,7 +32,7 @@
|
||||
name: sys-token-store
|
||||
tasks_from: write.yml
|
||||
vars:
|
||||
sys_token_store_user_key: "{{ MATOMO_ADMIN_USER_KEY }}"
|
||||
sys_token_store_user_key: "{{ IAM_PLATFORM_ADMIN_USER_KEY }}"
|
||||
sys_token_store_app: "{{ application_id }}"
|
||||
sys_token_store_token: "{{ matomo_token_value }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
@@ -28,7 +28,7 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
|
||||
# nocheck: persona-block-rationale — meta/services.yml pins sso.enabled=false: Matomo holds no Keycloak client, so the OIDC round-trip the shared helper waits for never happens; the admin journey is Matomo's native index.php?module=Login form against the local superuser (vars/main.yml MATOMO_ADMIN_*), driven by the role's own auth.spec.js (see README "Persona contract opt-outs").
|
||||
PERSONA_ADMINISTRATOR_BLOCKED=true
|
||||
|
||||
# nocheck: persona-block-rationale — the role provisions exactly one Matomo account, the superuser keyed by MATOMO_ADMIN_USER_KEY in vars/main.yml; there is no per-user provisioning, so biber has no Matomo identity to log in with at all. Matomo instead owns the deny-probe proving biber cannot reach the admin surface (see README "Persona contract opt-outs").
|
||||
# nocheck: persona-block-rationale — the role provisions exactly one Matomo account, the superuser keyed by IAM_PLATFORM_ADMIN_USER_KEY; there is no per-user provisioning, so biber has no Matomo identity to log in with at all. Matomo instead owns the deny-probe proving biber cannot reach the admin surface (see README "Persona contract opt-outs").
|
||||
PERSONA_BIBER_BLOCKED=true
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
|
||||
@@ -19,10 +19,9 @@ MATOMO_BOOTSTRAP_ATTEMPTS: 3
|
||||
MATOMO_INSTALLER_STEP_DEADLINE_S: 420
|
||||
MATOMO_BOOTSTRAP_SWARM_POLL_DELAY_S: 15
|
||||
|
||||
MATOMO_ADMIN_USER_KEY: "administrator"
|
||||
MATOMO_ADMIN_USER_NAME: "{{ lookup('users', MATOMO_ADMIN_USER_KEY).username }}"
|
||||
MATOMO_ADMIN_PASSWORD: "{{ lookup('users', MATOMO_ADMIN_USER_KEY).password }}"
|
||||
MATOMO_ADMIN_EMAIL: "{{ lookup('users', MATOMO_ADMIN_USER_KEY).email }}"
|
||||
MATOMO_ADMIN_USER_NAME: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).username }}"
|
||||
MATOMO_ADMIN_PASSWORD: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).password }}"
|
||||
MATOMO_ADMIN_EMAIL: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).email }}"
|
||||
MATOMO_SITE_NAME: "{{ lookup('design', application_id).label }}"
|
||||
MATOMO_SITE_URL: "{{ lookup('tls', application_id, 'url.base') }}"
|
||||
MATOMO_EXCLUDED_IPS: "{{ lookup('config', application_id, 'services.matomo.excluded_ips') }}"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# TODO
|
||||
|
||||
- Recovery token moved account: the recovery token was stored under `administrator`, a user this role never declared in `meta/users.yml`, and now follows `IAM_PLATFORM_ADMIN_USER_KEY`. Writer and reader both derive from the var, so a fresh deployment is consistent, but a deployment initialised earlier holds its recovery token under the old account: `01_init.yml` then reads an empty `OPENBAO_RECOVERY_KEY` and silently skips the block that rebuilds the AppRole when neither login is accepted. Move the stored token onto `platform_administrator` on such a deployment before relying on that path again.
|
||||
- Local deploy without LAM: `meta/variants.yml` variant 0 pins `services.lam` on while `meta/services.yml` declares no `lam` entry, so `disable=lam` writes no override and `make compose-deploy apps=web-app-openbao variant=0 disable=...,lam` aborts with "`disable` conflicts with the current inventory state"; without `lam` in the list the round deploys `web-app-lam` even when `ldap` is disabled. Declare `lam` in `meta/services.yml` with the `'web-app-lam' in group_names` flags, or let the services disabler flip keys that only a variant bakes.
|
||||
@@ -48,7 +48,7 @@
|
||||
name: sys-token-store
|
||||
tasks_from: write.yml
|
||||
vars:
|
||||
sys_token_store_user_key: "{{ OPENBAO_RECOVERY_USER_KEY }}"
|
||||
sys_token_store_user_key: "{{ IAM_PLATFORM_ADMIN_USER_KEY }}"
|
||||
sys_token_store_app: "{{ OPENBAO_RECOVERY_TOKEN_APP }}"
|
||||
sys_token_store_token: >-
|
||||
{{ (openbao_init_raw.stdout | from_json).recovery_keys_b64 | first }}
|
||||
|
||||
@@ -37,11 +37,10 @@ OPENBAO_SEAL_KEY_PREVIOUS: "{{ OPENBAO_APPLIED.seal_key | default('', true) }}"
|
||||
OPENBAO_SEAL_KEY_PREVIOUS_ID: "{{ (OPENBAO_SEAL_KEY_PREVIOUS | hash('sha256'))[:16] if OPENBAO_SEAL_KEY_PREVIOUS else '' }}"
|
||||
OPENBAO_SEAL_ROTATING: "{{ (OPENBAO_SEAL_KEY_PREVIOUS | length > 0) and (OPENBAO_SEAL_KEY_PREVIOUS != OPENBAO_SEAL_KEY) }}"
|
||||
|
||||
OPENBAO_RECOVERY_USER_KEY: "administrator"
|
||||
OPENBAO_RECOVERY_TOKEN_APP: "{{ application_id }}-recovery"
|
||||
OPENBAO_RECOVERY_KEY: >-
|
||||
{{ (sys_token_store_users | default({}, true))
|
||||
.get(OPENBAO_RECOVERY_USER_KEY, {})
|
||||
.get(IAM_PLATFORM_ADMIN_USER_KEY, {})
|
||||
.get('tokens', {})
|
||||
.get(OPENBAO_RECOVERY_TOKEN_APP, '') }}
|
||||
|
||||
|
||||
@@ -16,9 +16,8 @@ PIXELFED_PUBLIC_STORAGE_SOURCE: "{{ [PIXELFED_STORAGE_DIR, 'app', 'public'] | pa
|
||||
PIXELFED_PUBLIC_STORAGE_LINK: "{{ [PIXELFED_STORAGE_DIR | dirname, 'public', 'storage'] | path_join }}"
|
||||
PIXELFED_INIT_MARKER: "{{ [PIXELFED_STORAGE_DIR, '.docker.init'] | path_join }}"
|
||||
PIXELFED_DB_NAME: "{{ lookup('database', application_id, 'name') }}"
|
||||
PIXELFED_ADMIN_USER_KEY: "administrator"
|
||||
PIXELFED_ADMIN_USERNAME: "{{ lookup('users', PIXELFED_ADMIN_USER_KEY).username }}"
|
||||
PIXELFED_ADMIN_EMAIL: "{{ lookup('users', PIXELFED_ADMIN_USER_KEY).email }}"
|
||||
PIXELFED_ADMIN_PASSWORD: "{{ lookup('users', PIXELFED_ADMIN_USER_KEY).password }}"
|
||||
PIXELFED_ADMIN_NAME: "{{ (lookup('users', PIXELFED_ADMIN_USER_KEY).firstname ~ ' ' ~ lookup('users', PIXELFED_ADMIN_USER_KEY).lastname) | trim }}"
|
||||
PIXELFED_ADMIN_USERNAME: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).username }}"
|
||||
PIXELFED_ADMIN_EMAIL: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).email }}"
|
||||
PIXELFED_ADMIN_PASSWORD: "{{ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).password }}"
|
||||
PIXELFED_ADMIN_NAME: "{{ (lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).firstname ~ ' ' ~ lookup('users', IAM_PLATFORM_ADMIN_USER_KEY).lastname) | trim }}"
|
||||
PIXELFED_ADMIN_OIDC_ID: "{{ PIXELFED_ADMIN_USERNAME }}"
|
||||
@@ -37,17 +37,43 @@ import re
|
||||
import unittest
|
||||
|
||||
from utils.cache.files import iter_non_ignored_files, read_text
|
||||
from utils.cache.yaml import load_yaml
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
OWNER_ROLE = "roles/user-administrator/"
|
||||
|
||||
_HOST_ADMIN_USER = re.compile(r"lookup\(\s*(['\"])users\1\s*,\s*(['\"])administrator\2")
|
||||
_GENERAL_VARS = PROJECT_ROOT / "group_vars" / "all" / "00_general.yml"
|
||||
|
||||
|
||||
def admin_user_keys() -> tuple[str, str]:
|
||||
"""Return the host administrator key and the platform administrator key.
|
||||
|
||||
Returns:
|
||||
``IAM_HOST_ADMIN_USER_KEY`` and ``IAM_PLATFORM_ADMIN_USER_KEY`` from
|
||||
the global variables, so this rule matches the keys the roles actually
|
||||
resolve instead of a second copy of their spelling.
|
||||
"""
|
||||
general = load_yaml(str(_GENERAL_VARS))
|
||||
return (
|
||||
str(general["IAM_HOST_ADMIN_USER_KEY"]),
|
||||
str(general["IAM_PLATFORM_ADMIN_USER_KEY"]),
|
||||
)
|
||||
|
||||
|
||||
HOST_ADMIN, PLATFORM_ADMIN = admin_user_keys()
|
||||
|
||||
_HOST_ADMIN_USER = re.compile(
|
||||
rf"lookup\(\s*(['\"])users\1\s*,\s*(['\"]){re.escape(HOST_ADMIN)}\2"
|
||||
)
|
||||
_HOST_ADMIN_USER_KEY = re.compile(
|
||||
r"^\s*[a-z_]*user_key:\s*['\"]?administrator['\"]?\s*$"
|
||||
rf"^\s*[a-z_]*user_key:\s*['\"]?{re.escape(HOST_ADMIN)}['\"]?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HOST_ADMIN_DOTTED = re.compile(rf"users\.{re.escape(HOST_ADMIN)}\.")
|
||||
_RETIRED_CREDENTIAL = re.compile(
|
||||
rf"secrets\.credentials\.{re.escape(HOST_ADMIN)}_password"
|
||||
)
|
||||
_HOST_ADMIN_DOTTED = re.compile(r"users\.administrator\.")
|
||||
_RETIRED_CREDENTIAL = re.compile(r"secrets\.credentials\.administrator_password")
|
||||
|
||||
_SUFFIXES = (".yml", ".yaml", ".j2", ".py", ".js", ".sh", ".sql")
|
||||
|
||||
@@ -67,13 +93,13 @@ class TestNoHostAdminCredentialInRoles(unittest.TestCase):
|
||||
or _HOST_ADMIN_DOTTED.search(line)
|
||||
):
|
||||
findings.append(
|
||||
f"{rel}:{number}: uses the host administrator account; "
|
||||
f"use lookup('users', 'platform_administrator') instead"
|
||||
f"{rel}:{number}: uses the {HOST_ADMIN} account; read "
|
||||
f"IAM_PLATFORM_ADMIN_USER_KEY ({PLATFORM_ADMIN}) instead"
|
||||
)
|
||||
if _RETIRED_CREDENTIAL.search(line):
|
||||
findings.append(
|
||||
f"{rel}:{number}: secrets.credentials.administrator_password is "
|
||||
f"retired; use lookup('users', 'platform_administrator') instead"
|
||||
f"{rel}:{number}: secrets.credentials.{HOST_ADMIN}_password is "
|
||||
f"retired; read IAM_PLATFORM_ADMIN_USER_KEY ({PLATFORM_ADMIN}) instead"
|
||||
)
|
||||
|
||||
if findings:
|
||||
|
||||
Reference in new issue
Block a user