Files
computer-playbook/tests/unit/python/roles/web-app-openbao/test_credential_rotation.py
T
kevinveenbirkenbach 7035169500 Merge branch 'main' into feature/store-i18n
# Conflicts:
#	roles/web-app-moodle/files/playwright/test-litellm-gateway.js
#	roles/web-app-moodle/tasks/04_oidc.yml
#	roles/web-app-moodle/templates/nginx-moodle.conf.j2
#	roles/web-app-moodle/vars/main.yml
#	roles/web-app-pixelfed/meta/services.yml
2026-10-08 02:34:58 +02:00

435 lines
16 KiB
Python

"""Guards for the per-deploy credential rotation path of web-app-openbao.
The platform regenerates generated-algorithm credentials on every deploy, so the
AppRole a run is handed differs from the one the running instance was configured
with. The seal key is pinned out of that and moves only when an operator changes
it, but the same transition then has to carry it. Each part fails silently
rather than loudly when broken -- a sealed node and an unusable AppRole look
identical to ordinary drift. These tests pin them.
"""
from __future__ import annotations
import unittest
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SECRETS
from . import PROJECT_ROOT
ROLE_DIR = PROJECT_ROOT / "roles/web-app-openbao"
TASKS_DIR = ROLE_DIR / "tasks"
HCL_CONTEXT = {
"OPENBAO_LOG_LEVEL": "info",
"OPENBAO_DATA_DOCKER": "/openbao/file",
"OPENBAO_NAME": "openbao",
"container_port": "8200",
"OPENBAO_SEAL_KEY_ID": "0123456789abcdef",
"OPENBAO_SEAL_KEY_PREVIOUS_ID": "fedcba9876543210",
"OPENBAO_BASE_URL": "https://bao.example.com",
}
ENV_CONTEXT = {
"OPENBAO_SEAL_KEY": "current-key",
"OPENBAO_SEAL_KEY_PREVIOUS": "previous-key",
"container_port": "8200",
}
def jinja_env(searchpath) -> Environment:
env = Environment(
loader=FileSystemLoader(str(searchpath)),
undefined=StrictUndefined,
autoescape=select_autoescape(default_for_string=False),
)
env.filters["bool"] = bool
env.filters["dotenv_quote"] = lambda value: f'"{value}"'
return env
def tasks_of(name: str) -> list[dict]:
return load_yaml_any(str(TASKS_DIR / name))
def leaf_tasks_of(name: str) -> list[dict]:
"""Leaf tasks of a file, each carrying the conditions it inherits.
A guard shared by consecutive siblings lives on a `block:`, so a leaf's
own `when:` is only part of what gates it. Ansible prepends the parent's
conditions, and so does this.
Returns: leaves whose `when` is the accumulated condition list, absent
when nothing gates the task.
"""
def walk(tasks: list[dict], inherited: list[str]) -> list[dict]:
leaves: list[dict] = []
for task in tasks or []:
own = task.get("when", [])
own = [own] if isinstance(own, str) else list(own)
conditions = inherited + own
if isinstance(task.get("block"), list):
leaves.extend(walk(task["block"], conditions))
continue
leaf = {k: v for k, v in task.items() if k != "when"}
if conditions:
leaf["when"] = conditions
leaves.append(leaf)
return leaves
return walk(load_yaml_any(str(TASKS_DIR / name)), [])
def guarded_blocks_of(name: str) -> list[dict]:
"""Every `block:` task of a file, outermost first.
Returns: each block with `when` replaced by the conditions that actually
gate it, its own plus every ancestor's, so a guard hoisted onto a parent
still reads as guarding the block below it.
"""
def walk(tasks: list[dict], inherited: list[str]) -> list[dict]:
found: list[dict] = []
for task in tasks or []:
if not isinstance(task.get("block"), list):
continue
own = task.get("when", [])
own = [own] if isinstance(own, str) else list(own)
conditions = inherited + own
found.append({**task, "when": conditions})
found.extend(walk(task["block"], conditions))
return found
return walk(load_yaml_any(str(TASKS_DIR / name)), [])
class TestWhichCredentialsRotate(unittest.TestCase):
"""The seal key survives a deploy; the AppRole does not.
The static seal carries one generation of slack, and rotating every deploy
spends it continuously: a run that skips a generation leaves the raft store
readable only from a volume backup plus the key that wrapped it. The
AppRole is the opposite case -- the live authentication path, whose
rotation the fallback login in 01_init.yml exists to absorb.
"""
def setUp(self):
self.credentials = load_yaml_any(str(ROLE_DIR / ROLE_FILE_META_SECRETS))[
"credentials"
]
def test_the_seal_key_is_pinned(self):
self.assertIs(self.credentials["seal_key"].get("rotatable"), False)
def test_both_approle_credentials_still_rotate(self):
for name in ("approle_role_id", "approle_secret_id"):
with self.subTest(credential=name):
self.assertIsNot(self.credentials[name].get("rotatable"), False)
class TestSealStanzaRotation(unittest.TestCase):
def _render(self, rotating: bool) -> str:
env = jinja_env(ROLE_DIR / "templates")
return env.get_template("openbao.hcl.j2").render(
OPENBAO_SEAL_ROTATING=rotating, **HCL_CONTEXT
)
def test_the_previous_key_is_offered_while_the_keys_differ(self):
rendered = self._render(True)
self.assertIn('previous_key_id = "fedcba9876543210"', rendered)
self.assertIn('previous_key = "env://OPENBAO_SEAL_KEY_PREVIOUS"', rendered)
def test_no_previous_key_is_offered_on_a_first_deploy(self):
rendered = self._render(False)
self.assertNotIn("previous_key", rendered)
self.assertIn('current_key_id = "0123456789abcdef"', rendered)
class TestEnvCarriesThePreviousKey(unittest.TestCase):
"""The shared sys-svc-container include needs a live Ansible context.
Only the role's own lines are rendered here; the include is dropped, which
leaves the conditional under test intact.
"""
def _render(self, rotating: bool) -> str:
source = read_text(str(ROLE_DIR / "templates/env.j2"))
body = "\n".join(
line for line in source.splitlines() if "{% include" not in line
)
env = jinja_env(ROLE_DIR / "templates")
return env.from_string(body).render(
OPENBAO_SEAL_ROTATING=rotating, **ENV_CONTEXT
)
def test_the_previous_key_reaches_the_container_while_rotating(self):
rendered = self._render(True)
self.assertIn('OPENBAO_SEAL_KEY_PREVIOUS="previous-key"', rendered)
self.assertIn('OPENBAO_SEAL_KEY="current-key"', rendered)
def test_no_previous_key_is_exported_on_a_first_deploy(self):
rendered = self._render(False)
self.assertNotIn("OPENBAO_SEAL_KEY_PREVIOUS", rendered)
self.assertIn('OPENBAO_SEAL_KEY="current-key"', rendered)
class TestAppliedStateIsReadBeforeTheConfigIsRendered(unittest.TestCase):
def test_the_state_read_precedes_the_backend_include(self):
"""env.j2 is rendered by sys-stk-backend.
Reading the applied state after that include leaves the container
without the previous seal key, and the node comes up sealed with no way
to decrypt its own store.
"""
tasks = tasks_of("00_core.yml")
state_read = next(
i
for i, task in enumerate(tasks)
if task.get("ansible.builtin.slurp", {})
.get("src", "")
.endswith("OPENBAO_APPLIED_STATE_HOST }}")
)
backend = next(
i
for i, task in enumerate(tasks)
if task.get("include_role", {}).get("name") == "sys-stk-backend"
)
self.assertLess(state_read, backend)
class TestAppRoleLoginToleratesRotation(unittest.TestCase):
def setUp(self):
self.tasks = leaf_tasks_of("01_init.yml")
def test_the_inventory_login_does_not_abort_the_play(self):
login = next(
task
for task in self.tasks
if task.get("register") == "openbao_approle_login"
)
self.assertIs(login.get("failed_when"), False)
def test_a_fallback_login_uses_the_previously_applied_credentials(self):
fallback = next(
task
for task in self.tasks
if task.get("register") == "openbao_approle_login_previous"
)
self.assertIs(
fallback.get("failed_when"),
False,
"a failing fallback must reach the guard, not abort with a raw 403",
)
self.assertIn(
"OPENBAO_APPLIED.approle_role_id",
fallback["ansible.builtin.shell"],
)
self.assertIn(
"OPENBAO_APPLIED.approle_secret_id",
fallback["args"]["stdin"],
)
def test_the_stored_token_is_whichever_login_succeeded(self):
"""A regression here re-introduces the bug this path exists to fix.
Storing openbao_approle_login.stdout feeds the token helper an empty
string on every rotated deploy, and reconciliation then fails on the
first write instead of on the login.
"""
store = next(
task
for task in self.tasks
if "bao login -no-print" in str(task.get("ansible.builtin.shell", ""))
)
self.assertIn("openbao_approle_token", store["args"]["stdin"])
def test_neither_credential_being_accepted_fails_loudly(self):
self.assertTrue(
any("ansible.builtin.fail" in task for task in self.tasks),
"a rotated-out instance must fail with a message, not a bare 403",
)
def test_the_rotation_pass_runs_before_the_token_is_dropped(self):
rotate = next(
i
for i, task in enumerate(self.tasks)
if task.get("ansible.builtin.include_tasks", {}) == "06_rotate.yml"
)
drop = next(
i
for i, task in enumerate(self.tasks)
if "token revoke -self" in str(task.get("ansible.builtin.shell", ""))
)
self.assertLess(rotate, drop)
class TestTheSecretIdSwapIsGuarded(unittest.TestCase):
"""Re-registering an unchanged SecretID answers 500, and the old one must go.
`custom-secret-id` is not idempotent: writing a SecretID the role already
holds fails with `SecretID is already registered`, so an unguarded re-pin
breaks every deploy that did not rotate. Leaving the previous SecretID
registered is the opposite failure -- the rotation would revoke nothing.
"""
def setUp(self):
self.tasks = leaf_tasks_of("06_rotate.yml")
def _guarded(self, fragment: str) -> dict:
return next(
task
for task in self.tasks
if fragment in str(task.get("ansible.builtin.shell", ""))
)
def test_the_new_secret_id_is_registered_only_while_rotating(self):
task = self._guarded("custom-secret-id")
self.assertEqual(task["when"], ["OPENBAO_SECRET_ID_ROTATING | bool"])
self.assertIn("OPENBAO_APPROLE_SECRET_ID", task["args"]["stdin"])
def test_the_previous_secret_id_is_destroyed_while_rotating(self):
task = self._guarded("secret-id/destroy")
self.assertEqual(task["when"], ["OPENBAO_SECRET_ID_ROTATING | bool"])
self.assertIn("OPENBAO_SECRET_ID_REGISTERED", task["args"]["stdin"])
def test_the_role_id_is_re_pinned_unconditionally(self):
task = self._guarded("role-id")
self.assertNotIn("when", task)
class TestTheRecoveryKeyIsKeptAndUsable(unittest.TestCase):
"""The one authority that does not rotate, and the way back it buys.
`bao operator init` issues a recovery key once and nothing reissues it.
Dropping it leaves the AppRole as the only administrative path, so a reset,
a replaced manager, or a run that dies between the two pins in
06_rotate.yml locks the instance out for good while its data stays
readable. sys-token-store holds it rather than the inventory, which no role
writes.
"""
def setUp(self):
self.tasks = tasks_of("01_init.yml")
def _recovery_block(self) -> dict:
return next(
block
for block in guarded_blocks_of("01_init.yml")
if any(
child.get("register") == "openbao_approle_login_recovered"
for child in block["block"]
)
)
def _init_block(self) -> list[dict]:
return next(
task["block"]
for task in self.tasks
if "block" in task and "openbao_initialized" in str(task.get("when", ""))
)
def test_the_key_is_persisted_on_the_deploy_that_creates_it(self):
store = next(
task
for task in self._init_block()
if task.get("ansible.builtin.include_role", {}).get("name")
== "sys-token-store"
)
self.assertEqual(
store["ansible.builtin.include_role"]["tasks_from"], "write.yml"
)
self.assertIn("recovery_keys_b64", store["vars"]["sys_token_store_token"])
def test_the_key_is_loaded_before_the_logins_that_may_need_it(self):
load = next(
i
for i, task in enumerate(self.tasks)
if task.get("ansible.builtin.include_role", {}).get("tasks_from")
== "01_load.yml"
)
login = next(
i
for i, task in enumerate(self.tasks)
if task.get("register") == "openbao_approle_login"
)
self.assertLess(load, login)
def test_recovery_runs_only_when_both_logins_failed_and_a_key_exists(self):
conditions = " ".join(self._recovery_block()["when"])
self.assertIn("openbao_approle_login.rc != 0", conditions)
self.assertIn("openbao_approle_login_previous.rc", conditions)
self.assertIn("OPENBAO_RECOVERY_KEY", conditions)
def test_the_rebuilt_approle_is_the_one_the_inventory_holds(self):
"""Recovering to the recorded pair would strand the deploy again.
02_auth.yml re-pins both halves to the inventory values, so the login
that proves recovery worked has to use those, not the applied ones.
"""
login = next(
task
for task in self._recovery_block()["block"]
if task.get("register") == "openbao_approle_login_recovered"
)
self.assertIn("OPENBAO_APPROLE_ROLE_ID", login["ansible.builtin.shell"])
self.assertIn("OPENBAO_APPROLE_SECRET_ID", login["args"]["stdin"])
def test_the_recovery_path_ends_by_rebuilding_the_approle(self):
tasks = tasks_of("utils/recover.yml")
self.assertEqual(
tasks[-1]["ansible.builtin.include_tasks"],
"../02_auth.yml",
"minting a root token without re-pinning leaves the deploy no better off",
)
def test_a_rejected_recovery_key_fails_loudly(self):
tasks = tasks_of("utils/recover.yml")
guard = next(task for task in tasks if "ansible.builtin.fail" in task)
self.assertIn("complete", guard["when"])
class TestTheRegisteredSecretIdComesFromTheLogin(unittest.TestCase):
def test_it_is_the_credential_that_was_just_accepted(self):
"""Deriving it from the state file instead would be wrong.
On a deploy whose inventory credentials still work, the state file may
hold an older value; destroying that would leave the live SecretID
registered and revoke nothing.
"""
tasks = tasks_of("01_init.yml")
keep = next(
task
for task in tasks
if "openbao_secret_id_registered"
in task.get("ansible.builtin.set_fact", {})
)
expression = keep["ansible.builtin.set_fact"]["openbao_secret_id_registered"]
self.assertIn("openbao_approle_login.rc == 0", expression)
self.assertIn("OPENBAO_APPROLE_SECRET_ID", expression)
self.assertIn("OPENBAO_APPLIED.approle_secret_id", expression)
class TestTheAppliedStateIsRecorded(unittest.TestCase):
def test_every_credential_the_next_deploy_needs_is_written(self):
tasks = tasks_of("06_rotate.yml")
record = next(task for task in tasks if "ansible.builtin.copy" in task)
applied = record["vars"]["openbao_applied_now"]
self.assertEqual(
sorted(applied),
["approle_role_id", "approle_secret_id", "seal_key"],
)
self.assertEqual(record["ansible.builtin.copy"]["mode"], "0600")
self.assertEqual(record["ansible.builtin.copy"]["owner"], "root")
def test_the_record_is_the_last_thing_the_rotation_does(self):
"""Recording before the pins would claim credentials that never applied."""
tasks = tasks_of("06_rotate.yml")
record = next(
i for i, task in enumerate(tasks) if "ansible.builtin.copy" in task
)
self.assertEqual(record, len(tasks) - 1)