diff --git a/plugins/filter/platform_admin.py b/plugins/filter/platform_admin.py new file mode 100644 index 0000000000..6ec440669d --- /dev/null +++ b/plugins/filter/platform_admin.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from ansible.errors import AnsibleFilterError + +_FIELDS = {"login": str, "email": str, "admin": bool} + + +def _validated(accounts) -> list[dict]: + if not isinstance(accounts, list): + raise AnsibleFilterError("platform_admin_plan expects the accounts as a list") + for account in accounts: + if not isinstance(account, dict) or any( + not isinstance(account.get(field), kind) for field, kind in _FIELDS.items() + ): + raise AnsibleFilterError( + f"an account needs a string login and email and a boolean admin: {account}" + ) + return accounts + + +def platform_admin_plan( + accounts, login: str, email: str, app: str, case_sensitive_login: bool +) -> str: + """Decide how the platform administrator reaches an installed app. + + Args: + accounts: every account of the app as ``{login, email, admin}``. + login: login the platform administrator has in the app. + email: email an account created for it gets. + app: name of the app, for the failure messages. + case_sensitive_login: whether the app tells logins apart by case. + + Returns: + ``realign`` when ``login`` is an administrator already, ``create`` + when it is absent and is added next to the existing administrators. + + Raises: + AnsibleFilterError: ``login`` belongs to a non-administrator, the app + has no administrator at all, or another account holds ``email``. + """ + accounts = _validated(accounts) + + def same(other: str) -> bool: + return ( + other == login + if case_sensitive_login + else other.casefold() == login.casefold() + ) + + holder = next((account for account in accounts if same(account["login"])), None) + if holder is not None: + if holder["admin"]: + return "realign" + raise AnsibleFilterError( + f"the {app} account '{holder['login']}' is no administrator and is not " + f"promoted; make it an administrator in {app} or rename it, then redeploy" + ) + + if not any(account["admin"] for account in accounts): + raise AnsibleFilterError( + f"{app} has no administrator, so its install is unfinished; finish or " + f"reset that install instead of adding '{login}' to it" + ) + + email_holders = sorted( + account["login"] + for account in accounts + if account["email"].casefold() == email.casefold() + ) + if email_holders: + raise AnsibleFilterError( + f"the email '{email}' is held by the {app} account " + f"{', '.join(email_holders)}; change that account's email, then redeploy" + ) + + return "create" + + +class FilterModule: + def filters(self): + return {"platform_admin_plan": platform_admin_plan} diff --git a/roles/web-app-joomla/filter_plugins/joomla_users.py b/roles/web-app-joomla/filter_plugins/joomla_users.py new file mode 100644 index 0000000000..90910dfc50 --- /dev/null +++ b/roles/web-app-joomla/filter_plugins/joomla_users.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import re +import unicodedata + +from ansible.errors import AnsibleFilterError + +_BORDER = re.compile(r"^ *-+(?: +-+)* *$") +_HEADER = ["ID", "Username", "Name", "Email", "Blocked", "Groups"] +_ADMIN_GROUP = "Super Users" +_ZERO_WIDTH = frozenset({"Mn", "Me", "Cf"}) +_DOUBLE_WIDTH = frozenset({"W", "F"}) +_DUPLICATE_USERNAME = "The username already exists!" +_DUPLICATE_EMAIL = "The email address already exists!" + + +def _width(char: str) -> int: + """Return the terminal columns ``char`` takes in a Symfony table. + + Args: + char: one code point. + + Returns: + 0 for combining marks and format characters, 2 for East Asian wide + and fullwidth characters, 1 otherwise. + """ + if unicodedata.combining(char) or unicodedata.category(char) in _ZERO_WIDTH: + return 0 + return 2 if unicodedata.east_asian_width(char) in _DOUBLE_WIDTH else 1 + + +def _cut(line: str, spans: list[tuple[int, int]]) -> list[str]: + """Split a table row into the cells under the border's dash runs. + + Args: + line: one row of the table. + spans: ``[start, end)`` display columns of every dash run. + + Returns: + The stripped text of every cell, in column order. + """ + cells: list[list[str]] = [[] for _ in spans] + column = 0 + for char in line: + for index, (start, end) in enumerate(spans): + if start <= column < end: + cells[index].append(char) + break + column += _width(char) + return ["".join(cell).strip() for cell in cells] + + +def joomla_user_accounts(output) -> list[dict]: + """Read the accounts out of the table ``cli/joomla.php user:list`` prints. + + The table is cut along its ``---`` border, not along whitespace, because a + name may hold spaces and the group column lists ``Registered, Super Users``. + The border is cut in display columns, not code points: Symfony pads every + cell to the display width of the widest one, so a CJK name takes two + columns per character and a decomposed accent takes none. + + Args: + output: stdout of ``user:list``. + + Returns: + One ``{login, email, admin}`` per listed account, ``admin`` being + membership in the Super Users group. + """ + lines = output.splitlines() if isinstance(output, str) else [] + borders = [index for index, line in enumerate(lines) if _BORDER.match(line)] + if len(borders) < 3: + raise AnsibleFilterError("user:list printed no account table") + + spans = [match.span() for match in re.finditer(r"-+", lines[borders[0]])] + + header = _cut(lines[borders[0] + 1], spans) + if header != _HEADER: + raise AnsibleFilterError( + f"user:list printed the columns {header}, expected {_HEADER}" + ) + + accounts = [] + for line in lines[borders[1] + 1 : borders[-1]]: + if not line.strip() or _BORDER.match(line): + continue + row = dict(zip(_HEADER, _cut(line, spans), strict=True)) + accounts.append( + { + "login": row["Username"], + "email": row["Email"], + "admin": _ADMIN_GROUP + in [group.strip() for group in row["Groups"].split(",")], + } + ) + return accounts + + +def joomla_user_add_conflict(output, login: str, email: str) -> str: + """Explain a ``user:add`` refusal caused by an account ``user:list`` hides. + + ``user:list`` inner-joins every account with its user groups, so an account + that belongs to no group never reached the plan that chose to add. + + Args: + output: stdout and stderr of ``user:add``. + login: username that was added. + email: email that was added. + + Returns: + The failure message for a username or email Joomla already holds, an + empty string for any other refusal. + """ + text = output if isinstance(output, str) else "" + if _DUPLICATE_USERNAME in text: + return ( + f"the Joomla account '{login}' (in any letter case) already exists but " + "belongs to no user group, so user:list does not show it; add it to " + "Super Users in Users > Manage or rename it, then redeploy" + ) + if _DUPLICATE_EMAIL in text: + return ( + f"a Joomla account without any user group already holds '{email}', so " + "user:list does not show it; give that account a user group or change " + "its email in Users > Manage, then redeploy" + ) + return "" + + +class FilterModule: + def filters(self): + return { + "joomla_user_accounts": joomla_user_accounts, + "joomla_user_add_conflict": joomla_user_add_conflict, + } diff --git a/roles/web-app-joomla/tasks/07_reset_admin_password.yml b/roles/web-app-joomla/tasks/07_reset_admin_password.yml index c230f1ba79..5bd580c192 100644 --- a/roles/web-app-joomla/tasks/07_reset_admin_password.yml +++ b/roles/web-app-joomla/tasks/07_reset_admin_password.yml @@ -1,20 +1,64 @@ --- -# Reset Joomla admin password via CLI (inside the container). # Immediately after a fresh install, `user:reset-password` can transiently # report "The user does not exist!" even though the user has been # created and is queryable via `user:list` β€” observed consistently in CI. # Joomla's CLI bootstraps a fresh session per invocation but Symfony's # service container + autoloader warm-up plus the recent DB commit appear -# to race on first post-install invocation. A short retry loop with a -# small delay tolerates this without masking a genuine failure (a truly -# missing user keeps failing after all retries). +# to race on first post-install invocation. +# Every password option uses `--password=value`: Symfony binds a separate +# value only when it does not start with `-`. +- name: "πŸ”Ž List the Joomla accounts until a Super User shows up" + shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec {{ JOOMLA_LOCAL_CID | quote }} + php "{{ JOOMLA_CLI_FILE }}" user:list --no-interaction + register: joomla_user_list + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + changed_when: false + retries: 6 + delay: 5 + until: joomla_user_list.rc == 0 and 'Super Users' in joomla_user_list.stdout + delegate_to: "{{ JOOMLA_HOST_NODE }}" + +- name: "🧭 Decide whether the Joomla administrator is added or realigned" + ansible.builtin.set_fact: + joomla_admin_plan: >- + {{ joomla_user_list.stdout | joomla_user_accounts + | platform_admin_plan(JOOMLA_USER_NAME, JOOMLA_USER_EMAIL, 'Joomla', false) }} + +- name: "βž• Add the Joomla administrator next to the existing Super Users" + shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node + container exec -e J_USERNAME -e J_NAME -e J_EMAIL -e J_PASSWORD {{ JOOMLA_LOCAL_CID | quote }} + sh -lc 'php "{{ JOOMLA_CLI_FILE }}" user:add + --username="$J_USERNAME" --name="$J_NAME" --email="$J_EMAIL" + --password="$J_PASSWORD" --usergroup="Super Users" --no-interaction' + environment: + J_USERNAME: "{{ JOOMLA_USER_NAME }}" + J_NAME: "{{ JOOMLA_USER }}" + J_EMAIL: "{{ JOOMLA_USER_EMAIL }}" + J_PASSWORD: "{{ JOOMLA_USER_PASSWORD }}" + register: j_user_add + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + changed_when: j_user_add.rc == 0 + failed_when: >- + j_user_add.rc != 0 + and (j_user_add.stdout ~ j_user_add.stderr) | joomla_user_add_conflict(JOOMLA_USER_NAME, JOOMLA_USER_EMAIL) == '' + when: joomla_admin_plan == 'create' + delegate_to: "{{ JOOMLA_HOST_NODE }}" + +- name: "πŸ›‘ Name the group-less Joomla account that user:list does not show" + ansible.builtin.fail: + msg: "{{ (j_user_add.stdout ~ j_user_add.stderr) | joomla_user_add_conflict(JOOMLA_USER_NAME, JOOMLA_USER_EMAIL) }}" + when: + - joomla_admin_plan == 'create' + - j_user_add.rc != 0 + - name: "♻️ Reset Joomla admin password (non-interactive CLI)" shell: > # nocheck: container-exec-resolver runtime-resolved CID, delegated to the hosting node container exec -e JOOMLA_USER_PASSWORD {{ JOOMLA_LOCAL_CID | quote }} sh -lc 'php "{{ JOOMLA_CLI_FILE }}" user:reset-password --username "{{ JOOMLA_USER_NAME }}" - --password "$JOOMLA_USER_PASSWORD"' + --password="$JOOMLA_USER_PASSWORD"' environment: JOOMLA_USER_PASSWORD: "{{ JOOMLA_USER_PASSWORD }}" register: j_password_reset diff --git a/roles/web-app-listmonk/files/sql/admin_accounts.sql b/roles/web-app-listmonk/files/sql/admin_accounts.sql new file mode 100644 index 0000000000..b1fd832beb --- /dev/null +++ b/roles/web-app-listmonk/files/sql/admin_accounts.sql @@ -0,0 +1,11 @@ +-- listmonk grants every permission to role id 1 by that id alone +-- (internal/auth/models.go SuperAdminRoleID), whatever the role is named. +SELECT COALESCE( + json_agg(json_build_object( + 'login', username, + 'email', email, + 'admin', type = 'user' AND user_role_id = 1 + ) ORDER BY id), + '[]'::json + ) +FROM users; diff --git a/roles/web-app-listmonk/files/sql/realign_admin_credential.sql b/roles/web-app-listmonk/files/sql/realign_admin_credential.sql index 615ced3c9c..b978a501cc 100644 --- a/roles/web-app-listmonk/files/sql/realign_admin_credential.sql +++ b/roles/web-app-listmonk/files/sql/realign_admin_credential.sql @@ -4,6 +4,19 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; \getenv pw LISTMONK_REALIGN_PASSWORD +\if :create +INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) +SELECT :'login', :'password_login'::boolean, crypt(:'pw', gen_salt('bf')), :'email', :'login', + 'user', 1, 'enabled' +WHERE length(:'pw') > 0 + AND NOT EXISTS (SELECT 1 FROM users WHERE username = :'login') + AND NOT EXISTS (SELECT 1 FROM users WHERE lower(email) = lower(:'email')) + AND EXISTS (SELECT 1 FROM users WHERE type = 'user' AND user_role_id = 1); +\set created :ROW_COUNT +\else +\set created 0 +\endif + UPDATE users SET password = crypt(:'pw', gen_salt('bf')) WHERE username = :'login' @@ -15,6 +28,7 @@ SELECT CASE THEN '[FAIL] LISTMONK_REALIGN_PASSWORD did not reach the container' WHEN NOT EXISTS (SELECT 1 FROM users WHERE username = :'login') THEN '[FAIL] no account named ' || :'login' + WHEN :created = 1 THEN '[CHANGED] the Super Admin ' || :'login' || ' was created' WHEN :ROW_COUNT = 1 THEN '[CHANGED]' ELSE '[UNCHANGED]' END AS realign; diff --git a/roles/web-app-listmonk/tasks/02_manager_ops.yml b/roles/web-app-listmonk/tasks/02_manager_ops.yml index c3e60174c7..e454d34c88 100644 --- a/roles/web-app-listmonk/tasks/02_manager_ops.yml +++ b/roles/web-app-listmonk/tasks/02_manager_ops.yml @@ -10,22 +10,38 @@ - name: πŸ“‚ Include database setup routine for Listmonk include_tasks: "01_database.yml" +- name: πŸ”Ž List the Listmonk accounts and their Super Admin role + ansible.builtin.shell: + cmd: "{{ LISTMONK_EXEC_DATABASE }} -v ON_ERROR_STOP=1 -tAq" + stdin: "{{ lookup('file', 'sql/admin_accounts.sql') }}" + register: listmonk_admin_accounts + changed_when: false + +- name: 🧭 Decide whether the Listmonk administrator is created or realigned + ansible.builtin.set_fact: + listmonk_admin_plan: >- + {{ listmonk_admin_accounts.stdout | from_json + | platform_admin_plan(LISTMONK_ADMIN_USERNAME, LISTMONK_ADMIN_EMAIL, 'Listmonk', true) }} + - name: πŸ”„ Update administrator email and password login in Listmonk (as superuser) shell: | {{ LISTMONK_EXEC_DATABASE }} -v ON_ERROR_STOP=1 << 'EOSQL' UPDATE users - SET email = '{{ lookup('users', 'platform_administrator').email }}', + SET email = '{{ LISTMONK_ADMIN_EMAIL }}', password_login = {{ 'false' if lookup('config',application_id, 'services.sso.enabled') else 'true' }} WHERE username = '{{ LISTMONK_ADMIN_USERNAME }}'; EOSQL args: executable: /bin/bash -- name: πŸ”‘ Realign the administrator password Listmonk persisted at install +- name: πŸ”‘ Ensure the Listmonk Super Admin exists and accepts the declared password ansible.builtin.shell: cmd: >- {{ LISTMONK_EXEC_DATABASE }} -v ON_ERROR_STOP=1 -v login={{ LISTMONK_ADMIN_USERNAME | quote }} + -v email={{ LISTMONK_ADMIN_EMAIL | quote }} + -v password_login={{ 'false' if LISTMONK_OIDC_ENABLED | bool else 'true' }} + -v create={{ 'true' if listmonk_admin_plan == 'create' else 'false' }} stdin: "{{ lookup('file', 'sql/realign_admin_credential.sql') }}" environment: LISTMONK_REALIGN_PASSWORD: "{{ LISTMONK_ADMIN_PASSWORD }}" diff --git a/roles/web-app-listmonk/vars/main.yml b/roles/web-app-listmonk/vars/main.yml index 40d0011607..4783174deb 100644 --- a/roles/web-app-listmonk/vars/main.yml +++ b/roles/web-app-listmonk/vars/main.yml @@ -5,6 +5,7 @@ container_port: "{{ lookup('config', application_id, 'services.listmonk.ports.in LISTMONK_ADMIN_USERNAME: "{{ lookup('users', 'platform_administrator').username }}" LISTMONK_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}" +LISTMONK_ADMIN_EMAIL: "{{ lookup('users', 'platform_administrator').email }}" LISTMONK_NAME: "{{ lookup('config', application_id, 'services.listmonk.name') }}" LISTMONK_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'),'config.toml' ] | path_join }}" diff --git a/roles/web-app-matomo/README.md b/roles/web-app-matomo/README.md index 7d71379035..bfcf77fe19 100644 --- a/roles/web-app-matomo/README.md +++ b/roles/web-app-matomo/README.md @@ -14,7 +14,7 @@ This role deploys Matomo using Docker, automating the setup of your analytics pl - **Robust Tracking:** Track user interactions across your website with comprehensive analytics tools. - **Privacy-First:** Enjoy a self-hosted solution that prioritizes data ownership and privacy. - **Customizable Setup:** Configure database connections, admin credentials, and server settings via environment variables and Matomo's `config/config.ini.php`, which the role patches on every deploy. -- **Administrator Password:** The superuser password converges to the configured administrator's password on every deploy. +- **Administrator Password:** The superuser password converges to the configured administrator's password on every deploy. An installed Matomo that lacks the configured administrator gets it as a further superuser next to the existing ones. - **Corporate Design:** The InfinitoDesign theme plugin maps Matomo's theme colors onto the design tokens and ships the logo set; the administrator and the login page follow the browser color scheme. Both are removed when `design` is off. - **Scalable Deployment:** Use Docker to ensure your analytics platform can grow with your traffic demands. - **Version Upgrades:** When the pinned image ships a newer Matomo than the data volume holds, the deploy installs that release into the volume, keeps `config/config.ini.php`, `misc/user/` and Marketplace plugins, and runs `core:update`; a volume newer than the image stops the deploy. diff --git a/roles/web-app-matomo/files/php/converge_superuser_password.php b/roles/web-app-matomo/files/php/converge_superuser_password.php index d63a31087d..925b5b883f 100644 --- a/roles/web-app-matomo/files/php/converge_superuser_password.php +++ b/roles/web-app-matomo/files/php/converge_superuser_password.php @@ -1,8 +1,14 @@ logins other than $login whose email is $email, case-insensitively + */ + public function holdersOf(string $email, string $login): array; + + public function insertSuperuser(string $login, string $hash, string $email): void; + + public function updatePassword(string $login, string $hash): void; +} + +final class PdoUsers implements Users +{ + public function __construct(private PDO $pdo, private string $table) + { + } + + public function find(string $login): ?array + { + $read = $this->pdo->prepare("SELECT password, superuser_access FROM `{$this->table}` WHERE login = ?"); + $read->execute([$login]); + $row = $read->fetch(PDO::FETCH_ASSOC); + + return $row === false ? null : ['password' => (string) $row['password'], 'superuser' => (int) $row['superuser_access'] === 1]; + } + + public function superusers(): int + { + return (int) $this->pdo->query("SELECT COUNT(*) FROM `{$this->table}` WHERE superuser_access = 1")->fetchColumn(); + } + + public function holdersOf(string $email, string $login): array + { + $read = $this->pdo->prepare("SELECT login FROM `{$this->table}` WHERE LOWER(email) = LOWER(?) AND login <> ?"); + $read->execute([$email, $login]); + + return array_map('strval', $read->fetchAll(PDO::FETCH_COLUMN)); + } + + public function insertSuperuser(string $login, string $hash, string $email): void + { + $this->pdo->prepare( + "INSERT INTO `{$this->table}` (login, password, email, twofactor_secret, superuser_access, date_registered, ts_password_modified)" + . " VALUES (?, ?, ?, '', 1, UTC_TIMESTAMP(), UTC_TIMESTAMP())" + )->execute([$login, $hash, $email]); + } + + public function updatePassword(string $login, string $hash): void + { + $this->pdo->prepare("UPDATE `{$this->table}` SET password = ?, ts_password_modified = UTC_TIMESTAMP() WHERE login = ?") + ->execute([$hash, $login]); + } +} + +/** + * @return array{0: int, 1: string} exit code and the [CHANGED], [UNCHANGED] or [FAIL] line + */ +function ensure(Users $users, string $login, string $password, string $email): array +{ + $account = $users->find($login); + + if ($account === null) { + if ($users->superusers() === 0) { + return [1, "[FAIL] Matomo has no superuser, so its install is unfinished; finish or reset that install instead of adding '$login' to it"]; + } + $holders = $users->holdersOf($email, $login); + if ($holders !== []) { + return [1, sprintf("[FAIL] the email '%s' is held by the Matomo user %s; change that account's email, then redeploy", $email, implode(', ', $holders))]; + } + $users->insertSuperuser($login, password_hash(md5($password), PASSWORD_DEFAULT), $email); + + return [0, "[CHANGED] the Matomo superuser '$login' was created with the declared password"]; + } + + if (!$account['superuser']) { + return [1, "[FAIL] the Matomo user '$login' has no superuser access and is not promoted; grant it superuser access in Matomo or rename it, then redeploy"]; + } + + if (password_verify(md5($password), $account['password'])) { + return [0, "[UNCHANGED] the Matomo superuser already accepts the declared password"]; + } + + $users->updatePassword($login, password_hash(md5($password), PASSWORD_DEFAULT)); + + return [0, "[CHANGED] the Matomo superuser was realigned with the declared password"]; +} + function required(string $name): string { $value = getenv($name); if ($value === false || $value === '') { - fwrite(STDERR, "[FAIL] $name is empty\n"); - exit(1); + throw new \RuntimeException("$name is empty"); } + return $value; } -$configPath = required('MATOMO_CONFIG'); -$login = required('MATOMO_SUPERUSER_LOGIN'); -$password = required('MATOMO_SUPERUSER_PASSWORD'); +function main(): int +{ + try { + $configPath = required('MATOMO_CONFIG'); + $config = parse_ini_file($configPath, true); + if ($config === false || !isset($config['database']['tables_prefix'])) { + throw new \RuntimeException("$configPath carries no database.tables_prefix"); + } -$config = parse_ini_file($configPath, true); -if ($config === false || !isset($config['database']['tables_prefix'])) { - fwrite(STDERR, "[FAIL] $configPath carries no database.tables_prefix\n"); - exit(1); -} -$prefix = $config['database']['tables_prefix']; + [$host, $port] = array_pad(explode(':', required('MATOMO_DATABASE_HOST')), 2, '3306'); + $pdo = new PDO( + "mysql:host=$host;port=$port;dbname=" . required('MATOMO_DATABASE_DBNAME'), + required('MATOMO_DATABASE_USERNAME'), + required('MATOMO_DATABASE_PASSWORD'), + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); -[$host, $port] = array_pad(explode(':', required('MATOMO_DATABASE_HOST')), 2, 3306); -$pdo = new PDO( - "mysql:host=$host;port=$port;dbname=" . required('MATOMO_DATABASE_DBNAME'), - required('MATOMO_DATABASE_USERNAME'), - required('MATOMO_DATABASE_PASSWORD'), - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] -); + [$code, $line] = ensure( + new PdoUsers($pdo, $config['database']['tables_prefix'] . 'user'), + required('MATOMO_SUPERUSER_LOGIN'), + required('MATOMO_SUPERUSER_PASSWORD'), + required('MATOMO_SUPERUSER_EMAIL') + ); + } catch (Throwable $error) { + [$code, $line] = [1, '[FAIL] ' . $error->getMessage()]; + } -$read = $pdo->prepare("SELECT password FROM `{$prefix}user` WHERE login = ?"); -$read->execute([$login]); -$stored = $read->fetchColumn(); + file_put_contents($code === 0 ? 'php://stdout' : 'php://stderr', $line . "\n"); -if ($stored === false) { - fwrite(STDERR, "[FAIL] no Matomo user named '$login'\n"); - exit(1); + return $code; } -if (password_verify(md5($password), $stored)) { - echo "[UNCHANGED] the Matomo superuser already accepts the declared password\n"; - exit(0); +if (!debug_backtrace()) { + exit(main()); } - -$write = $pdo->prepare( - "UPDATE `{$prefix}user` SET password = ?, ts_password_modified = UTC_TIMESTAMP() WHERE login = ?" -); -$write->execute([password_hash(md5($password), PASSWORD_DEFAULT), $login]); -echo "[CHANGED] the Matomo superuser was realigned with the declared password\n"; diff --git a/roles/web-app-matomo/tasks/03_install_path.yml b/roles/web-app-matomo/tasks/03_install_path.yml index 547da3251d..98a3cd21c4 100644 --- a/roles/web-app-matomo/tasks/03_install_path.yml +++ b/roles/web-app-matomo/tasks/03_install_path.yml @@ -7,7 +7,7 @@ changed_when: false delegate_to: "{{ MATOMO_HOST_NODE }}" -- name: "πŸ”‘ Matomo install | Realign the superuser of an existing install" +- name: "πŸ”‘ Matomo install | Ensure the superuser of an existing install" include_tasks: file: utils/converge_superuser.yml apply: diff --git a/roles/web-app-matomo/tasks/utils/converge_superuser.yml b/roles/web-app-matomo/tasks/utils/converge_superuser.yml index 1380e9c9ac..0cd950371b 100644 --- a/roles/web-app-matomo/tasks/utils/converge_superuser.yml +++ b/roles/web-app-matomo/tasks/utils/converge_superuser.yml @@ -1,15 +1,16 @@ --- -- name: "πŸ”‘ Realign the administrator password Matomo persisted at install" +- name: "πŸ”‘ Ensure the Matomo superuser exists and accepts the declared password" ansible.builtin.shell: # nocheck: container-exec-resolver MATOMO_LOCAL_CID resolved at delegate_to runtime; container_address would return a manager-only subshell cmd: > container exec -i --user root - -e MATOMO_CONFIG -e MATOMO_SUPERUSER_LOGIN -e MATOMO_SUPERUSER_PASSWORD + -e MATOMO_CONFIG -e MATOMO_SUPERUSER_LOGIN -e MATOMO_SUPERUSER_PASSWORD -e MATOMO_SUPERUSER_EMAIL {{ MATOMO_LOCAL_CID | quote }} php stdin: "{{ lookup('file', 'php/converge_superuser_password.php') }}" environment: MATOMO_CONFIG: "{{ MATOMO_CONFIG }}" MATOMO_SUPERUSER_LOGIN: "{{ MATOMO_ADMIN_USER_NAME }}" MATOMO_SUPERUSER_PASSWORD: "{{ MATOMO_ADMIN_PASSWORD }}" + MATOMO_SUPERUSER_EMAIL: "{{ MATOMO_ADMIN_EMAIL }}" register: matomo_superuser_realign changed_when: matomo_superuser_realign.stdout is search('\[CHANGED\]') failed_when: matomo_superuser_realign.rc != 0 or matomo_superuser_realign.stdout is search('\[FAIL\]') diff --git a/roles/web-app-mediawiki/README.md b/roles/web-app-mediawiki/README.md index 1582ae65a7..c66de59588 100644 --- a/roles/web-app-mediawiki/README.md +++ b/roles/web-app-mediawiki/README.md @@ -120,7 +120,7 @@ The block sits behind a `file_exists` guard on `extensions/AWS/vendor/autoload.p ## Persona contract opt-outs -This role declares `PERSONA_ADMINISTRATOR_BLOCKED` in `templates/playwright.env.j2`. The wiki's bureaucrat and sysop is a local account created by `maintenance/run.php createAndPromote` in `tasks/04_admin.yml` with `MEDIAWIKI_ADMINISTRATOR_PASSWORD` β€” the role-local `credentials.administrator_password`, not the Keycloak secret the persona helper carries in `ADMIN_PASSWORD`. With `sso` on, `$wgPluggableAuth_EnableLocalLogin` is false, so no native form accepts that password anyway, and `files/shell/realign_admin_password.sh` deliberately sends none while no local password provider is loaded; `$wgOpenIDConnect_UseEmailNameAsUserName` lands the Keycloak identity on a separate, e-mail-named wiki account that holds no sysop rights. The path back is a deploy step that promotes the OIDC-provisioned administrator account to sysop. +This role declares `PERSONA_ADMINISTRATOR_BLOCKED` in `templates/playwright.env.j2`. The wiki's bureaucrat and sysop is a local account created by `maintenance/run.php createAndPromote` in `tasks/04_admin.yml` with `MEDIAWIKI_ADMINISTRATOR_PASSWORD` β€” the role-local `credentials.administrator_password`, not the Keycloak secret the persona helper carries in `ADMIN_PASSWORD`. With `sso` on, `$wgPluggableAuth_EnableLocalLogin` is false, so no native form accepts that password anyway, and `files/shell/realign_admin_password.sh` deliberately sends none while no local password provider is loaded, and on a wiki that lacks the account it creates the account only while local login is on, leaving it to the first SSO login otherwise; OpenIDConnect names the wiki account it creates after Keycloak's `preferred_username` (`$wgOpenIDConnect_UseEmailNameAsUserName` applies only when that claim is missing), and that account holds no sysop rights when SSO created it. The same script promotes that account to sysop and bureaucrat only while local login is off and its email, which PluggableAuth confirms when it creates the account, equals `MEDIAWIKI_ADMINISTRATOR_EMAIL`; otherwise the deploy fails and names the reason (local login on, email differs, email unconfirmed). Biber is blocked by `$wgPluggableAuth_EnableAutoLogin`, which this role sets to true in `vars/main.yml`: every anonymous request is bounced straight back into the identity provider, so the verified unauthenticated landing after in-app logout that the persona contract demands can never be observed here. The path back to the generic personas is either auto-login off, or a deploy step that promotes the OIDC-provisioned administrator account to sysop. diff --git a/roles/web-app-mediawiki/files/shell/realign_admin_password.sh b/roles/web-app-mediawiki/files/shell/realign_admin_password.sh index 08df265519..43c64fc955 100644 --- a/roles/web-app-mediawiki/files/shell/realign_admin_password.sh +++ b/roles/web-app-mediawiki/files/shell/realign_admin_password.sh @@ -5,43 +5,115 @@ # MEDIAWIKI_HTML_DIR wiki root that holds maintenance/run.php # MEDIAWIKI_ADMIN_NAME account to create, promote and realign # MEDIAWIKI_ADMIN_PASSWORD password it must accept afterwards +# MEDIAWIKI_ADMIN_EMAIL email an account created here gets # MEDIAWIKI_LOCAL_LOGIN "true" while a local password provider is loaded +# MEDIAWIKI_ADMIN_STATE_SQL files/sql/admin_state.sql; %(name)s and %(email)s become hex literals +# +# A missing account is created only while local login is on; SSO-only, createAndPromote would +# fail on the password step, so SSO's first login creates it, named after Keycloak's +# preferred_username. # # Exit: 0 with [CHANGED] or [UNCHANGED] on stdout, 1 with [FAIL] on stderr. set -eu -if [ -z "${MEDIAWIKI_HTML_DIR:-}" ] || [ -z "${MEDIAWIKI_ADMIN_NAME:-}" ]; then - echo "[FAIL] MEDIAWIKI_HTML_DIR or MEDIAWIKI_ADMIN_NAME is empty." >&2 +fail() { + echo "[FAIL] $*" >&2 exit 1 +} + +hex() { + printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' +} + +if [ -z "${MEDIAWIKI_HTML_DIR:-}" ] || [ -z "${MEDIAWIKI_ADMIN_NAME:-}" ] || [ -z "${MEDIAWIKI_ADMIN_EMAIL:-}" ]; then + fail "MEDIAWIKI_HTML_DIR, MEDIAWIKI_ADMIN_NAME or MEDIAWIKI_ADMIN_EMAIL is empty." fi +if [ -z "${MEDIAWIKI_ADMIN_STATE_SQL:-}" ]; then + fail "MEDIAWIKI_ADMIN_STATE_SQL is empty." +fi + +run="${MEDIAWIKI_HTML_DIR}/maintenance/run.php" +query="$(printf '%s\n' "$MEDIAWIKI_ADMIN_STATE_SQL" | sed \ + -e "s/%(name)s/X'$(hex "$MEDIAWIKI_ADMIN_NAME")'/g" \ + -e "s/%(email)s/X'$(hex "$MEDIAWIKI_ADMIN_EMAIL")'/g")" + +if ! probe="$(php "$run" sql --query "$query" 2>&1)"; then + fail "the account probe exited non-zero: ${probe}" +fi +state="$(printf '%s\n' "$probe" | sed -n 's/^ *\[state\] => //p')" + +case "$state" in +admin) ;; +absent) + if [ "${MEDIAWIKI_LOCAL_LOGIN:-}" != "true" ]; then + echo "[UNCHANGED] ${MEDIAWIKI_ADMIN_NAME} is provisioned by SSO at first login" + exit 0 + fi + if [ -z "${MEDIAWIKI_ADMIN_PASSWORD:-}" ]; then + fail "MEDIAWIKI_ADMIN_PASSWORD is empty; refusing to create ${MEDIAWIKI_ADMIN_NAME} without it." + fi + if ! out="$(php "$run" createAndPromote --bureaucrat --sysop --email "$MEDIAWIKI_ADMIN_EMAIL" "$MEDIAWIKI_ADMIN_NAME" "$MEDIAWIKI_ADMIN_PASSWORD" 2>&1)"; then + fail "createAndPromote exited non-zero: ${out}" + fi + case "$out" in + *"Creating and promoting User:"*) + echo "[CHANGED] the MediaWiki administrator ${MEDIAWIKI_ADMIN_NAME} was created with the declared password" + exit 0 + ;; + *) fail "createAndPromote did not create ${MEDIAWIKI_ADMIN_NAME}: ${out}" ;; + esac + ;; +not-admin:*) + if [ "${MEDIAWIKI_LOCAL_LOGIN:-}" = "true" ]; then + reason="local login is on" + elif [ "$state" = "not-admin:mismatch" ]; then + reason="its email differs from ${MEDIAWIKI_ADMIN_EMAIL}" + elif [ "$state" = "not-admin:unconfirmed" ]; then + reason="its email ${MEDIAWIKI_ADMIN_EMAIL} is not confirmed" + else + if ! out="$(php "$run" createAndPromote --force --bureaucrat --sysop "$MEDIAWIKI_ADMIN_NAME" 2>&1)"; then + fail "createAndPromote exited non-zero: ${out}" + fi + case "$out" in + *"Promoting User:"*) + echo "[CHANGED] promoted ${MEDIAWIKI_ADMIN_NAME} (SSO account, email matches)" + exit 0 + ;; + *) fail "createAndPromote did not promote ${MEDIAWIKI_ADMIN_NAME}: ${out}" ;; + esac + fi + fail "the MediaWiki account ${MEDIAWIKI_ADMIN_NAME} is no sysop and is not promoted (${reason}); add it to the sysop group or rename it, then redeploy" + ;; +no-admin) fail "MediaWiki has no sysop, so its install is unfinished; finish or reset that install instead of adding ${MEDIAWIKI_ADMIN_NAME} to it" ;; +email-held:*) fail "the email ${MEDIAWIKI_ADMIN_EMAIL} is held by the MediaWiki account ${state#email-held:}; change that account's email, then redeploy" ;; +*) fail "the account probe returned no state: ${probe}" ;; +esac + set -- --force --bureaucrat --sysop "$MEDIAWIKI_ADMIN_NAME" if [ "${MEDIAWIKI_LOCAL_LOGIN:-}" = "true" ]; then if [ -z "${MEDIAWIKI_ADMIN_PASSWORD:-}" ]; then - echo "[FAIL] MEDIAWIKI_ADMIN_PASSWORD is empty; refusing to set it." >&2 - exit 1 + fail "MEDIAWIKI_ADMIN_PASSWORD is empty; refusing to set it." fi set -- "$@" "$MEDIAWIKI_ADMIN_PASSWORD" fi -if ! out="$(php "${MEDIAWIKI_HTML_DIR}/maintenance/run.php" createAndPromote "$@" 2>&1)"; then - echo "[FAIL] createAndPromote exited non-zero: ${out}" >&2 - exit 1 +if ! out="$(php "$run" createAndPromote "$@" 2>&1)"; then + fail "createAndPromote exited non-zero: ${out}" fi case "$out" in *"Password set."*) echo "[CHANGED] the MediaWiki administrator accepts the declared password" ;; -*"Creating and promoting User:"* | *"Promoting User:"*) - echo "[CHANGED] the MediaWiki administrator was created or promoted" +*"Promoting User:"*) + echo "[CHANGED] the MediaWiki administrator was promoted" ;; *"Account exists and nothing to do."*) echo "[UNCHANGED] the MediaWiki administrator is already in place" ;; *) - echo "[FAIL] createAndPromote neither created nor realigned: ${out}" >&2 - exit 1 + fail "createAndPromote neither created nor realigned: ${out}" ;; esac diff --git a/roles/web-app-mediawiki/files/sql/.sqlfluff b/roles/web-app-mediawiki/files/sql/.sqlfluff new file mode 100644 index 0000000000..5b702f70c2 --- /dev/null +++ b/roles/web-app-mediawiki/files/sql/.sqlfluff @@ -0,0 +1,6 @@ +[sqlfluff] +dialect = mysql +templater = placeholder + +[sqlfluff:templater:placeholder] +param_style = pyformat diff --git a/roles/web-app-mediawiki/files/sql/admin_state.sql b/roles/web-app-mediawiki/files/sql/admin_state.sql new file mode 100644 index 0000000000..84dea93601 --- /dev/null +++ b/roles/web-app-mediawiki/files/sql/admin_state.sql @@ -0,0 +1,44 @@ +SELECT CASE + WHEN s.id IS NOT NULL AND s.sysop > 0 THEN 'admin' + WHEN s.id IS NOT NULL AND s.email_match AND s.confirmed THEN 'not-admin:match' + WHEN s.id IS NOT NULL AND s.email_match THEN 'not-admin:unconfirmed' + WHEN s.id IS NOT NULL THEN 'not-admin:mismatch' + WHEN ( + SELECT COUNT(*) FROM user_groups + WHERE ug_group = 'sysop' AND (ug_expiry IS NULL OR ug_expiry > DATE_FORMAT(UTC_TIMESTAMP(), '%Y%m%d%H%i%s')) + ) = 0 THEN 'no-admin' + WHEN h.holders IS NOT NULL THEN CONCAT('email-held:', h.holders) + ELSE 'absent' +END AS state +FROM (SELECT 1) AS one +LEFT JOIN ( + SELECT + user_id AS id, + ( + SELECT COUNT(*) FROM user_groups + WHERE + ug_user = user_id + AND ug_group = 'sysop' AND (ug_expiry IS NULL OR ug_expiry > DATE_FORMAT(UTC_TIMESTAMP(), '%Y%m%d%H%i%s')) + ) AS sysop, + LOWER(CONVERT(user_email USING utf8mb4)) = LOWER(CONVERT(%(email)s USING utf8mb4)) AS email_match, + user_email_authenticated IS NOT NULL AS confirmed + FROM user + WHERE user_name = CAST( + CONCAT( + UPPER(LEFT(REPLACE(CONVERT(%(name)s USING utf8mb4), '_', ' '), 1)), + SUBSTRING(REPLACE(CONVERT(%(name)s USING utf8mb4), '_', ' '), 2) + ) AS BINARY + ) +) AS s ON TRUE +LEFT JOIN ( + SELECT GROUP_CONCAT(CONVERT(user_name USING utf8mb4) SEPARATOR ', ') AS holders + FROM user + WHERE + LOWER(CONVERT(user_email USING utf8mb4)) = LOWER(CONVERT(%(email)s USING utf8mb4)) + AND user_name <> CAST( + CONCAT( + UPPER(LEFT(REPLACE(CONVERT(%(name)s USING utf8mb4), '_', ' '), 1)), + SUBSTRING(REPLACE(CONVERT(%(name)s USING utf8mb4), '_', ' '), 2) + ) AS BINARY + ) +) AS h ON TRUE diff --git a/roles/web-app-mediawiki/tasks/04_admin.yml b/roles/web-app-mediawiki/tasks/04_admin.yml index 51400f7b77..cfe640c534 100644 --- a/roles/web-app-mediawiki/tasks/04_admin.yml +++ b/roles/web-app-mediawiki/tasks/04_admin.yml @@ -1,5 +1,5 @@ --- -- name: "πŸ”‘ Realign the MediaWiki administrator account and its password" +- name: "πŸ”‘ Ensure the MediaWiki administrator account exists and accepts its password" ansible.builtin.command: cmd: >- container run --rm -i @@ -10,7 +10,7 @@ -v {{ MEDIAWIKI_LOCALSETTINGS_HOST_PATH }}:{{ MEDIAWIKI_LOCALSETTINGS_CONTAINER_PATH }}:ro -v {{ MEDIAWIKI_LOCAL_MOUNT_DIR }}:{{ MEDIAWIKI_LOCAL_PATH }}:ro -e MEDIAWIKI_HTML_DIR -e MEDIAWIKI_ADMIN_NAME -e MEDIAWIKI_ADMIN_PASSWORD - -e MEDIAWIKI_LOCAL_LOGIN + -e MEDIAWIKI_ADMIN_EMAIL -e MEDIAWIKI_LOCAL_LOGIN -e MEDIAWIKI_ADMIN_STATE_SQL {{ SWARM_REGISTRY_PREFIX }}{{ MEDIAWIKI_CUSTOM_IMAGE }}:{{ MEDIAWIKI_VERSION }} sh -s stdin: "{{ lookup('file', 'shell/realign_admin_password.sh') }}" @@ -18,7 +18,9 @@ MEDIAWIKI_HTML_DIR: "{{ MEDIAWIKI_HTML_DIR }}" MEDIAWIKI_ADMIN_NAME: "{{ MEDIAWIKI_ADMINISTRATOR_NAME }}" MEDIAWIKI_ADMIN_PASSWORD: "{{ MEDIAWIKI_ADMINISTRATOR_PASSWORD }}" + MEDIAWIKI_ADMIN_EMAIL: "{{ MEDIAWIKI_ADMINISTRATOR_EMAIL }}" MEDIAWIKI_LOCAL_LOGIN: "{{ MEDIAWIKI_LOCAL_PASSWORD_LOGIN | string | lower }}" + MEDIAWIKI_ADMIN_STATE_SQL: "{{ lookup('file', 'sql/admin_state.sql') }}" register: mediawiki_admin_realign changed_when: mediawiki_admin_realign.stdout is search('\[CHANGED\]') failed_when: >- diff --git a/roles/web-app-mediawiki/templates/playwright.env.j2 b/roles/web-app-mediawiki/templates/playwright.env.j2 index a86a6ced69..8bf534e9c0 100644 --- a/roles/web-app-mediawiki/templates/playwright.env.j2 +++ b/roles/web-app-mediawiki/templates/playwright.env.j2 @@ -41,7 +41,7 @@ PEERTUBE_BASE_URL={{ (lookup('landing_url', 'web-app-peertube') if (lookup('conf PEERTUBE_SERVICE_ENABLED={{ lookup('config', application_id, 'services.peertube.enabled') | string | lower }} -# nocheck: persona-block-rationale β€” the wiki's bureaucrat/sysop is a local account created by maintenance/run.php createAndPromote (vars/main.yml MEDIAWIKI_ADMINISTRATOR_PASSWORD, tasks/04_admin.yml); with sso on $wgPluggableAuth_EnableLocalLogin is false so no native form accepts it, and $wgOpenIDConnect_UseEmailNameAsUserName lands the Keycloak identity on a separate e-mail-named wiki account with no sysop rights (see README "Persona contract opt-outs"). +# nocheck: persona-block-rationale β€” the wiki's bureaucrat/sysop is a local account created by maintenance/run.php createAndPromote (vars/main.yml MEDIAWIKI_ADMINISTRATOR_PASSWORD, tasks/04_admin.yml); with sso on $wgPluggableAuth_EnableLocalLogin is false so no native form accepts it, and the Keycloak identity lands on the wiki account OpenIDConnect names after preferred_username, which holds no sysop rights until tasks/04_admin.yml promotes it (see README "Persona contract opt-outs"). PERSONA_ADMINISTRATOR_BLOCKED=true # nocheck: persona-block-rationale β€” $wgPluggableAuth_EnableAutoLogin is true (vars/main.yml, rendered by templates/oidc.php.j2), so every anonymous request is bounced straight back into the IdP; the verified unauthenticated landing after in-app logout that the persona contract requires can never be observed on this role (see README "Persona contract opt-outs"). diff --git a/roles/web-app-mediawiki/vars/main.yml b/roles/web-app-mediawiki/vars/main.yml index ef4c4d9d2c..ff9a731c6f 100644 --- a/roles/web-app-mediawiki/vars/main.yml +++ b/roles/web-app-mediawiki/vars/main.yml @@ -33,6 +33,7 @@ MEDIAWIKI_NETWORK_OBJSTORE: "{{ lookup('objstore', application_id, 'network') }} MEDIAWIKI_ADMINISTRATOR_NAME: "{{ lookup('users', 'platform_administrator').username }}" MEDIAWIKI_ADMINISTRATOR_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}" +MEDIAWIKI_ADMINISTRATOR_EMAIL: "{{ lookup('users', 'platform_administrator').email }}" MEDIAWIKI_SECRET_KEY: "{{ lookup('config', application_id, 'secrets.credentials.secret_key') }}" MEDIAWIKI_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}" diff --git a/roles/web-app-suitecrm/files/php/realign_admin_password.php b/roles/web-app-suitecrm/files/php/realign_admin_password.php index 1d925d6e69..974d697765 100644 --- a/roles/web-app-suitecrm/files/php/realign_admin_password.php +++ b/roles/web-app-suitecrm/files/php/realign_admin_password.php @@ -1,8 +1,6 @@ ` so it never reaches the host-side argv: - * SUITECRM_REALIGN_USERNAME administrator login to realign + * SUITECRM_REALIGN_USERNAME administrator login to create or realign * SUITECRM_REALIGN_PASSWORD password that login must accept afterwards + * SUITECRM_REALIGN_EMAIL primary email a created administrator gets * * Exit: 0 with [CHANGED] or [UNCHANGED] on stdout, 1 with [FAIL] on stderr. + * + * php reads the script from stdin, so no path tells a direct run from a test's require: + * main() runs only when debug_backtrace() finds no including frame. For the same reason + * the CLI defines no STDOUT/STDERR constants (php_cli.c registers them only for a script + * file), so output goes through php://stdout and php://stderr. */ declare(strict_types=1); +namespace Infinito\SuiteCrm\Administrator; + +use PDO; +use RuntimeException; +use Throwable; + +interface Users +{ + /** + * @return list every undeleted account with the login; user_name is not unique + */ + public function find(string $login): array; + + public function administrators(): int; + + /** + * @return list logins other than $login of undeleted users linked to $email, case-insensitively + */ + public function holdersOf(string $email, string $login): array; + + public function insertAdministrator(string $login, string $hash, string $email): void; + + /** + * @return list addresses linked to the user, empty for an LDAP-created account + */ + public function emailsOf(string $id): array; + + public function updateHash(string $id, string $hash): void; +} + +final class PdoUsers implements Users +{ + public function __construct(private PDO $pdo) + { + } + + public function find(string $login): array + { + $read = $this->pdo->prepare('SELECT id, user_hash, is_admin FROM users WHERE user_name = ? AND deleted = 0'); + $read->execute([$login]); + + return array_map( + static fn (array $row): array => ['id' => (string) $row['id'], 'hash' => (string) $row['user_hash'], 'admin' => (int) $row['is_admin'] === 1], + $read->fetchAll(PDO::FETCH_ASSOC) + ); + } + + public function administrators(): int + { + return (int) $this->pdo->query('SELECT COUNT(*) FROM users WHERE is_admin = 1 AND deleted = 0')->fetchColumn(); + } + + public function holdersOf(string $email, string $login): array + { + $read = $this->pdo->prepare( + 'SELECT DISTINCT u.user_name FROM users u' + . " JOIN email_addr_bean_rel r ON r.bean_id = u.id AND r.bean_module = 'Users' AND r.deleted = 0" + . ' JOIN email_addresses e ON e.id = r.email_address_id AND e.deleted = 0' + . ' WHERE u.deleted = 0 AND e.email_address_caps = ? AND u.user_name <> ?' + ); + $read->execute([strtoupper($email), $login]); + + return array_map('strval', $read->fetchAll(PDO::FETCH_COLUMN)); + } + + public function insertAdministrator(string $login, string $hash, string $email): void + { + $userId = guid(); + $this->pdo->beginTransaction(); + try { + $this->pdo->prepare( + 'INSERT INTO users (id, user_name, user_hash, system_generated_password, pwd_last_changed, sugar_login,' + . ' first_name, last_name, is_admin, external_auth_only, receive_notifications, date_entered,' + . ' date_modified, modified_user_id, created_by, status, deleted, portal_only, show_on_employees,' + . ' employee_status, is_group, factor_auth)' + . " VALUES (?, ?, ?, 0, UTC_TIMESTAMP(), 1, '', ?, 1, 0, 1, UTC_TIMESTAMP(), UTC_TIMESTAMP(), ?, ?," + . " 'Active', 0, 0, 1, 'Active', 0, 0)" + )->execute([$userId, $login, $hash, $login, $userId, $userId]); + + $find = $this->pdo->prepare('SELECT id FROM email_addresses WHERE email_address_caps = ? AND deleted = 0 LIMIT 1'); + $find->execute([strtoupper($email)]); + $addressId = $find->fetchColumn(); + if ($addressId === false) { + $addressId = guid(); + $this->pdo->prepare( + 'INSERT INTO email_addresses (id, email_address, email_address_caps, invalid_email, opt_out,' + . " confirm_opt_in, date_created, date_modified, deleted) VALUES (?, ?, ?, 0, 0, 'not-opt-in'," + . ' UTC_TIMESTAMP(), UTC_TIMESTAMP(), 0)' + )->execute([$addressId, $email, strtoupper($email)]); + } + + $this->pdo->prepare( + 'INSERT INTO email_addr_bean_rel (id, email_address_id, bean_id, bean_module, primary_address,' + . " reply_to_address, date_created, date_modified, deleted) VALUES (?, ?, ?, 'Users', 1, 0," + . ' UTC_TIMESTAMP(), UTC_TIMESTAMP(), 0)' + )->execute([guid(), (string) $addressId, $userId]); + + $this->pdo->commit(); + } catch (Throwable $error) { + $this->pdo->rollBack(); + throw $error; + } + } + + public function updateHash(string $id, string $hash): void + { + $write = $this->pdo->prepare('UPDATE users SET user_hash = ?, pwd_last_changed = UTC_TIMESTAMP() WHERE id = ?'); + $write->execute([$hash, $id]); + if ($write->rowCount() !== 1) { + throw new RuntimeException(sprintf('the realign changed %d rows, expected exactly one', $write->rowCount())); + } + } + + public function emailsOf(string $id): array + { + $read = $this->pdo->prepare( + 'SELECT e.email_address FROM email_addr_bean_rel r' + . ' JOIN email_addresses e ON e.id = r.email_address_id AND e.deleted = 0' + . " WHERE r.bean_id = ? AND r.bean_module = 'Users' AND r.deleted = 0" + ); + $read->execute([$id]); + + return array_map('strval', $read->fetchAll(PDO::FETCH_COLUMN)); + } +} + +function guid(): string +{ + $hex = bin2hex(random_bytes(16)); + + return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20)); +} + +/** + * @return string why the non-administrator holding the login says nothing about who owns it + */ +function emailDetail(Users $users, string $id, string $email): string +{ + $emails = $users->emailsOf($id); + if ($emails === []) { + return 'it carries no email address, like every account the LDAP login creates'; + } + if (!in_array(strtoupper($email), array_map('strtoupper', $emails), true)) { + return sprintf("its email %s differs from '%s'", implode(', ', $emails), $email); + } + + return 'every SuiteCRM user may edit their own name and email, so a matching email proves nothing'; +} + +/** + * @return array{0: int, 1: string} exit code and the [CHANGED], [UNCHANGED] or [FAIL] line + */ +function ensure(Users $users, string $login, string $password, string $email): array +{ + $digest = strtolower(md5($password)); + $accounts = $users->find($login); + + if (count($accounts) > 1) { + return [1, sprintf("[FAIL] %d undeleted SuiteCRM users carry the login '%s'; delete or rename all but one, then redeploy", count($accounts), $login)]; + } + + $account = $accounts[0] ?? null; + if ($account === null) { + if ($users->administrators() === 0) { + return [1, "[FAIL] SuiteCRM has no administrator, so its install is unfinished; finish or reset that install instead of adding '$login' to it"]; + } + $holders = $users->holdersOf($email, $login); + if ($holders !== []) { + return [1, sprintf("[FAIL] the email '%s' is held by the SuiteCRM user %s; change that user's email, then redeploy", $email, implode(', ', $holders))]; + } + $users->insertAdministrator($login, password_hash($digest, PASSWORD_DEFAULT), $email); + + return [0, "[CHANGED] the SuiteCRM administrator '$login' was created with the declared password"]; + } + + if (!$account['admin']) { + return [1, sprintf( + "[FAIL] the SuiteCRM user '%s' is no administrator and is not promoted (%s); make it an administrator in SuiteCRM or rename it, then redeploy", + $login, + emailDetail($users, $account['id'], $email) + )]; + } + + $stored = $account['hash']; + $legacy = strlen($stored) === 32 && $stored[0] !== '$'; + if ($stored !== '' && ($legacy ? $stored === $digest : password_verify($digest, $stored))) { + return [0, '[UNCHANGED] the SuiteCRM administrator already accepts the declared password']; + } + + $users->updateHash($account['id'], password_hash($digest, PASSWORD_DEFAULT)); + + return [0, '[CHANGED] the SuiteCRM administrator was realigned with the declared password']; +} + function required(string $name): string { $value = getenv($name); if ($value === false || $value === '') { - fwrite(STDERR, "[FAIL] $name is empty\n"); - exit(1); + throw new RuntimeException("$name is empty"); } return $value; } -$username = required('SUITECRM_REALIGN_USERNAME'); -$password = required('SUITECRM_REALIGN_PASSWORD'); -$dsn = sprintf( - 'mysql:host=%s;port=%s;dbname=%s', - required('SUITECRM_DB_HOST'), - required('SUITECRM_DB_PORT'), - required('SUITECRM_DB_NAME') -); -$digest = strtolower(md5($password)); +function main(): int +{ + try { + $pdo = new PDO( + sprintf('mysql:host=%s;port=%s;dbname=%s', required('SUITECRM_DB_HOST'), required('SUITECRM_DB_PORT'), required('SUITECRM_DB_NAME')), + required('SUITECRM_DB_USER'), + required('SUITECRM_DB_PASSWORD'), + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); -try { - $pdo = new PDO( - $dsn, - required('SUITECRM_DB_USER'), - required('SUITECRM_DB_PASSWORD'), - [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] - ); - - $read = $pdo->prepare('SELECT id, user_hash FROM users WHERE user_name = ? AND deleted = 0'); - $read->execute([$username]); - $row = $read->fetch(PDO::FETCH_ASSOC); - - if ($row === false) { - fwrite(STDERR, "[FAIL] no SuiteCRM user named '$username'\n"); - exit(1); + [$code, $line] = ensure( + new PdoUsers($pdo), + required('SUITECRM_REALIGN_USERNAME'), + required('SUITECRM_REALIGN_PASSWORD'), + required('SUITECRM_REALIGN_EMAIL') + ); + } catch (Throwable $error) { + [$code, $line] = [1, '[FAIL] ' . $error->getMessage()]; } - $stored = (string) $row['user_hash']; - $legacy = strlen($stored) === 32 && $stored[0] !== '$'; - if ($stored !== '' && ($legacy ? $stored === $digest : password_verify($digest, $stored))) { - echo "[UNCHANGED] the SuiteCRM administrator already accepts the declared password\n"; - exit(0); - } + file_put_contents($code === 0 ? 'php://stdout' : 'php://stderr', $line . "\n"); - $write = $pdo->prepare( - 'UPDATE users SET user_hash = ?, pwd_last_changed = UTC_TIMESTAMP() WHERE id = ?' - ); - $write->execute([password_hash($digest, PASSWORD_DEFAULT), $row['id']]); - - if ($write->rowCount() !== 1) { - fwrite(STDERR, sprintf("[FAIL] the realign changed %d rows, expected exactly one\n", $write->rowCount())); - exit(1); - } -} catch (Throwable $error) { - fwrite(STDERR, '[FAIL] ' . $error->getMessage() . "\n"); - exit(1); + return $code; } -echo "[CHANGED] the SuiteCRM administrator was realigned with the declared password\n"; +if (!debug_backtrace()) { + exit(main()); +} diff --git a/roles/web-app-suitecrm/tasks/01_manager_ops.yml b/roles/web-app-suitecrm/tasks/01_manager_ops.yml index edf32beea8..b883e96940 100644 --- a/roles/web-app-suitecrm/tasks/01_manager_ops.yml +++ b/roles/web-app-suitecrm/tasks/01_manager_ops.yml @@ -57,16 +57,17 @@ retries: 300 delay: 5 -- name: "πŸ”‘ Realign the SuiteCRM administrator password with the declared one" +- name: "πŸ”‘ Ensure the SuiteCRM administrator exists and accepts the declared password" ansible.builtin.shell: cmd: >- container exec -i --user {{ SUITECRM_WEB_USER }} - -e SUITECRM_REALIGN_USERNAME -e SUITECRM_REALIGN_PASSWORD + -e SUITECRM_REALIGN_USERNAME -e SUITECRM_REALIGN_PASSWORD -e SUITECRM_REALIGN_EMAIL {{ SUITECRM_CONTAINER_ADDRESS }} php stdin: "{{ lookup('file', 'php/realign_admin_password.php') }}" environment: SUITECRM_REALIGN_USERNAME: "{{ SUITECRM_ADMIN_USERNAME }}" SUITECRM_REALIGN_PASSWORD: "{{ SUITECRM_ADMIN_PASSWORD }}" + SUITECRM_REALIGN_EMAIL: "{{ SUITECRM_ADMIN_EMAIL }}" register: suitecrm_admin_password_realign changed_when: suitecrm_admin_password_realign.stdout is search('\[CHANGED\]') failed_when: >- diff --git a/roles/web-app-suitecrm/templates/env.j2 b/roles/web-app-suitecrm/templates/env.j2 index 1bcd038098..17913a82c5 100644 --- a/roles/web-app-suitecrm/templates/env.j2 +++ b/roles/web-app-suitecrm/templates/env.j2 @@ -17,7 +17,7 @@ SUITECRM_DB_PASSWORD={{ lookup('database', application_id, 'password') | dotenv_ SUITECRM_ADMIN_USERNAME={{ SUITECRM_ADMIN_USERNAME }} SUITECRM_ADMIN_PASSWORD={{ SUITECRM_ADMIN_PASSWORD | dotenv_quote }} -SUITECRM_ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email }} +SUITECRM_ADMIN_EMAIL={{ SUITECRM_ADMIN_EMAIL }} SUITECRM_URL={{ SUITECRM_URL }} diff --git a/roles/web-app-suitecrm/vars/main.yml b/roles/web-app-suitecrm/vars/main.yml index cbf0633aba..587b2bbe0f 100644 --- a/roles/web-app-suitecrm/vars/main.yml +++ b/roles/web-app-suitecrm/vars/main.yml @@ -7,6 +7,7 @@ swarm_stack_converge_retries: 600 SUITECRM_ADMIN_USERNAME: "{{ lookup('users', 'platform_administrator').username }}" SUITECRM_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}" +SUITECRM_ADMIN_EMAIL: "{{ lookup('users', 'platform_administrator').email }}" SUITECRM_WELL_SOFTWARE_NAME: "{{ SOFTWARE_DOMAIN | replace('.', '-') }}" SUITECRM_PHP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'php.ini'] | path_join }}" diff --git a/roles/web-app-wordpress/files/php/realign_admin_password.php b/roles/web-app-wordpress/files/php/realign_admin_password.php index 9d57047b7e..1010df5210 100644 --- a/roles/web-app-wordpress/files/php/realign_admin_password.php +++ b/roles/web-app-wordpress/files/php/realign_admin_password.php @@ -1,29 +1,73 @@ $login, 'WP_REALIGN_PASSWORD' => $password, 'WP_REALIGN_EMAIL' => $email] as $name => $value) { + if (!is_string($value) || $value === '') { + file_put_contents('php://stderr', "[FAIL] $name is empty\n"); + exit(1); + } } +$is_administrator = static function ($account): bool { + return in_array('administrator', (array) $account->roles, true) + || (is_multisite() && is_super_admin($account->ID)); +}; + $user = get_user_by('login', $login); + if (!$user) { - fwrite(STDERR, "[FAIL] no WordPress user named '$login'\n"); + $administrators = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 1]); + if ($administrators === [] && (!is_multisite() || get_super_admins() === [])) { + file_put_contents('php://stderr', "[FAIL] WordPress has no administrator, so its install is unfinished; finish or reset that install instead of adding '$login' to it\n"); + exit(1); + } + + $holder = get_user_by('email', $email); + if ($holder) { + file_put_contents('php://stderr', "[FAIL] the email '$email' is held by the WordPress user {$holder->user_login}; change that user's email, then redeploy\n"); + exit(1); + } + + $id = wp_insert_user([ + 'user_login' => $login, + 'user_email' => $email, + 'user_pass' => $password, + 'role' => 'administrator', + ]); + if (is_wp_error($id)) { + file_put_contents('php://stderr', "[FAIL] WordPress refused to create '$login': " . $id->get_error_message() . "\n"); + exit(1); + } + if (is_multisite()) { + grant_super_admin($id); + } + + echo "[CHANGED] the WordPress administrator '$login' was created with the declared password\n"; + exit(0); +} + +if (!$is_administrator($user)) { + file_put_contents('php://stderr', "[FAIL] the WordPress user '$login' is no administrator and is not promoted; give it the administrator role or rename it, then redeploy\n"); exit(1); } diff --git a/roles/web-app-wordpress/tasks/02_install.yml b/roles/web-app-wordpress/tasks/02_install.yml index b589a0d987..eb23623e01 100644 --- a/roles/web-app-wordpress/tasks/02_install.yml +++ b/roles/web-app-wordpress/tasks/02_install.yml @@ -33,17 +33,18 @@ environment: WP_ADMIN_PASSWORD: "{{ WORDPRESS_ADMIN_PASSWORD }}" -- name: "πŸ”‘ Realign the WordPress administrator with the declared password" +- name: "πŸ”‘ Ensure the WordPress administrator exists and accepts the declared password" ansible.builtin.shell: cmd: >- container exec -i -u {{ WORDPRESS_USER }} - -e WP_REALIGN_LOGIN -e WP_REALIGN_PASSWORD + -e WP_REALIGN_LOGIN -e WP_REALIGN_PASSWORD -e WP_REALIGN_EMAIL {{ WORDPRESS_CONTAINER_ADDRESS }} wp eval-file - --path={{ WORDPRESS_DOCKER_HTML_PATH | quote }} stdin: "{{ lookup('file', 'php/realign_admin_password.php') }}" environment: WP_REALIGN_LOGIN: "{{ WORDPRESS_ADMIN_USER }}" WP_REALIGN_PASSWORD: "{{ WORDPRESS_ADMIN_PASSWORD }}" + WP_REALIGN_EMAIL: "{{ WORDPRESS_ADMIN_EMAIL }}" register: wordpress_admin_realign changed_when: wordpress_admin_realign.stdout is search('\[CHANGED\]') failed_when: >- diff --git a/tests/lint/ansible/roles/test_admin_step_creates_missing_admin.py b/tests/lint/ansible/roles/test_admin_step_creates_missing_admin.py new file mode 100644 index 0000000000..271073f0ec --- /dev/null +++ b/tests/lint/ansible/roles/test_admin_step_creates_missing_admin.py @@ -0,0 +1,98 @@ +"""Lint guard: the administrator step of the listed roles MUST create a +missing administrator, not only realign an existing one. + +An app installed before its administrator login was introduced has no such +account. A step that only realigns then ends in "no ... user named" or +"user ... does not exist" on every deploy of that install, while a fresh +install passes, so no test round catches it. + +Each entry names the file the role's administrator step runs and the call +that creates the account. A rewrite that drops the create path fails the +first test. The second test flags a further script in these roles that can +end in such a failure, so a new realign step lands with its create path or +in this list. + +The list is explicit rather than a scan of every role: ``web-app-gitlab`` +realigns ``root``, which every GitLab install has, and would be flagged by +the same failure text without being wrong. +""" + +from __future__ import annotations + +import re +import unittest + +from utils.cache.files import read_text + +from . import PROJECT_ROOT + +_ROLES_DIR = PROJECT_ROOT / "roles" + +ADMIN_STEPS = { + "web-app-joomla": ("tasks/07_reset_admin_password.yml", "user:add"), + "web-app-listmonk": ("files/sql/realign_admin_credential.sql", "INSERT INTO users"), + "web-app-matomo": ("files/php/converge_superuser_password.php", "insertSuperuser("), + "web-app-mediawiki": ( + "files/shell/realign_admin_password.sh", + "createAndPromote --bureaucrat --sysop --email", + ), + "web-app-suitecrm": ( + "files/php/realign_admin_password.php", + "insertAdministrator(", + ), + "web-app-wordpress": ("files/php/realign_admin_password.php", "wp_insert_user("), +} + +_ABSENT_ACCOUNT = re.compile( + r"no [\w ]*?(?:user|account|administrator|superuser) named|user [^\n]*does not exist", + re.IGNORECASE, +) + + +def _role_scripts(role: str): + for tree in ("files", "tasks"): + for path in sorted((_ROLES_DIR / role / tree).rglob("*")): + if path.is_file() and "playwright" not in path.parts: + yield path + + +class TestAdminStepCreatesMissingAdmin(unittest.TestCase): + def test_every_admin_step_keeps_its_create_path(self) -> None: + offenders = [] + for role, (step, marker) in sorted(ADMIN_STEPS.items()): + path = _ROLES_DIR / role / step + if not path.is_file(): + offenders.append( + f"{role}: {step} is gone; point the entry at the new step" + ) + elif marker not in read_text(str(path)): + offenders.append(f"{role}: {step} no longer calls {marker!r}") + self.assertFalse( + offenders, + "these administrator steps only realign an existing account, so an " + "install without it fails on every deploy; create it next to the " + "existing administrators:\n " + "\n ".join(offenders), + ) + + def test_no_other_script_of_these_roles_ends_on_an_absent_account(self) -> None: + offenders = [] + for role, (step, _) in sorted(ADMIN_STEPS.items()): + for path in _role_scripts(role): + relative = path.relative_to(_ROLES_DIR / role).as_posix() + if relative == step: + continue + try: + text = read_text(str(path)) + except UnicodeDecodeError: + continue + if _ABSENT_ACCOUNT.search(text): + offenders.append(f"{role}/{relative}") + self.assertFalse( + offenders, + "these scripts can end on an absent account; give them a create " + "path and list them in ADMIN_STEPS:\n " + "\n ".join(offenders), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/php/roles/web-app-matomo/files/ConvergeSuperuserPasswordTest.php b/tests/unit/php/roles/web-app-matomo/files/ConvergeSuperuserPasswordTest.php new file mode 100644 index 0000000000..b5b77a2d4e --- /dev/null +++ b/tests/unit/php/roles/web-app-matomo/files/ConvergeSuperuserPasswordTest.php @@ -0,0 +1,209 @@ + $rows user table keyed by login + */ + public function __construct(public array $rows) + { + } + + public function find(string $login): ?array + { + $row = $this->rows[$login] ?? null; + + return $row === null ? null : ['password' => $row['password'], 'superuser' => $row['superuser']]; + } + + public function superusers(): int + { + return count(array_filter($this->rows, static fn (array $row): bool => $row['superuser'])); + } + + public function holdersOf(string $email, string $login): array + { + $holders = []; + foreach ($this->rows as $holder => $row) { + if ($holder !== $login && strtolower($row['email']) === strtolower($email)) { + $holders[] = $holder; + } + } + + return $holders; + } + + public function insertSuperuser(string $login, string $hash, string $email): void + { + $this->inserted[] = ['login' => $login, 'email' => $email]; + $this->rows[$login] = ['password' => $hash, 'email' => $email, 'superuser' => true]; + } + + public function updatePassword(string $login, string $hash): void + { + $this->updated[] = $login; + $this->rows[$login]['password'] = $hash; + } +} + +final class ConvergeSuperuserPasswordTest extends TestCase +{ + private const LOGIN = 'platformadmin'; + + private const PASSWORD = 'the-declared-password'; + + private const EMAIL = 'platformadmin@example.org'; + + /** + * @return array{password: string, email: string, superuser: bool} + */ + private static function row(string $password, string $email, bool $superuser): array + { + return ['password' => password_hash(md5($password), PASSWORD_DEFAULT), 'email' => $email, 'superuser' => $superuser]; + } + + /** + * @return array{0: int, 1: string, 2: string} exit code, stdout and stderr of `php` fed the script on stdin + */ + private static function pipeIntoPhp(): array + { + $process = proc_open( + [PHP_BINARY], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + [] + ); + fwrite($pipes[0], (string) file_get_contents(dirname(__DIR__, 6) . '/roles/web-app-matomo/files/php/converge_superuser_password.php')); + fclose($pipes[0]); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $stdout, $stderr]; + } + + private static function legacyAdministrator(): array + { + return self::row('host-password', 'administrator@example.org', true); + } + + public function testTheScriptPipedIntoPhpLikeTheRoleReportsAnEmptyEnvironmentAsFail(): void + { + [$code, $stdout, $stderr] = self::pipeIntoPhp(); + + self::assertSame(1, $code, $stdout . $stderr); + self::assertSame('', $stdout); + self::assertStringContainsString("[FAIL] MATOMO_CONFIG is empty\n", $stderr); + self::assertStringNotContainsString('Undefined constant', $stderr); + } + + public function testAnAbsentLoginIsCreatedAsSuperuserNextToTheExistingOne(): void + { + $users = new MatomoFakeUsers(['administrator' => self::legacyAdministrator()]); + $administrator = $users->rows['administrator']; + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[CHANGED]', $line); + self::assertSame([['login' => self::LOGIN, 'email' => self::EMAIL]], $users->inserted); + self::assertTrue(password_verify(md5(self::PASSWORD), $users->rows[self::LOGIN]['password'])); + self::assertSame($administrator, $users->rows['administrator']); + } + + public function testASecondRunAfterTheCreateChangesNothing(): void + { + $users = new MatomoFakeUsers(['administrator' => self::legacyAdministrator()]); + ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[UNCHANGED]', $line); + self::assertCount(1, $users->inserted); + self::assertSame([], $users->updated); + } + + public function testAPresentSuperuserWithTheDeclaredPasswordIsLeftAlone(): void + { + $users = new MatomoFakeUsers([self::LOGIN => self::row(self::PASSWORD, self::EMAIL, true)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[UNCHANGED]', $line); + self::assertSame([], $users->inserted); + self::assertSame([], $users->updated); + } + + public function testAPresentSuperuserWithAStalePasswordIsRealigned(): void + { + $users = new MatomoFakeUsers([self::LOGIN => self::row('stale', self::EMAIL, true)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[CHANGED]', $line); + self::assertSame([self::LOGIN], $users->updated); + self::assertTrue(password_verify(md5(self::PASSWORD), $users->rows[self::LOGIN]['password'])); + } + + public function testAnInstallWithoutAnySuperuserFailsInsteadOfCreating(): void + { + $users = new MatomoFakeUsers(['viewer' => self::row('x', 'viewer@example.org', false)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('no superuser', $line); + self::assertSame([], $users->inserted); + } + + public function testAnEmailHeldByAnotherAccountFailsNamingTheHolder(): void + { + $users = new MatomoFakeUsers([ + 'administrator' => self::legacyAdministrator(), + 'someone' => self::row('x', 'PlatformAdmin@Example.org', false), + ]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('someone', $line); + self::assertSame([], $users->inserted); + } + + public function testANonSuperuserHoldingTheLoginIsNotPromoted(): void + { + $users = new MatomoFakeUsers([ + 'administrator' => self::legacyAdministrator(), + self::LOGIN => self::row('stale', self::EMAIL, false), + ]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('not promoted', $line); + self::assertFalse($users->rows[self::LOGIN]['superuser']); + self::assertSame([], $users->inserted); + self::assertSame([], $users->updated); + } +} diff --git a/tests/unit/php/roles/web-app-suitecrm/files/RealignAdminPasswordTest.php b/tests/unit/php/roles/web-app-suitecrm/files/RealignAdminPasswordTest.php new file mode 100644 index 0000000000..9a741d4113 --- /dev/null +++ b/tests/unit/php/roles/web-app-suitecrm/files/RealignAdminPasswordTest.php @@ -0,0 +1,291 @@ +, admin: bool}> $rows undeleted users, id = list index + */ + public function __construct(public array $rows) + { + } + + public function find(string $login): array + { + $found = []; + foreach ($this->rows as $id => $row) { + if ($row['login'] === $login) { + $found[] = ['id' => (string) $id, 'hash' => $row['hash'], 'admin' => $row['admin']]; + } + } + + return $found; + } + + public function administrators(): int + { + return count(array_filter($this->rows, static fn (array $row): bool => $row['admin'])); + } + + public function holdersOf(string $email, string $login): array + { + $holders = []; + foreach ($this->rows as $row) { + if ($row['login'] !== $login && in_array(strtoupper($email), array_map('strtoupper', $row['emails']), true)) { + $holders[] = $row['login']; + } + } + + return $holders; + } + + public function insertAdministrator(string $login, string $hash, string $email): void + { + $this->inserted[] = ['login' => $login, 'email' => $email]; + $this->rows[] = ['login' => $login, 'hash' => $hash, 'emails' => [$email], 'admin' => true]; + } + + public function emailsOf(string $id): array + { + return $this->rows[(int) $id]['emails']; + } + + public function updateHash(string $id, string $hash): void + { + $this->updated[] = $this->rows[(int) $id]['login']; + $this->rows[(int) $id]['hash'] = $hash; + } + + /** + * @return array{login: string, hash: string, emails: list, admin: bool}|null + */ + public function row(string $login): ?array + { + foreach ($this->rows as $row) { + if ($row['login'] === $login) { + return $row; + } + } + + return null; + } +} + +final class RealignAdminPasswordTest extends TestCase +{ + private const LOGIN = 'platformadmin'; + + private const PASSWORD = 'the-declared-password'; + + private const EMAIL = 'platformadmin@example.org'; + + /** + * @param list $emails addresses linked to the user + * @return array{login: string, hash: string, emails: list, admin: bool} + */ + private static function row(string $login, string $password, array $emails, bool $admin): array + { + return ['login' => $login, 'hash' => password_hash(strtolower(md5($password)), PASSWORD_DEFAULT), 'emails' => $emails, 'admin' => $admin]; + } + + /** + * @return array{login: string, hash: string, emails: list, admin: bool} + */ + private static function legacyAdministrator(): array + { + return self::row('administrator', 'host-password', ['administrator@example.org'], true); + } + + private static function accepts(string $hash): bool + { + return password_verify(strtolower(md5(self::PASSWORD)), $hash); + } + + /** + * @return array{0: int, 1: string, 2: string} exit code, stdout and stderr of `php` fed the script on stdin + */ + private static function pipeIntoPhp(): array + { + $process = proc_open( + [PHP_BINARY], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + [] + ); + fwrite($pipes[0], (string) file_get_contents(dirname(__DIR__, 6) . '/roles/web-app-suitecrm/files/php/realign_admin_password.php')); + fclose($pipes[0]); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [proc_close($process), $stdout, $stderr]; + } + + public function testTheScriptPipedIntoPhpLikeTheRoleReportsAnEmptyEnvironmentAsFail(): void + { + [$code, $stdout, $stderr] = self::pipeIntoPhp(); + + self::assertSame(1, $code, $stdout . $stderr); + self::assertSame('', $stdout); + self::assertStringContainsString("[FAIL] SUITECRM_DB_HOST is empty\n", $stderr); + self::assertStringNotContainsString('Undefined constant', $stderr); + } + + public function testAnAbsentLoginIsCreatedAsAdministratorNextToTheExistingOne(): void + { + $users = new FakeUsers([self::legacyAdministrator()]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[CHANGED]', $line); + self::assertSame([['login' => self::LOGIN, 'email' => self::EMAIL]], $users->inserted); + self::assertTrue(self::accepts($users->row(self::LOGIN)['hash'])); + self::assertSame(self::legacyAdministrator()['emails'], $users->row('administrator')['emails']); + self::assertTrue($users->row('administrator')['admin']); + } + + public function testASecondRunAfterTheCreateChangesNothing(): void + { + $users = new FakeUsers([self::legacyAdministrator()]); + ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[UNCHANGED]', $line); + self::assertCount(1, $users->inserted); + self::assertSame([], $users->updated); + } + + public function testAPresentAdministratorWithTheDeclaredPasswordIsLeftAlone(): void + { + $users = new FakeUsers([self::row(self::LOGIN, self::PASSWORD, [self::EMAIL], true)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[UNCHANGED]', $line); + self::assertSame([], $users->inserted); + self::assertSame([], $users->updated); + } + + public function testALegacyMd5HashOfTheDeclaredPasswordIsLeftAlone(): void + { + $users = new FakeUsers([['login' => self::LOGIN, 'hash' => strtolower(md5(self::PASSWORD)), 'emails' => [self::EMAIL], 'admin' => true]]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[UNCHANGED]', $line); + } + + public function testAPresentAdministratorWithAStalePasswordIsRealigned(): void + { + $users = new FakeUsers([self::row(self::LOGIN, 'stale', [self::EMAIL], true)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(0, $code); + self::assertStringStartsWith('[CHANGED]', $line); + self::assertSame([self::LOGIN], $users->updated); + self::assertTrue(self::accepts($users->row(self::LOGIN)['hash'])); + } + + public function testAnInstallWithoutAnyAdministratorFailsInsteadOfCreating(): void + { + $users = new FakeUsers([self::row('sales', 'x', ['sales@example.org'], false)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('no administrator', $line); + self::assertSame([], $users->inserted); + } + + public function testAnEmailHeldByAnotherUserFailsNamingTheHolder(): void + { + $users = new FakeUsers([ + self::legacyAdministrator(), + self::row('sales', 'x', ['PlatformAdmin@Example.org'], false), + ]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('sales', $line); + self::assertSame([], $users->inserted); + } + + public function testTwoUndeletedUsersWithTheLoginFailWithoutTouchingEither(): void + { + $users = new FakeUsers([ + self::legacyAdministrator(), + self::row(self::LOGIN, 'stale', [self::EMAIL], true), + self::row(self::LOGIN, 'other', ['other@example.org'], false), + ]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL] 2 undeleted SuiteCRM users carry the login', $line); + self::assertSame([], $users->updated); + self::assertSame([], $users->inserted); + } + + public function testANonAdministratorWithTheDeclaredEmailIsStillNotPromoted(): void + { + $users = new FakeUsers([self::legacyAdministrator(), self::row(self::LOGIN, 'stale', ['PlatformAdmin@Example.org'], false)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringStartsWith('[FAIL]', $line); + self::assertStringContainsString('not promoted', $line); + self::assertStringContainsString('a matching email proves nothing', $line); + self::assertFalse($users->row(self::LOGIN)['admin']); + self::assertSame([], $users->updated); + } + + public function testANonAdministratorWithAnotherEmailNamesTheMismatch(): void + { + $users = new FakeUsers([self::legacyAdministrator(), self::row(self::LOGIN, 'stale', ['someone@example.org'], false)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringContainsString("its email someone@example.org differs from 'platformadmin@example.org'", $line); + self::assertFalse($users->row(self::LOGIN)['admin']); + } + + public function testAnLdapCreatedNonAdministratorWithoutEmailSaysSo(): void + { + $users = new FakeUsers([self::legacyAdministrator(), self::row(self::LOGIN, 'stale', [], false)]); + + [$code, $line] = ensure($users, self::LOGIN, self::PASSWORD, self::EMAIL); + + self::assertSame(1, $code); + self::assertStringContainsString('it carries no email address', $line); + self::assertStringNotContainsString('differs', $line); + self::assertFalse($users->row(self::LOGIN)['admin']); + self::assertSame([], $users->updated); + } +} diff --git a/tests/unit/php/roles/web-app-wordpress/files/RealignAdminPasswordTest.php b/tests/unit/php/roles/web-app-wordpress/files/RealignAdminPasswordTest.php new file mode 100644 index 0000000000..f34bf5fe3c --- /dev/null +++ b/tests/unit/php/roles/web-app-wordpress/files/RealignAdminPasswordTest.php @@ -0,0 +1,301 @@ +message; + } + } + $GLOBALS['state'] = json_decode((string) getenv('STUB_STATE'), true); + $GLOBALS['state']['passwords_set'] = []; + register_shutdown_function(static function () { + file_put_contents((string) getenv('STUB_OUT'), json_encode($GLOBALS['state'])); + }); + function stub_user_where($field, $value) + { + foreach ($GLOBALS['state']['users'] as $user) { + if (strtolower((string) $user[$field]) === strtolower((string) $value)) { + return (object) $user; + } + } + return false; + } + function get_user_by($field, $value) + { + return stub_user_where($field === 'login' ? 'user_login' : 'user_email', $value); + } + function is_multisite() + { + return $GLOBALS['state']['multisite']; + } + function get_super_admins() + { + return $GLOBALS['state']['super_admins']; + } + function is_super_admin($id) + { + $user = stub_user_where('ID', $id); + return $user !== false && in_array($user->user_login, $GLOBALS['state']['super_admins'], true); + } + function get_users($args) + { + $ids = []; + foreach ($GLOBALS['state']['users'] as $user) { + if (in_array($args['role'], $user['roles'], true)) { + $ids[] = (string) $user['ID']; + } + } + return array_slice($ids, 0, $args['number']); + } + function wp_check_password($password, $hash, $id) + { + return $hash === 'hashed:' . $password; + } + function wp_set_password($password, $id) + { + foreach ($GLOBALS['state']['users'] as $index => $user) { + if ($user['ID'] === $id) { + $GLOBALS['state']['users'][$index]['user_pass'] = 'hashed:' . $password; + $GLOBALS['state']['passwords_set'][] = $user['user_login']; + } + } + } + function wp_insert_user($data) + { + if (stub_user_where('user_email', $data['user_email']) !== false) { + return new WP_Error('Sorry, that email address is already used!'); + } + $id = 1 + max(array_merge([0], array_column($GLOBALS['state']['users'], 'ID'))); + $GLOBALS['state']['users'][] = [ + 'ID' => $id, + 'user_login' => $data['user_login'], + 'user_email' => $data['user_email'], + 'user_pass' => 'hashed:' . $data['user_pass'], + 'roles' => [$data['role']], + ]; + return $id; + } + function grant_super_admin($id) + { + $GLOBALS['state']['super_admins'][] = stub_user_where('ID', $id)->user_login; + return true; + } + function is_wp_error($value) + { + return $value instanceof WP_Error; + } + require getenv('STUB_SCRIPT'); + PHP; + + /** + * @param int $id user ID + * @param string $login user_login + * @param string $password plaintext the stubbed hash stands for + * @param string $email user_email + * @param list $roles roles on the main site + * @return array{ID: int, user_login: string, user_email: string, user_pass: string, roles: list} + */ + private static function user(int $id, string $login, string $password, string $email, array $roles): array + { + return ['ID' => $id, 'user_login' => $login, 'user_email' => $email, 'user_pass' => 'hashed:' . $password, 'roles' => $roles]; + } + + /** + * @return array the legacy administrator next to which the platform administrator is ensured + */ + private static function legacyAdministrator(): array + { + return self::user(1, 'administrator', 'host-password', 'administrator@example.org', ['administrator']); + } + + /** + * @param list> $users users of the site + * @param bool $multisite whether the site is a network + * @param list $superAdmins logins of the network's super admins + * @return array{0: int, 1: string, 2: string, 3: array} exit code, stdout, stderr and the state afterwards + */ + private function runScript(array $users, bool $multisite, array $superAdmins): array + { + $harness = tempnam(sys_get_temp_dir(), 'wprealign'); + $out = tempnam(sys_get_temp_dir(), 'wprealign-state'); + file_put_contents($harness, self::HARNESS); + $env = [ + 'STUB_STATE' => (string) json_encode(['users' => $users, 'multisite' => $multisite, 'super_admins' => $superAdmins]), + 'STUB_OUT' => $out, + 'STUB_SCRIPT' => dirname(__DIR__, 6) . self::SCRIPT, + 'WP_REALIGN_LOGIN' => self::LOGIN, + 'WP_REALIGN_PASSWORD' => self::PASSWORD, + 'WP_REALIGN_EMAIL' => self::EMAIL, + ]; + $process = proc_open([PHP_BINARY, $harness], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + $state = json_decode((string) file_get_contents($out), true, flags: JSON_THROW_ON_ERROR); + unlink($harness); + unlink($out); + + return [$code, $stdout, $stderr, $state]; + } + + /** + * @param array $state + * @return array|null + */ + private static function find(array $state, string $login): ?array + { + foreach ($state['users'] as $user) { + if ($user['user_login'] === $login) { + return $user; + } + } + + return null; + } + + public function testAnEmptyEnvironmentFailsWithoutRelyingOnTheStderrConstant(): void + { + $process = proc_open( + [PHP_BINARY], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + [] + ); + fwrite($pipes[0], (string) file_get_contents(dirname(__DIR__, 6) . self::SCRIPT)); + fclose($pipes[0]); + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + self::assertSame(1, proc_close($process), $stdout . $stderr); + self::assertStringContainsString("[FAIL] WP_REALIGN_LOGIN is empty\n", $stderr); + self::assertStringNotContainsString('Undefined constant', $stderr); + } + + public function testAnAbsentLoginIsCreatedAsAdministratorNextToTheExistingOne(): void + { + [$code, $stdout, $stderr, $state] = $this->runScript([self::legacyAdministrator()], false, []); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[CHANGED]', $stdout); + $created = self::find($state, self::LOGIN); + self::assertNotNull($created); + self::assertSame(['administrator'], $created['roles']); + self::assertSame(self::EMAIL, $created['user_email']); + self::assertSame('hashed:' . self::PASSWORD, $created['user_pass']); + self::assertSame(self::legacyAdministrator(), self::find($state, 'administrator')); + self::assertSame([], $state['super_admins']); + } + + public function testAnAbsentLoginOnANetworkAlsoBecomesSuperAdmin(): void + { + [$code, $stdout, $stderr, $state] = $this->runScript([self::legacyAdministrator()], true, ['administrator']); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[CHANGED]', $stdout); + self::assertSame(['administrator', self::LOGIN], $state['super_admins']); + } + + public function testASecondRunAfterTheCreateChangesNothing(): void + { + [, , , $first] = $this->runScript([self::legacyAdministrator()], false, []); + + [$code, $stdout, $stderr, $second] = $this->runScript($first['users'], false, []); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[UNCHANGED]', $stdout); + self::assertSame($first['users'], $second['users']); + } + + public function testAPresentAdministratorWithTheDeclaredPasswordIsLeftAlone(): void + { + $users = [self::user(3, self::LOGIN, self::PASSWORD, self::EMAIL, ['administrator'])]; + + [$code, $stdout, $stderr, $state] = $this->runScript($users, false, []); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[UNCHANGED]', $stdout); + self::assertSame([], $state['passwords_set']); + self::assertSame($users, $state['users']); + } + + public function testAPresentAdministratorWithAStalePasswordIsRealigned(): void + { + [$code, $stdout, $stderr, $state] = $this->runScript([self::user(3, self::LOGIN, 'stale', self::EMAIL, ['administrator'])], false, []); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[CHANGED]', $stdout); + self::assertSame([self::LOGIN], $state['passwords_set']); + } + + public function testASuperAdminWithoutTheMainSiteRoleIsRealignedLikeAnAdministrator(): void + { + [$code, $stdout, $stderr, $state] = $this->runScript([self::user(3, self::LOGIN, 'stale', self::EMAIL, [])], true, [self::LOGIN]); + + self::assertSame(0, $code, $stderr); + self::assertStringStartsWith('[CHANGED]', $stdout); + self::assertSame([self::LOGIN], $state['passwords_set']); + } + + public function testASiteWithoutAnyAdministratorFailsInsteadOfCreating(): void + { + [$code, $stdout, $stderr, $state] = $this->runScript([self::user(2, 'reader', 'x', 'reader@example.org', ['subscriber'])], false, []); + + self::assertSame(1, $code); + self::assertSame('', $stdout); + self::assertStringContainsString('[FAIL]', $stderr); + self::assertStringContainsString('no administrator', $stderr); + self::assertNull(self::find($state, self::LOGIN)); + } + + public function testAnEmailHeldByAnotherUserFailsNamingTheHolder(): void + { + [$code, , $stderr, $state] = $this->runScript([ + self::legacyAdministrator(), + self::user(2, 'reader', 'x', 'PlatformAdmin@Example.org', ['subscriber']), + ], false, []); + + self::assertSame(1, $code); + self::assertStringContainsString('[FAIL]', $stderr); + self::assertStringContainsString('reader', $stderr); + self::assertNull(self::find($state, self::LOGIN)); + } + + public function testANonAdministratorHoldingTheLoginIsNotPromoted(): void + { + $users = [self::legacyAdministrator(), self::user(2, self::LOGIN, 'stale', self::EMAIL, ['subscriber'])]; + + [$code, , $stderr, $state] = $this->runScript($users, false, []); + + self::assertSame(1, $code); + self::assertStringContainsString('not promoted', $stderr); + self::assertSame($users, $state['users']); + self::assertSame([], $state['passwords_set']); + } +} diff --git a/tests/unit/python/plugins/filter/test_platform_admin.py b/tests/unit/python/plugins/filter/test_platform_admin.py new file mode 100644 index 0000000000..99b465a97b --- /dev/null +++ b/tests/unit/python/plugins/filter/test_platform_admin.py @@ -0,0 +1,89 @@ +import unittest + +from ansible.errors import AnsibleFilterError + +from plugins.filter.platform_admin import FilterModule + +LOGIN = "platformadmin" +EMAIL = "platformadmin@example.org" +LEGACY = {"login": "administrator", "email": "administrator@example.org", "admin": True} + + +def account(login: str, email: str, admin: bool) -> dict: + return {"login": login, "email": email, "admin": admin} + + +class TestPlatformAdminPlan(unittest.TestCase): + def setUp(self) -> None: + self.plan = FilterModule().filters()["platform_admin_plan"] + + def test_absent_login_next_to_an_administrator_is_created(self) -> None: + self.assertEqual(self.plan([LEGACY], LOGIN, EMAIL, "App", True), "create") + + def test_present_administrator_is_realigned(self) -> None: + accounts = [LEGACY, account(LOGIN, EMAIL, True)] + self.assertEqual(self.plan(accounts, LOGIN, EMAIL, "App", True), "realign") + + def test_present_administrator_is_realigned_even_when_its_email_differs( + self, + ) -> None: + accounts = [ + account("other", EMAIL, True), + account(LOGIN, "old@example.org", True), + ] + self.assertEqual(self.plan(accounts, LOGIN, EMAIL, "App", True), "realign") + + def test_app_without_administrator_fails(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "App has no administrator"): + self.plan( + [account("reader", "r@example.org", False)], LOGIN, EMAIL, "App", True + ) + + def test_app_without_any_account_fails(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "no administrator"): + self.plan([], LOGIN, EMAIL, "App", True) + + def test_email_held_by_another_account_fails_naming_every_holder(self) -> None: + accounts = [ + LEGACY, + account("zed", "PlatformAdmin@Example.org", False), + account("amy", EMAIL, True), + ] + with self.assertRaisesRegex( + AnsibleFilterError, "held by the App account amy, zed" + ): + self.plan(accounts, LOGIN, EMAIL, "App", True) + + def test_non_administrator_holding_the_login_is_not_promoted(self) -> None: + accounts = [LEGACY, account(LOGIN, EMAIL, False)] + with self.assertRaisesRegex(AnsibleFilterError, "not promoted"): + self.plan(accounts, LOGIN, EMAIL, "App", True) + + def test_case_insensitive_app_matches_the_login_in_any_case(self) -> None: + accounts = [LEGACY, account("PlatformAdmin", EMAIL, False)] + with self.assertRaisesRegex( + AnsibleFilterError, "'PlatformAdmin' is no administrator" + ): + self.plan(accounts, LOGIN, EMAIL, "App", False) + + def test_case_sensitive_app_treats_another_case_as_another_login(self) -> None: + accounts = [LEGACY, account("PlatformAdmin", "pa@example.org", False)] + self.assertEqual(self.plan(accounts, LOGIN, EMAIL, "App", True), "create") + + def test_malformed_account_is_refused(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "boolean admin"): + self.plan( + [{"login": LOGIN, "email": EMAIL, "admin": "yes"}], + LOGIN, + EMAIL, + "App", + True, + ) + + def test_accounts_that_are_no_list_are_refused(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "as a list"): + self.plan({"login": LOGIN}, LOGIN, EMAIL, "App", True) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-joomla/filter_plugins/test_joomla_users.py b/tests/unit/python/roles/web-app-joomla/filter_plugins/test_joomla_users.py new file mode 100644 index 0000000000..ac4ece8d36 --- /dev/null +++ b/tests/unit/python/roles/web-app-joomla/filter_plugins/test_joomla_users.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import importlib.util +import sys +import unicodedata +import unittest + +from ansible.errors import AnsibleFilterError + +from plugins.filter.platform_admin import platform_admin_plan + +from . import PROJECT_ROOT + + +def _load_module(rel_path: str, name: str): + path = PROJECT_ROOT / rel_path + spec = importlib.util.spec_from_file_location(name, str(path)) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +MODULE = _load_module( + "roles/web-app-joomla/filter_plugins/joomla_users.py", "joomla_users" +) + +HEADER = ["ID", "Username", "Name", "Email", "Blocked", "Groups"] +LEGACY = [ + "42", + "administrator", + "Administrator Example", + "administrator@example.org", + "0", + "Registered, Super Users", +] +READER = ["43", "biber", "Biber Test", "biber@example.org", "0", "Registered"] + + +def display_width(text: str) -> int: + """Count the terminal columns Symfony's Helper::width() gives ``text``. + + Args: + text: one table cell. + + Returns: + 2 per East Asian wide or fullwidth character, 0 per combining mark or + format character, 1 per other character. + """ + width = 0 + for char in text: + if unicodedata.combining(char) or unicodedata.category(char) in { + "Mn", + "Me", + "Cf", + }: + continue + width += 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1 + return width + + +def table(border: str, lines: list[str]) -> str: + """Wrap rendered rows in the title and borders of user:list. + + Args: + border: the dash line above, between and below the rows. + lines: the header row followed by every account row. + + Returns: + The stdout of ``user:list``. + """ + body = [border, lines[0], border, *lines[1:], border] + return "\nList Users\n==========\n\n" + "\n".join(body) + "\n\n" + + +def user_list(rows: list[list[str]]) -> str: + """Render ``rows`` the way SymfonyStyle::table() does for user:list. + + Args: + rows: the cells of every account, in the order of ``HEADER``. + + Returns: + The stdout of ``user:list``, every cell padded to its column's + display width as Symfony's Table::renderCell() pads it. + """ + widths = [ + max(display_width(row[column]) for row in [HEADER, *rows]) + 2 + for column in range(len(HEADER)) + ] + border = " " + " ".join("-" * width for width in widths) + " " + + def line(row: list[str]) -> str: + cells = ( + f" {cell}" + " " * (width - 1 - display_width(cell)) + for cell, width in zip(row, widths, strict=True) + ) + return " " + " ".join(cells) + " " + + return table(border, [line(row) for row in [HEADER, *rows]]) + + +def padded(*cells: tuple[str, int]) -> str: + """Render one row from hand-counted trailing pads. + + Args: + cells: ``(text, pad)`` per column, ``pad`` being the spaces after it. + + Returns: + The row as Symfony prints it. + """ + return " " + " ".join(f" {text}{' ' * pad}" for text, pad in cells) + " " + + +class TestJoomlaUserAccounts(unittest.TestCase): + def test_every_row_becomes_an_account_with_its_super_user_membership(self) -> None: + self.assertEqual( + MODULE.joomla_user_accounts(user_list([LEGACY, READER])), + [ + { + "login": "administrator", + "email": "administrator@example.org", + "admin": True, + }, + {"login": "biber", "email": "biber@example.org", "admin": False}, + ], + ) + + def test_cjk_and_decomposed_names_keep_the_following_columns_aligned( + self, + ) -> None: + output = table( + " ---- ---------- ---------- -------------------- --------- ------------- ", + [ + padded( + ("ID", 1), + ("Username", 1), + ("Name", 5), + ("Email", 14), + ("Blocked", 1), + ("Groups", 6), + ), + padded( + ("48", 1), + ("yamada", 3), + ("ε±±η”°ε€ͺιƒŽ", 1), + ("yamada@example.org", 1), + ("0", 7), + ("Super Users", 1), + ), + padded( + ("49", 1), + ("renee", 4), + ("Renée", 4), + ("renee@example.org", 2), + ("0", 7), + ("Registered", 2), + ), + ], + ) + + self.assertEqual( + MODULE.joomla_user_accounts(output), + [ + {"login": "yamada", "email": "yamada@example.org", "admin": True}, + {"login": "renee", "email": "renee@example.org", "admin": False}, + ], + ) + + def test_a_wide_username_is_read_whole(self) -> None: + row = ["50", "ε±±η”°", "Yamada", "y@example.org", "0", "Super Users"] + self.assertEqual( + MODULE.joomla_user_accounts(user_list([row])), + [{"login": "ε±±η”°", "email": "y@example.org", "admin": True}], + ) + + def test_a_group_title_merely_containing_super_users_is_no_membership(self) -> None: + row = ["44", "editor", "Editor", "editor@example.org", "0", "Super Users Lite"] + self.assertEqual( + MODULE.joomla_user_accounts(user_list([row]))[0]["admin"], False + ) + + def test_output_without_a_table_is_refused(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "no account table"): + MODULE.joomla_user_accounts("Error: could not connect to the database\n") + + def test_a_table_with_other_columns_is_refused(self) -> None: + output = user_list([LEGACY]).replace("Blocked", "Locked ") + with self.assertRaisesRegex(AnsibleFilterError, "expected"): + MODULE.joomla_user_accounts(output) + + +class TestJoomlaPlatformAdminPlan(unittest.TestCase): + def plan(self, rows: list[list[str]]) -> str: + return platform_admin_plan( + MODULE.joomla_user_accounts(user_list(rows)), + "platformadmin", + "platformadmin@example.org", + "Joomla", + False, + ) + + def test_absent_platform_administrator_is_created_next_to_a_super_user( + self, + ) -> None: + self.assertEqual(self.plan([LEGACY, READER]), "create") + + def test_present_super_user_is_realigned(self) -> None: + row = [ + "45", + "platformadmin", + "Platformadmin", + "platformadmin@example.org", + "0", + "Super Users", + ] + self.assertEqual(self.plan([LEGACY, row]), "realign") + + def test_site_without_a_super_user_fails(self) -> None: + with self.assertRaisesRegex(AnsibleFilterError, "Joomla has no administrator"): + self.plan([READER]) + + def test_email_held_by_another_account_fails_naming_it(self) -> None: + row = [ + "46", + "someone", + "Someone", + "PlatformAdmin@example.org", + "0", + "Registered", + ] + with self.assertRaisesRegex( + AnsibleFilterError, "held by the Joomla account someone" + ): + self.plan([LEGACY, row]) + + def test_registered_holder_of_the_username_in_another_case_is_not_promoted( + self, + ) -> None: + row = ["47", "PlatformAdmin", "PA", "pa@example.org", "0", "Registered"] + with self.assertRaisesRegex(AnsibleFilterError, "not promoted"): + self.plan([LEGACY, row]) + + +ADD_FAILED = "\nAdd User\n========\n\n [ERROR] {}\n\n" + + +class TestJoomlaUserAddConflict(unittest.TestCase): + def conflict(self, output: str) -> str: + return MODULE.joomla_user_add_conflict( + output, "platformadmin", "platformadmin@example.org" + ) + + def test_a_taken_username_names_the_group_less_account(self) -> None: + message = self.conflict(ADD_FAILED.format("The username already exists!")) + + self.assertIn("'platformadmin'", message) + self.assertIn("belongs to no user group", message) + self.assertIn("add it to Super Users", message) + + def test_a_taken_email_names_the_address_of_the_group_less_account(self) -> None: + message = self.conflict(ADD_FAILED.format("The email address already exists!")) + + self.assertIn("'platformadmin@example.org'", message) + self.assertIn("without any user group", message) + + def test_any_other_refusal_is_left_to_the_task_itself(self) -> None: + self.assertEqual( + self.conflict(ADD_FAILED.format("The email address is invalid!")), "" + ) + + def test_output_that_is_no_string_is_no_conflict(self) -> None: + self.assertEqual(self.conflict(None), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/web-app-mediawiki/files/test_realign_admin_password.py b/tests/unit/python/roles/web-app-mediawiki/files/test_realign_admin_password.py new file mode 100644 index 0000000000..83cfb0224a --- /dev/null +++ b/tests/unit/python/roles/web-app-mediawiki/files/test_realign_admin_password.py @@ -0,0 +1,239 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from utils import PROJECT_ROOT +from utils.cache.files import read_text + +SCRIPT = PROJECT_ROOT / "roles/web-app-mediawiki/files/shell/realign_admin_password.sh" +STATE_SQL = PROJECT_ROOT / "roles/web-app-mediawiki/files/sql/admin_state.sql" +NAME = "platformadmin" +PASSWORD = "the-declared-password" +EMAIL = "platformadmin@example.org" +CREATED = "wikidb: Creating and promoting User:Platformadmin into sysop, bureaucrat...\ndone.\n" +PROMOTED = "wikidb: Promoting User:Platformadmin into sysop, bureaucrat...\ndone." + +STUB_PHP = """#!{python} +import json, os, sys +args = sys.argv[1:] +with open(os.environ["STUB_LOG"], "a") as log: + log.write(json.dumps(args) + "\\n") +if args[1] == "sql": + print("stdClass Object\\n(\\n [state] => " + os.environ["STUB_STATE"] + "\\n)") +elif "--force" in args: + print(os.environ["STUB_PROMOTE_OUTPUT"]) +else: + print(os.environ["STUB_CREATE_OUTPUT"], end="") +""" + + +def _calls(log: Path) -> list[list[str]]: + return [ + json.loads(line) + for line in log.read_text().splitlines() # nocheck: cache-read tempdir fixture + ] + + +class TestRealignAdminPassword(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + root = Path(self._tmp.name) + self.log = root / "calls.jsonl" + self.log.touch() + self.bin = root / "bin" + self.bin.mkdir() + stub = self.bin / "php" + stub.write_text(STUB_PHP.format(python=sys.executable)) + stub.chmod(0o755) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _run( + self, state: str, local_login: bool, promote_output: str, email: str + ) -> subprocess.CompletedProcess[str]: + env = { + "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", + "STUB_LOG": str(self.log), + "STUB_STATE": state, + "STUB_PROMOTE_OUTPUT": promote_output, + "STUB_CREATE_OUTPUT": CREATED, + "MEDIAWIKI_HTML_DIR": "/var/www/html", + "MEDIAWIKI_ADMIN_NAME": NAME, + "MEDIAWIKI_ADMIN_PASSWORD": PASSWORD, + "MEDIAWIKI_ADMIN_EMAIL": email, + "MEDIAWIKI_LOCAL_LOGIN": "true" if local_login else "false", + "MEDIAWIKI_ADMIN_STATE_SQL": read_text(str(STATE_SQL)), + } + return subprocess.run( + ["sh", "-s"], + input=read_text(str(SCRIPT)), + capture_output=True, + text=True, + env=env, + check=False, + ) + + def _promotions(self) -> list[list[str]]: + return [call for call in _calls(self.log) if call[1] == "createAndPromote"] + + def test_absent_account_under_sso_is_left_to_the_first_sso_login(self) -> None: + result = self._run("absent", False, "", EMAIL) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("[UNCHANGED]", result.stdout) + self.assertIn("provisioned by SSO", result.stdout) + self.assertEqual(self._promotions(), []) + + def test_absent_account_with_local_login_is_created_with_password_and_email( + self, + ) -> None: + result = self._run("absent", True, "", EMAIL) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("[CHANGED]", result.stdout) + self.assertEqual( + self._promotions(), + [ + [ + "/var/www/html/maintenance/run.php", + "createAndPromote", + "--bureaucrat", + "--sysop", + "--email", + EMAIL, + NAME, + PASSWORD, + ] + ], + ) + + def test_present_sysop_is_realigned_with_force_and_the_password(self) -> None: + result = self._run("admin", True, "Password set.\ndone.", EMAIL) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("[CHANGED]", result.stdout) + self.assertEqual( + self._promotions()[0][2:], + ["--force", "--bureaucrat", "--sysop", NAME, PASSWORD], + ) + + def test_present_sysop_under_sso_is_left_unchanged_without_a_password( + self, + ) -> None: + result = self._run("admin", False, "Account exists and nothing to do.", EMAIL) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("[UNCHANGED]", result.stdout) + self.assertNotIn(PASSWORD, self._promotions()[0]) + + def test_wiki_without_any_sysop_fails_without_creating(self) -> None: + result = self._run("no-admin", True, "", EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("[FAIL]", result.stderr) + self.assertIn("no sysop", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_wiki_without_any_sysop_fails_under_sso_too(self) -> None: + result = self._run("no-admin", False, "", EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("no sysop", result.stderr) + + def test_email_held_by_another_account_fails_naming_the_holder(self) -> None: + result = self._run("email-held:Someone, Other", True, "", EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("[FAIL]", result.stderr) + self.assertIn("Someone, Other", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_sso_account_with_the_confirmed_declared_email_is_promoted(self) -> None: + result = self._run("not-admin:match", False, PROMOTED, EMAIL) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.strip(), + f"[CHANGED] promoted {NAME} (SSO account, email matches)", + ) + self.assertEqual( + self._promotions(), + [ + [ + "/var/www/html/maintenance/run.php", + "createAndPromote", + "--force", + "--bureaucrat", + "--sysop", + NAME, + ] + ], + ) + + def test_matching_account_is_not_promoted_while_local_login_is_on(self) -> None: + result = self._run("not-admin:match", True, PROMOTED, EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("not promoted (local login is on)", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_sso_account_with_another_email_is_not_promoted(self) -> None: + result = self._run("not-admin:mismatch", False, PROMOTED, EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn(f"its email differs from {EMAIL}", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_sso_account_with_an_unconfirmed_email_is_not_promoted(self) -> None: + result = self._run("not-admin:unconfirmed", False, PROMOTED, EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("is not confirmed", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_probe_without_a_state_fails(self) -> None: + result = self._run("", True, "", EMAIL) + + self.assertEqual(result.returncode, 1) + self.assertIn("returned no state", result.stderr) + self.assertEqual(self._promotions(), []) + + def test_declared_values_reach_the_probe_only_hex_encoded(self) -> None: + email = "o'brien@example.org" + + self._run("admin", False, "Account exists and nothing to do.", email) + + query = _calls(self.log)[0][3] + self.assertNotIn("o'brien", query) + self.assertIn(email.encode().hex(), query) + self.assertIn(NAME.encode().hex(), query) + self.assertNotIn("%(", query) + + def test_empty_state_query_fails_before_any_call(self) -> None: + result = subprocess.run( + ["sh", "-s"], + input=read_text(str(SCRIPT)), + capture_output=True, + text=True, + env={ + "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", + "STUB_LOG": str(self.log), + "MEDIAWIKI_HTML_DIR": "/var/www/html", + "MEDIAWIKI_ADMIN_NAME": NAME, + "MEDIAWIKI_ADMIN_EMAIL": EMAIL, + }, + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("MEDIAWIKI_ADMIN_STATE_SQL is empty", result.stderr) + self.assertEqual(_calls(self.log), []) + + +if __name__ == "__main__": + unittest.main()