Files
computer-playbook/tests/utils/role_users.py
T
kevinveenbirkenbachandClaude Opus 5 3c72c2002a refactor(credentials)!: give applications their own administrator account
Application roles used the host administrator account.
user-administrator/tasks/00_core.yml:17 hashes its password into the OS
user, files/administrator grants that name ALL=(ALL) ALL without
NOPASSWD, and rotate.py lists it in ROTATION_EXEMPT, so the value is
password-authenticated sudo that cannot be rotated to recover. Every role
that typed it into a web form was typing the host sudo credential, and the
staged Playwright env carrying it is written mode 0644.

roles/user-administrator/meta/users.yml now declares platform_administrator
beside administrator: no host account, no rotation exemption, mailbox and
identity so it stays SSO-capable and can receive the mail applications send
to their admin. Every use moves to it, all fields and not only the
password: leaving .email behind built accounts from both identities at
once, with the host operator's address under the platform account's login
name, and an IdP matching on e-mail resolves such an account back to the
host operator.

The account is reachable by two spellings, and both are migrated:
lookup('users', 'administrator') in its two- and three-argument forms, and
the dotted lookup('config', application_id, 'users.administrator.email')
that five roles used. administrator is now referenced nowhere outside
roles/user-administrator.

Four lints hold the boundary:

  - no role outside user-administrator may use the host administrator, in
    either spelling or as a *user_key literal
  - a role that looks a user up must declare it in its own meta/users.yml,
    because required_user_policies only pins passwords for users declared
    by a role resolved into the inventory, and those are the invokable
    roles; a user declared only by a non-invokable role keeps the
    {{ 42 | strong_password }} fallback, which re-renders per evaluation
  - a role that iterates a user or passes it as a *user_key must declare it
  - the biber and mapache test personas may be read only from the
    Playwright and CLI test surfaces

Two credentials that were derived by hashing the admin password get their
own declaration, because deriving them tied an unrelated rotation to a
password change: web-app-fider jwt_secret, which signs session and e-mail
sign-in tokens, and web-app-nextcloud integration_suitecrm_client_secret,
whose two ends must be re-registered in lockstep.

web-app-keycloak separates the two admins it has: a new meta/users.yml
declares keycloak_bootstrap for the temporary account the container creates
on first start, while the permanent account is platform_administrator. Both
resolve in vars/main.yml so the lookup runs once per role.

web-app-semaphore's break-glass admin no longer shares ansible_become_password
and no longer forwards its mail to the host administrator; tasks/02_breakglass.yml
realigns the password on every deploy, so it converges on its own.

The six roles whose meta/secrets.yml declared administrator_password as its
only credential lose the file.

tests/integration/ansible/lookups/test_declared_users_resolve.py runs the
lookup through the real plugin loader and fails on a declared user that
cannot be resolved or whose username, e-mail or password renders empty. The
two declarations reading ansible_become_password resolve to an empty string
when that variable is absent, and nothing raises on it.

Existing deployments keep the admin account their application database
already holds, under the old name. Roles that re-apply the credential every
run converge; 30 roles feed it from config with no provisioning task and do
not, so those accounts need a reset. web-app-friendica additionally guards
on "'administrator' not in stdout", which the substring platform-administrator
satisfies, and web-app-n8n initialises an owner that already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 16:28:52 +02:00

53 lines
1.2 KiB
Python

"""Reading the users a role declares in its role-meta users file."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_FILE_META_USERS
if TYPE_CHECKING:
from pathlib import Path
_TOP_LEVEL_KEY = re.compile(r"^([A-Za-z0-9_-]+):")
def declared_users(role_dir: Path) -> set[str]:
"""User keys a role declares.
Args:
role_dir: the role directory.
Returns:
Top-level keys of its role-meta users file, empty when it is absent.
"""
users_file = role_dir / ROLE_FILE_META_USERS
if not users_file.is_file():
return set()
return {
match.group(1)
for match in (
_TOP_LEVEL_KEY.match(line)
for line in read_text(str(users_file)).splitlines()
)
if match
}
def declared_universe(roles_dir: Path) -> set[str]:
"""Every user key any role declares.
Args:
roles_dir: the roles directory.
Returns:
Union of all roles' declared user keys.
"""
universe: set[str] = set()
for role_dir in roles_dir.iterdir():
if role_dir.is_dir():
universe |= declared_users(role_dir)
return universe