diff --git a/.github/renovate.json b/.github/renovate.json index d0a310c23..c3ca1b30c 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -122,6 +122,7 @@ "description": "Automerge patch bumps of components whose role has a Molecule scenario (via branch push - no PR). A bump touches that role's defaults/main.yml, which makes the Molecule workflow run that role's scenario, so the update only merges once the component has been started on the configuration the role renders. Patch only: a minor carries behaviour changes no static rule can judge, and reading the release notes to decide is a job for a human or an agent. Keep this list in step with the roles that have a scenario - bin/check-molecule-automerge-list.py enforces that.", "matchFileNames": [ "roles/custom/matrix-alertmanager-receiver/defaults/main.yml", + "roles/custom/matrix-authentication-service/defaults/main.yml", "roles/custom/matrix-bot-baibot/defaults/main.yml", "roles/custom/matrix-bot-draupnir/defaults/main.yml", "roles/custom/matrix-bot-maubot/defaults/main.yml", diff --git a/roles/custom/matrix-authentication-service/molecule/default/converge.yml b/roles/custom/matrix-authentication-service/molecule/default/converge.yml new file mode 100644 index 000000000..cf84a9012 --- /dev/null +++ b/roles/custom/matrix-authentication-service/molecule/default/converge.yml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Include roles for Matrix Authentication Service Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: true + tasks: + - name: Include base roles for Matrix Authentication Service Molecule tests + ansible.builtin.include_role: + name: "{{ role_name }}" + public: true + loop: + - com.devture.ansible.role.playbook_help + - com.devture.ansible.role.systemd_docker_base + loop_control: + loop_var: role_name + + # The role's main task file also carries separately invoked CLI actions. Include the + # validation and installation selected by the normal setup tags, without invoking them. + - name: Include Matrix Authentication Service setup task files + ansible.builtin.include_role: + name: "custom/{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') | basename }}" + public: true + tasks_from: "{{ role_task_file }}" + loop: + - validate_config.yml + - install.yml + loop_control: + loop_var: role_task_file + +# Component roles install their unit; the complete playbook's service manager starts it. +- name: Ensure Matrix Authentication Service is started + hosts: all + become: true + gather_facts: false + tasks: + - name: Ensure systemd daemon is reloaded + ansible.builtin.systemd_service: + daemon_reload: true + + - name: Ensure Matrix Authentication Service systemd service is started + ansible.builtin.systemd_service: + name: matrix-authentication-service.service + state: started diff --git a/roles/custom/matrix-authentication-service/molecule/default/molecule.yml b/roles/custom/matrix-authentication-service/molecule/default/molecule.yml new file mode 100644 index 000000000..a6533b502 --- /dev/null +++ b/roles/custom/matrix-authentication-service/molecule/default/molecule.yml @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +dependency: + name: galaxy + options: + requirements-file: requirements.yml + force: true +driver: + name: docker +platforms: + - name: matrix-authentication-service-${MOLECULE_DISTRO:-ubuntu2604}-default + image: "geerlingguy/docker-${MOLECULE_DISTRO:-ubuntu2604}-ansible:latest" + command: ${MOLECULE_DOCKER_COMMAND:-""} + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns_mode: host + privileged: true + pre_build_image: true +provisioner: + name: ansible + config_options: + defaults: + callback_result_format: yaml + inventory: + group_vars: + all: + matrix_authentication_service_hostname: mas.molecule.local + matrix_authentication_service_path_prefix: /molecule-auth + matrix_authentication_service_container_network: matrix-authentication-service-molecule + + # verify.yml is a separate play where role defaults are out of scope. These are + # pinned to the paths the role derives from matrix_base_data_path. + matrix_authentication_service_base_path: /matrix/matrix-authentication-service + matrix_authentication_service_config_path: /matrix/matrix-authentication-service/config + matrix_authentication_service_data_path: /matrix/matrix-authentication-service/data + matrix_authentication_service_data_keys_path: /matrix/matrix-authentication-service/data/keys + + # Real Postgres, with a database and role which differ from the component defaults. + # A migrated schema therefore proves hostname resolution and authentication too. + matrix_authentication_service_config_database_host: matrix-postgres-molecule + matrix_authentication_service_config_database_port: 5432 + matrix_authentication_service_config_database_username: molecule_mas_user + matrix_authentication_service_config_database_password: molecule_pg_password_97af31 + matrix_authentication_service_config_database_database: molecule_mas_database + matrix_authentication_service_config_database_max_connections: 7 + matrix_authentication_service_config_database_min_connections: 2 + matrix_authentication_service_config_database_connect_timeout: 17 + + matrix_authentication_service_config_secrets_encryption: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + matrix_authentication_service_config_matrix_homeserver: molecule.local + matrix_authentication_service_config_matrix_secret: molecule_homeserver_secret_d3a58f + matrix_authentication_service_config_matrix_endpoint: http://matrix.molecule.local:8008 + + # Values unlike both the role and MAS defaults, so parsed configuration assertions + # can tell whether this scenario reached the rendered file. + matrix_authentication_service_config_http_trusted_proxies: + - 198.51.100.0/24 + - 2001:db8:1234::/48 + matrix_authentication_service_config_email_from_name: Molecule MAS + matrix_authentication_service_config_email_from_address: auth@molecule.local + matrix_authentication_service_config_account_displayname_change_allowed: false + matrix_authentication_service_config_account_password_registration_enabled: true + matrix_authentication_service_config_account_password_registration_email_required: false + matrix_authentication_service_config_account_password_recovery_enabled: true + matrix_authentication_service_config_account_login_with_email_allowed: true + matrix_authentication_service_config_passwords_minimum_complexity: 2 + + # Traefik itself is intentionally absent. The rendered label file and Docker's live + # labels prove the role composed the non-default routing contract correctly. + matrix_authentication_service_container_labels_traefik_entrypoints: web + matrix_authentication_service_container_labels_traefik_tls_certResolver: molecule-resolver + matrix_authentication_service_container_labels_public_main_priority: 742 + matrix_authentication_service_container_labels_public_compatibility_layer_enabled: true + matrix_authentication_service_container_labels_public_compatibility_layer_hostname: matrix.molecule.local + matrix_authentication_service_container_labels_public_compatibility_layer_priority: 743 + matrix_authentication_service_container_labels_traefik_additional_response_headers_custom: + X-Molecule-MAS: covered + matrix_authentication_service_container_labels_additional_labels: | + molecule.mas.coverage=enabled + env: + # Workaround for https://github.com/ansible/molecule/issues/4391 + ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/../..:/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:${ANSIBLE_HOME:-~/.ansible}/roles +scenario: + test_sequence: + - dependency + - cleanup + - destroy + - syntax + - create + - prepare + - converge + - idempotence + - verify + - cleanup + - destroy +verifier: + name: ansible diff --git a/roles/custom/matrix-authentication-service/molecule/default/prepare.yml b/roles/custom/matrix-authentication-service/molecule/default/prepare.yml new file mode 100644 index 000000000..0fb3a33e2 --- /dev/null +++ b/roles/custom/matrix-authentication-service/molecule/default/prepare.yml @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +- name: Prepare Matrix Authentication Service Molecule tests + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml" + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: true + tasks: + - name: Ensure apt cache is updated + ansible.builtin.apt: + update_cache: true + cache_valid_time: 600 + when: ansible_facts['os_family'] == 'Debian' + + - name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-requests + - fuse-overlayfs + state: present + + - name: Ensure Docker is installed + ansible.builtin.include_role: + name: ansible-role-docker + vars: + docker_daemon_options: + storage-driver: fuse-overlayfs + + - name: Ensure the matrix group exists + ansible.builtin.group: + name: "{{ matrix_group_name }}" + gid: "{{ matrix_user_gid }}" + state: present + + - name: Ensure the matrix user exists + ansible.builtin.user: + name: "{{ matrix_user_name }}" + uid: "{{ matrix_user_uid }}" + group: "{{ matrix_group_name }}" + create_home: false + system: true + state: present + + - name: Ensure the base data path exists + ansible.builtin.file: + path: "{{ matrix_base_data_path }}" + state: directory + owner: "{{ matrix_user_name }}" + group: "{{ matrix_group_name }}" + mode: "0750" + + - name: Ensure the private container network exists + ansible.builtin.command: + argv: + - docker + - network + - create + - "{{ matrix_authentication_service_container_network }}" + register: matrix_authentication_service_molecule_network + changed_when: matrix_authentication_service_molecule_network.rc == 0 + failed_when: + - matrix_authentication_service_molecule_network.rc != 0 + - "'already exists' not in matrix_authentication_service_molecule_network.stderr" + + - name: Ensure Postgres is running + ansible.builtin.include_tasks: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/postgres.yml" + vars: + molecule_shared_postgres_network: "{{ matrix_authentication_service_container_network }}" + molecule_shared_postgres_database: "{{ matrix_authentication_service_config_database_database }}" + molecule_shared_postgres_username: "{{ matrix_authentication_service_config_database_username }}" + molecule_shared_postgres_password: "{{ matrix_authentication_service_config_database_password }}" + + # MAS does not need a successful homeserver request to expose discovery, but providing + # its configured endpoint makes the scenario independent of future startup checks. + - name: Ensure the homeserver stub is running + ansible.builtin.include_tasks: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/tasks/homeserver-stub.yml" + vars: + molecule_shared_stub_network: "{{ matrix_authentication_service_container_network }}" + molecule_shared_stub_server_name: molecule.local diff --git a/roles/custom/matrix-authentication-service/molecule/default/requirements.yml b/roles/custom/matrix-authentication-service/molecule/default/requirements.yml new file mode 120000 index 000000000..1567a5392 --- /dev/null +++ b/roles/custom/matrix-authentication-service/molecule/default/requirements.yml @@ -0,0 +1 @@ +../../../../../molecule-shared/requirements.yml \ No newline at end of file diff --git a/roles/custom/matrix-authentication-service/molecule/default/verify.yml b/roles/custom/matrix-authentication-service/molecule/default/verify.yml new file mode 100644 index 000000000..cdc81313d --- /dev/null +++ b/roles/custom/matrix-authentication-service/molecule/default/verify.yml @@ -0,0 +1,538 @@ +# SPDX-FileCopyrightText: 2026 Slavi Pantaleev +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +--- +# MAS exits on a bad startup configuration rather than retrying in-process. The restart +# counter guards that failure mode; live health, OIDC, JWKS, and schema checks carry the +# stronger proof that the stable process is actually using what this role rendered. +- name: Verify Matrix Authentication Service + hosts: all + become: true + vars_files: + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/vars.yml" + - "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/../../../molecule-shared/playbook-context.yml" + gather_facts: false + vars: + matrix_authentication_service_config: >- + {{ matrix_authentication_service_config_file.content | b64decode | from_yaml }} + matrix_authentication_service_web_listener: >- + {{ matrix_authentication_service_config.http.listeners + | selectattr('name', 'equalto', 'web') | first | default({}) }} + matrix_authentication_service_internal_listener: >- + {{ matrix_authentication_service_config.http.listeners + | selectattr('name', 'equalto', 'internal') | first | default({}) }} + matrix_authentication_service_discovery_body: >- + {{ matrix_authentication_service_discovery_http.stdout_lines[:-1] | join('\n') }} + matrix_authentication_service_discovery: >- + {{ matrix_authentication_service_discovery_body | from_json }} + matrix_authentication_service_jwks_body: >- + {{ matrix_authentication_service_jwks_http.stdout_lines[:-1] | join('\n') }} + matrix_authentication_service_jwks: >- + {{ matrix_authentication_service_jwks_body | from_json }} + matrix_authentication_service_jwks_kids: >- + {{ matrix_authentication_service_jwks['keys'] | map(attribute='kid') | list }} + matrix_authentication_service_labels_lines: >- + {{ (matrix_authentication_service_labels_file.content | b64decode).splitlines() }} + matrix_authentication_service_runtime: >- + {{ (matrix_authentication_service_container_inspect.stdout | from_json) | first }} + matrix_authentication_service_config_mounts: >- + {{ matrix_authentication_service_runtime.Mounts + | selectattr('Destination', 'equalto', '/config.yaml') | list }} + matrix_authentication_service_keys_mounts: >- + {{ matrix_authentication_service_runtime.Mounts + | selectattr('Destination', 'equalto', '/keys') | list }} + + tasks: + # Read the pin from the role itself so Renovate changes what this assertion expects. + - name: Load the role's defaults under a separate name + ansible.builtin.include_vars: + file: "{{ lookup('env', 'MOLECULE_PROJECT_DIRECTORY') }}/defaults/main.yml" + name: matrix_authentication_service_role_defaults + + - name: Wait for the Matrix Authentication Service unit to become active + ansible.builtin.systemd_service: + name: matrix-authentication-service.service + register: matrix_authentication_service_service + until: matrix_authentication_service_service.status.ActiveState == 'active' + retries: 30 + delay: 5 + failed_when: false + + - name: Assert the Matrix Authentication Service process is stable + ansible.builtin.assert: + that: + - matrix_authentication_service_service.status.ActiveState == 'active' + - matrix_authentication_service_service.status.NRestarts is defined + - matrix_authentication_service_service.status.NRestarts | int == 0 + fail_msg: >- + matrix-authentication-service.service is + {{ matrix_authentication_service_service.status.ActiveState | default('unknown') }} + after {{ matrix_authentication_service_service.status.NRestarts | default('?') }} restart(s) + success_msg: "Matrix Authentication Service is active and has not restarted" + + # The internal listener binds loopback inside the MAS container. Sharing its network + # namespace is the only way to probe it without changing the deployment contract. + - name: Request the prefixed internal health endpoint + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network=container:matrix-authentication-service + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - http://[::1]:8081/molecule-auth/health + register: matrix_authentication_service_health_http + changed_when: false + until: "'HTTP_STATUS=200' in matrix_authentication_service_health_http.stdout" + retries: 24 + delay: 5 + failed_when: false + + - name: Assert the internal listener reports healthy + ansible.builtin.assert: + that: + - matrix_authentication_service_health_http.rc == 0 + - matrix_authentication_service_health_http.stdout_lines[-1] == 'HTTP_STATUS=200' + - matrix_authentication_service_health_http.stdout_lines[0] == 'ok' + fail_msg: >- + MAS did not report healthy on its prefixed, loopback-only internal listener + ({{ matrix_authentication_service_health_http.stdout | default('no output') }}) + success_msg: "MAS reports healthy on the prefixed internal listener" + + - name: Request the unprefixed internal health path + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network=container:matrix-authentication-service + - "{{ molecule_shared_image_curl }}" + - --silent + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - http://[::1]:8081/health + register: matrix_authentication_service_unprefixed_health_http + changed_when: false + failed_when: false + + - name: Assert the internal health resource exists only below the configured prefix + ansible.builtin.assert: + that: + - matrix_authentication_service_unprefixed_health_http.rc == 0 + - matrix_authentication_service_unprefixed_health_http.stdout == 'HTTP_STATUS=404' + fail_msg: "The unprefixed health path answered, so the configured prefix is not proven" + success_msg: "The internal listener rejects the unprefixed health path" + + # Probed through the private network because the role deliberately publishes no host port. + - name: Request the prefixed OpenID Provider discovery document + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_authentication_service_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - http://matrix-authentication-service:8080/molecule-auth/.well-known/openid-configuration + register: matrix_authentication_service_discovery_http + changed_when: false + until: "'HTTP_STATUS=200' in matrix_authentication_service_discovery_http.stdout" + retries: 24 + delay: 5 + failed_when: false + + - name: Assert MAS serves a real OpenID Provider discovery document + ansible.builtin.assert: + that: + - matrix_authentication_service_discovery_http.rc == 0 + - matrix_authentication_service_discovery_http.stdout_lines[-1] == 'HTTP_STATUS=200' + - matrix_authentication_service_discovery is mapping + - matrix_authentication_service_discovery.issuer == 'https://mas.molecule.local/molecule-auth/' + - matrix_authentication_service_discovery.authorization_endpoint == 'https://mas.molecule.local/molecule-auth/authorize' + - matrix_authentication_service_discovery.token_endpoint == 'https://mas.molecule.local/molecule-auth/oauth2/token' + - matrix_authentication_service_discovery.jwks_uri == 'https://mas.molecule.local/molecule-auth/oauth2/keys.json' + - matrix_authentication_service_discovery.userinfo_endpoint == 'https://mas.molecule.local/molecule-auth/oauth2/userinfo' + - "'openid' in matrix_authentication_service_discovery.scopes_supported" + - "'authorization_code' in matrix_authentication_service_discovery.grant_types_supported" + - "'S256' in matrix_authentication_service_discovery.code_challenge_methods_supported" + fail_msg: >- + MAS did not return the configured, prefixed OIDC discovery contract + ({{ matrix_authentication_service_discovery_http.stdout | default('no output') }}) + success_msg: "MAS advertises its configured prefixed OpenID Provider contract" + + - name: Request the unprefixed discovery path + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_authentication_service_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --output + - /dev/null + - --write-out + - "HTTP_STATUS=%{http_code}" + - http://matrix-authentication-service:8080/.well-known/openid-configuration + register: matrix_authentication_service_unprefixed_discovery_http + changed_when: false + failed_when: false + + - name: Assert discovery exists only below the configured prefix + ansible.builtin.assert: + that: + - matrix_authentication_service_unprefixed_discovery_http.rc == 0 + - matrix_authentication_service_unprefixed_discovery_http.stdout == 'HTTP_STATUS=404' + fail_msg: "The unprefixed discovery path answered, so the configured prefix is not proven" + success_msg: "The public listener rejects the unprefixed discovery path" + + - name: Request the JWKS advertised by discovery + ansible.builtin.command: + argv: + - docker + - run + - --rm + - --network={{ matrix_authentication_service_container_network }} + - "{{ molecule_shared_image_curl }}" + - --silent + - --show-error + - --write-out + - "\nHTTP_STATUS=%{http_code}" + - http://matrix-authentication-service:8080/molecule-auth/oauth2/keys.json + register: matrix_authentication_service_jwks_http + changed_when: false + failed_when: false + + - name: Assert JWKS exposes all role-managed signing keys + ansible.builtin.assert: + that: + - matrix_authentication_service_jwks_http.rc == 0 + - matrix_authentication_service_jwks_http.stdout_lines[-1] == 'HTTP_STATUS=200' + - matrix_authentication_service_jwks is mapping + - matrix_authentication_service_jwks['keys'] is sequence + - matrix_authentication_service_jwks['keys'] | length == 4 + - "'default-rsa' in matrix_authentication_service_jwks_kids" + - "'default-ecdsa-p256' in matrix_authentication_service_jwks_kids" + - "'default-ecdsa-p384' in matrix_authentication_service_jwks_kids" + - "'default-ecdsa-k256' in matrix_authentication_service_jwks_kids" + - (matrix_authentication_service_jwks['keys'] | selectattr('kid', 'equalto', 'default-rsa') | first).kty == 'RSA' + - (matrix_authentication_service_jwks['keys'] | selectattr('kid', 'equalto', 'default-rsa') | first).n | length > 100 + - (matrix_authentication_service_jwks['keys'] | selectattr('kid', 'equalto', 'default-ecdsa-p256') | first).crv == 'P-256' + - (matrix_authentication_service_jwks['keys'] | selectattr('kid', 'equalto', 'default-ecdsa-p384') | first).crv == 'P-384' + - (matrix_authentication_service_jwks['keys'] | selectattr('kid', 'equalto', 'default-ecdsa-k256') | first).crv == 'secp256k1' + fail_msg: "MAS did not derive the expected public JWKS from all four managed keys" + success_msg: "MAS exposes all four role-managed signing keys in its public JWKS" + + - name: Read the rendered MAS configuration + ansible.builtin.slurp: + src: "{{ matrix_authentication_service_config_path }}/config.yaml" + register: matrix_authentication_service_config_file + + - name: Read the rendered MAS configuration identity + ansible.builtin.stat: + path: "{{ matrix_authentication_service_config_path }}/config.yaml" + register: matrix_authentication_service_config_stat + + - name: Assert the parsed listener and public URL configuration + ansible.builtin.assert: + that: + - matrix_authentication_service_config.http.listeners | length == 2 + - matrix_authentication_service_web_listener.resources | map(attribute='name') | list == ['discovery', 'human', 'oauth', 'compat', 'graphql', 'assets'] + - "matrix_authentication_service_web_listener.binds == [{'address': '[::]:8080'}]" + - matrix_authentication_service_web_listener.prefix == '/molecule-auth' + - not matrix_authentication_service_web_listener.proxy_protocol + - "matrix_authentication_service_internal_listener.resources == [{'name': 'health'}]" + - "matrix_authentication_service_internal_listener.binds == [{'host': 'localhost', 'port': 8081}]" + - matrix_authentication_service_internal_listener.prefix == '/molecule-auth' + - matrix_authentication_service_config.http.public_base == 'https://mas.molecule.local/molecule-auth/' + - matrix_authentication_service_config.http.issuer == 'https://mas.molecule.local/molecule-auth/' + - matrix_authentication_service_config.http.trusted_proxies == ['198.51.100.0/24', '2001:db8:1234::/48'] + fail_msg: "The parsed MAS listener or public URL configuration is incorrect" + success_msg: "The parsed MAS configuration carries the non-default listener contract" + + - name: Assert the parsed Postgres pool configuration + ansible.builtin.assert: + that: + - matrix_authentication_service_config.database.host == matrix_authentication_service_config_database_host + - matrix_authentication_service_config.database.port == matrix_authentication_service_config_database_port + - matrix_authentication_service_config.database.username == matrix_authentication_service_config_database_username + - matrix_authentication_service_config.database.password == matrix_authentication_service_config_database_password + - matrix_authentication_service_config.database.database == matrix_authentication_service_config_database_database + - matrix_authentication_service_config.database.ssl_mode == 'disable' + - matrix_authentication_service_config.database.max_connections == 7 + - matrix_authentication_service_config.database.min_connections == 2 + - matrix_authentication_service_config.database.connect_timeout == 17 + fail_msg: "The parsed MAS configuration does not point at scenario Postgres" + success_msg: "The parsed MAS configuration points at scenario Postgres" + no_log: true + + - name: Assert the parsed Matrix and cryptographic configuration + ansible.builtin.assert: + that: + - matrix_authentication_service_config.matrix.homeserver == 'molecule.local' + - matrix_authentication_service_config.matrix.endpoint == 'http://matrix.molecule.local:8008' + - matrix_authentication_service_config.matrix.secret == matrix_authentication_service_config_matrix_secret + - matrix_authentication_service_config.secrets.encryption == matrix_authentication_service_config_secrets_encryption + - matrix_authentication_service_config.secrets['keys'] | map(attribute='kid') | list == ['default-rsa', 'default-ecdsa-p256', 'default-ecdsa-p384', 'default-ecdsa-k256'] + - matrix_authentication_service_config.secrets['keys'] | map(attribute='key_file') | list == ['/keys/rsa-2048.priv.pem', '/keys/ecdsa-p256.priv.pem', '/keys/ecdsa-p384.priv.pem', '/keys/ecdsa-k256.priv.pem'] + fail_msg: "The parsed MAS Matrix or signing-key configuration is incorrect" + success_msg: "The parsed MAS configuration carries its Matrix secrets and managed keys" + no_log: true + + - name: Assert the parsed non-default account behavior + ansible.builtin.assert: + that: + - not matrix_authentication_service_config.account.displayname_change_allowed + - matrix_authentication_service_config.account.password_registration_enabled + - not matrix_authentication_service_config.account.password_registration_email_required + - matrix_authentication_service_config.account.password_recovery_enabled + - matrix_authentication_service_config.account.login_with_email_allowed + - matrix_authentication_service_config.passwords.minimum_complexity == 2 + - matrix_authentication_service_config.email.from == '"Molecule MAS" ' + - matrix_authentication_service_config.email.transport == 'blackhole' + - matrix_authentication_service_config.clients == [] + - "'upstream_oauth2' not in matrix_authentication_service_config" + - "'captcha' not in matrix_authentication_service_config" + fail_msg: "The parsed MAS configuration does not carry the scenario account behavior" + success_msg: "The parsed MAS configuration carries the non-default account behavior" + + - name: Assert the rendered configuration file identity + ansible.builtin.assert: + that: + - matrix_authentication_service_config_stat.stat.uid | int == matrix_user_uid | int + - matrix_authentication_service_config_stat.stat.gid | int == matrix_user_gid | int + - matrix_authentication_service_config_stat.stat.mode == '0644' + fail_msg: "config.yaml has the wrong ownership or mode" + success_msg: "config.yaml has the playbook identity and expected mode" + + - name: Read the managed signing-key identities + ansible.builtin.stat: + path: "{{ matrix_authentication_service_data_keys_path }}/{{ key_file }}" + loop: + - rsa-2048.priv.pem + - ecdsa-p256.priv.pem + - ecdsa-p384.priv.pem + - ecdsa-k256.priv.pem + loop_control: + loop_var: key_file + register: matrix_authentication_service_key_stats + + - name: Assert every managed signing key is non-empty and private + ansible.builtin.assert: + that: + - key_stat.stat.exists + - key_stat.stat.isreg + - key_stat.stat.size | int > 200 + - key_stat.stat.uid | int == matrix_user_uid | int + - key_stat.stat.gid | int == matrix_user_gid | int + - key_stat.stat.mode == '0600' + fail_msg: "A managed MAS signing key is missing, empty, exposed, or misowned" + success_msg: "The managed MAS signing key is present and private" + loop: "{{ matrix_authentication_service_key_stats.results }}" + loop_control: + loop_var: key_stat + label: "{{ key_stat.key_file }}" + + - name: Validate every managed signing key with OpenSSL + ansible.builtin.command: + argv: + - openssl + - pkey + - -in + - "{{ matrix_authentication_service_data_keys_path }}/{{ key_file }}" + - -check + - -noout + loop: + - rsa-2048.priv.pem + - ecdsa-p256.priv.pem + - ecdsa-p384.priv.pem + - ecdsa-k256.priv.pem + loop_control: + loop_var: key_file + register: matrix_authentication_service_key_validation + changed_when: false + + - name: Assert OpenSSL accepts every managed signing key + ansible.builtin.assert: + that: + - key_validation.rc == 0 + fail_msg: "OpenSSL rejected a role-managed MAS signing key" + success_msg: "OpenSSL accepts the role-managed MAS signing key" + loop: "{{ matrix_authentication_service_key_validation.results }}" + loop_control: + loop_var: key_validation + label: "{{ key_validation.key_file }}" + + # These tables can only exist after MAS has reached the named host, authenticated as + # the non-default role, selected the non-default database, and run genuine migrations. + - name: List the tables MAS created in Postgres + ansible.builtin.command: + argv: + - docker + - exec + - matrix-postgres-molecule + - psql + - --username={{ matrix_authentication_service_config_database_username }} + - --dbname={{ matrix_authentication_service_config_database_database }} + - --tuples-only + - --no-align + - --command=SELECT tablename FROM pg_tables WHERE schemaname = 'public' + register: matrix_authentication_service_tables + changed_when: false + + - name: Count successful MAS migrations in Postgres + ansible.builtin.command: + argv: + - docker + - exec + - matrix-postgres-molecule + - psql + - --username={{ matrix_authentication_service_config_database_username }} + - --dbname={{ matrix_authentication_service_config_database_database }} + - --tuples-only + - --no-align + - --command=SELECT count(*) FROM _sqlx_migrations WHERE success + register: matrix_authentication_service_migrations + changed_when: false + + - name: Assert MAS genuinely migrated the configured Postgres database + ansible.builtin.assert: + that: + - matrix_authentication_service_tables.rc == 0 + - "'_sqlx_migrations' in matrix_authentication_service_table_names" + - "'users' in matrix_authentication_service_table_names" + - "'user_sessions' in matrix_authentication_service_table_names" + - "'oauth2_clients' in matrix_authentication_service_table_names" + - "'oauth2_access_tokens' in matrix_authentication_service_table_names" + - "'upstream_oauth_providers' in matrix_authentication_service_table_names" + - matrix_authentication_service_table_names | length > 30 + - matrix_authentication_service_migrations.rc == 0 + - matrix_authentication_service_migrations.stdout | trim | int > 100 + fail_msg: >- + MAS did not genuinely migrate {{ matrix_authentication_service_config_database_database }} + ({{ matrix_authentication_service_table_names | length }} tables, + {{ matrix_authentication_service_migrations.stdout | trim | default('?') }} migrations) + success_msg: "MAS genuinely migrated the configured Postgres database" + vars: + matrix_authentication_service_table_names: >- + {{ matrix_authentication_service_tables.stdout_lines | select | list }} + + - name: Read the Traefik labels the role rendered + ansible.builtin.slurp: + src: "{{ matrix_authentication_service_config_path }}/labels" + register: matrix_authentication_service_labels_file + + - name: Assert the rendered labels carry the non-default public routing contract + ansible.builtin.assert: + that: + - "'traefik.enable=true' in matrix_authentication_service_labels_lines" + - "'traefik.docker.network=matrix-authentication-service-molecule' in matrix_authentication_service_labels_lines" + - "'traefik.http.services.matrix-authentication-service.loadbalancer.server.port=8080' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers.matrix-authentication-service.rule=Host(`mas.molecule.local`) && PathPrefix(`/molecule-auth`)' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers.matrix-authentication-service.priority=742' in matrix_authentication_service_labels_lines" + - "'traefik.http.middlewares.matrix-authentication-service-slashless-redirect.redirectregex.regex=(/molecule-auth)$' in matrix_authentication_service_labels_lines" + - "'traefik.http.middlewares.matrix-authentication-service-add-headers.headers.customresponseheaders.X-Molecule-MAS=covered' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers.matrix-authentication-service.middlewares=matrix-authentication-service-slashless-redirect,matrix-authentication-service-add-headers' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers.matrix-authentication-service.entrypoints=web' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers.matrix-authentication-service.tls=false' in matrix_authentication_service_labels_lines" + - "'molecule.mas.coverage=enabled' in matrix_authentication_service_labels_lines" + fail_msg: "The rendered labels do not carry the configured public MAS route" + success_msg: "The rendered labels carry the configured public MAS route" + + - name: Assert the rendered labels carry the compatibility-layer route + ansible.builtin.assert: + that: + - "'traefik.http.middlewares.matrix-authentication-service-add-prefix.addprefix.prefix=/molecule-auth' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.rule=Host(`matrix.molecule.local`) && PathRegexp(`^/_matrix/client/(?P([^/]+))/(?P(login|logout|refresh))`)' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.priority=743' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.middlewares=matrix-authentication-service-add-prefix' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.entrypoints=web' in matrix_authentication_service_labels_lines" + - "'traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.tls=false' in matrix_authentication_service_labels_lines" + fail_msg: "The rendered labels do not carry the configured compatibility route" + success_msg: "The rendered labels carry the configured compatibility-layer route" + + - name: Inspect the running MAS container + ansible.builtin.command: + argv: + - docker + - container + - inspect + - matrix-authentication-service + register: matrix_authentication_service_container_inspect + changed_when: false + + - name: Assert the running container uses the exact image pinned by the role + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.Config.Image == 'ghcr.io/element-hq/matrix-authentication-service:' + matrix_authentication_service_role_defaults.matrix_authentication_service_version + fail_msg: "The running MAS container does not use the exact role-pinned image" + success_msg: "The running MAS container uses the exact role-pinned image" + + - name: Assert the running container uses the playbook identity + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.Config.User == (matrix_user_uid | string) + ':' + (matrix_user_gid | string) + fail_msg: "The running MAS container does not use the playbook UID and GID" + success_msg: "The running MAS container uses the playbook UID and GID" + + - name: Assert the running container security isolation + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.HostConfig.ReadonlyRootfs is sameas true + - matrix_authentication_service_runtime.HostConfig.CapDrop == ['ALL'] + - not matrix_authentication_service_runtime.HostConfig.Privileged + fail_msg: "The running MAS container lacks read-only, capability-dropped isolation" + success_msg: "The running MAS container has read-only, capability-dropped isolation" + + - name: Assert the running container mounts only configuration and keys read-only + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.Mounts | length == 2 + - matrix_authentication_service_config_mounts | length == 1 + - matrix_authentication_service_config_mounts[0].RW is sameas false + - matrix_authentication_service_keys_mounts | length == 1 + - matrix_authentication_service_keys_mounts[0].RW is sameas false + fail_msg: "The running MAS container's config or key mount contract is incorrect" + success_msg: "The running MAS container mounts only configuration and keys read-only" + + - name: Assert the running container has only its dedicated network + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.NetworkSettings.Networks is mapping + - matrix_authentication_service_runtime.NetworkSettings.Networks | length == 1 + - matrix_authentication_service_container_network in matrix_authentication_service_runtime.NetworkSettings.Networks + fail_msg: "The running MAS container has unexpected network attachments" + success_msg: "The running MAS container has only its dedicated network" + + - name: Assert the running container publishes no host ports + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.HostConfig.PortBindings | default({}, true) | length == 0 + - matrix_authentication_service_runtime.NetworkSettings.Ports | default({}, true) | length == 0 + - not matrix_authentication_service_runtime.HostConfig.PublishAllPorts + fail_msg: "The running MAS container unexpectedly publishes a host port" + success_msg: "The running MAS container publishes no host ports" + + - name: Assert Docker accepted the role's exact routing labels + ansible.builtin.assert: + that: + - matrix_authentication_service_runtime.Config.Labels is mapping + - matrix_authentication_service_runtime.Config.Labels['molecule.mas.coverage'] == 'enabled' + - matrix_authentication_service_runtime.Config.Labels['traefik.docker.network'] == 'matrix-authentication-service-molecule' + - matrix_authentication_service_runtime.Config.Labels['traefik.http.services.matrix-authentication-service.loadbalancer.server.port'] == '8080' + - matrix_authentication_service_runtime.Config.Labels['traefik.http.routers.matrix-authentication-service.rule'] == 'Host(`mas.molecule.local`) && PathPrefix(`/molecule-auth`)' + - matrix_authentication_service_runtime.Config.Labels['traefik.http.routers.matrix-authentication-service.priority'] == '742' + - matrix_authentication_service_runtime.Config.Labels['traefik.http.routers._internalmatrix-authentication-service-public-compatibility-layer.priority'] == '743' + - matrix_authentication_service_runtime.Config.Labels['traefik.http.middlewares.matrix-authentication-service-add-headers.headers.customresponseheaders.X-Molecule-MAS'] == 'covered' + fail_msg: "Docker did not attach the exact labels rendered by the role" + success_msg: "Docker attached the exact routing labels rendered by the role"