"""Console authorization, redaction, and audit model tests (#633). Covers each acceptance criterion and each required test named in the issue: * AC1 — RBAC matrix and privileged-action list. * AC2 — redaction rules, unit-tested against sample payloads. * AC3 — audit event schema with required fields and retention defaults. * AC4 — Phase 2 integration points. * AC5 — local-dev mode with explicit insecurity warnings. Required tests: redaction units (token, keychain, password patterns), default-deny for unauthenticated write stubs, and audit record creation for a simulated privileged preview. """ from __future__ import annotations import datetime import json import os import pathlib import sys import tempfile import unittest from starlette.testclient import TestClient sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from task_capability_map import TASK_CAPABILITY_MAP # noqa: E402 from webui import console_audit, console_authz # noqa: E402 from webui.app import create_app # noqa: E402 from webui.console_redaction import ( # noqa: E402 REDACTED, redact_payload, redact_text, redaction_policy, scan_for_secrets, ) DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs" AUTHZ_DOC = DOCS / "webui-authz-audit.md" def _principal(role: str) -> console_authz.Principal: return console_authz.Principal( subject=f"{role}@example.com", role=role, identity_source=console_authz.IDENTITY_ACCESS_PROXY, authenticated=True, ) class TestRoleMatrix(unittest.TestCase): """AC1 — the written RBAC matrix and privileged-action list.""" def test_roles_are_ordered_least_to_most_authority(self): self.assertEqual( console_authz.ROLE_ORDER, ("viewer", "operator", "controller", "admin"), ) def test_every_role_has_a_description(self): for role in console_authz.ROLE_ORDER: with self.subTest(role=role): self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip()) def test_higher_roles_inherit_lower_role_actions(self): matrix = { entry["role"]: set(entry["permitted_actions"]) for entry in console_authz.rbac_matrix()["roles"] } for lower, higher in zip( console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:] ): with self.subTest(lower=lower, higher=higher): self.assertTrue(matrix[lower].issubset(matrix[higher])) def test_viewer_holds_no_write_action(self): matrix = { entry["role"]: set(entry["permitted_actions"]) for entry in console_authz.rbac_matrix()["roles"] } self.assertEqual(matrix["viewer"], set()) def test_privileged_action_list_is_non_empty_and_classified(self): privileged = console_authz.privileged_actions() self.assertTrue(privileged) ids = {action.action_id for action in privileged} # Merge and branch deletion are the canonical privileged pair. self.assertIn("merge_pr", ids) self.assertIn("delete_branch", ids) def test_merge_and_delete_require_dual_control_and_break_glass(self): for action_id in ("merge_pr", "delete_branch"): with self.subTest(action=action_id): action = console_authz.get_action(action_id) self.assertTrue(action.dual_control) self.assertTrue(action.break_glass) self.assertTrue(action.requires_confirmation) def test_every_write_action_requires_confirmation(self): for action in console_authz.ACTIONS.values(): with self.subTest(action=action.action_id): self.assertTrue(action.requires_confirmation) def test_delete_branch_is_admin_only(self): self.assertEqual( console_authz.get_action("delete_branch").minimum_role, console_authz.ADMIN, ) def test_actions_map_to_real_mcp_capability_vocabulary(self): """The console must not invent an authority the MCP layer lacks.""" for action in console_authz.ACTIONS.values(): with self.subTest(action=action.action_id): self.assertIn(action.task_key, TASK_CAPABILITY_MAP) self.assertEqual( action.mcp_permission, TASK_CAPABILITY_MAP[action.task_key]["permission"], ) self.assertEqual( action.mcp_role, TASK_CAPABILITY_MAP[action.task_key]["role"], ) def test_matrix_declares_deny_by_default_and_execution_disabled(self): matrix = console_authz.rbac_matrix() self.assertEqual(matrix["default_decision"], "deny") self.assertFalse(matrix["execution_enabled"]) class TestAuthorizeDefaultDeny(unittest.TestCase): """Fail-closed behaviour of the authorization decision.""" def test_anonymous_is_denied_every_action(self): for action_id in console_authz.ACTIONS: with self.subTest(action=action_id): decision = console_authz.authorize(action_id) self.assertFalse(decision.allowed) self.assertEqual( decision.reason_code, console_authz.DENY_UNAUTHENTICATED ) def test_unknown_action_is_denied(self): decision = console_authz.authorize( "not_a_real_action", _principal("admin") ) self.assertFalse(decision.allowed) self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION) def test_unknown_role_is_denied(self): rogue = console_authz.Principal( subject="x@example.com", role="superuser", identity_source=console_authz.IDENTITY_ACCESS_PROXY, authenticated=True, ) decision = console_authz.authorize("comment_issue", rogue) self.assertFalse(decision.allowed) self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE) def test_insufficient_role_is_denied(self): decision = console_authz.authorize("merge_pr", _principal("operator")) self.assertFalse(decision.allowed) self.assertEqual( decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE ) def test_sufficient_role_allows_preview_only(self): decision = console_authz.authorize("merge_pr", _principal("controller")) self.assertTrue(decision.allowed) self.assertFalse(decision.execution_enabled) def test_execution_is_refused_while_phase_is_not_active(self): decision = console_authz.authorize( "merge_pr", _principal("controller"), for_execution=True ) self.assertFalse(decision.allowed) self.assertEqual( decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE ) def test_allowed_decision_never_reports_execution_enabled(self): for action_id in console_authz.ACTIONS: with self.subTest(action=action_id): decision = console_authz.authorize( action_id, _principal("admin") ) self.assertFalse(decision.execution_enabled) class TestIdentityResolution(unittest.TestCase): """AC5 — identity sources, including the insecure local-dev mode.""" def test_no_auth_mode_yields_anonymous_viewer(self): principal = console_authz.resolve_principal(env={}) self.assertFalse(principal.authenticated) self.assertEqual(principal.role, console_authz.VIEWER) self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE) def test_local_dev_mode_warns_that_identity_is_unverified(self): principal = console_authz.resolve_principal( env={ console_authz.AUTH_MODE_ENV: "local-dev", console_authz.DEV_SUBJECT_ENV: "dev@example.com", console_authz.DEV_ROLE_ENV: "admin", } ) self.assertTrue(principal.authenticated) self.assertEqual(principal.role, "admin") self.assertTrue(principal.warnings) self.assertIn("asserted", " ".join(principal.warnings).lower()) def test_local_dev_without_subject_falls_back_to_anonymous(self): principal = console_authz.resolve_principal( env={console_authz.AUTH_MODE_ENV: "local-dev"} ) self.assertFalse(principal.authenticated) def test_local_dev_unknown_role_degrades_to_viewer(self): principal = console_authz.resolve_principal( env={ console_authz.AUTH_MODE_ENV: "local_dev", console_authz.DEV_SUBJECT_ENV: "dev@example.com", console_authz.DEV_ROLE_ENV: "root", } ) self.assertEqual(principal.role, console_authz.VIEWER) def test_access_proxy_without_header_fails_closed(self): """A proxy-mode request that did not traverse the proxy is anonymous.""" principal = console_authz.resolve_principal( headers={}, env={console_authz.AUTH_MODE_ENV: "access_proxy"}, ) self.assertFalse(principal.authenticated) def test_access_proxy_role_comes_from_server_config_not_client(self): env = { console_authz.AUTH_MODE_ENV: "access_proxy", console_authz.ROLE_MAP_ENV: json.dumps( {"ops@example.com": "controller"} ), } principal = console_authz.resolve_principal( headers={ console_authz.ACCESS_SUBJECT_HEADER: "ops@example.com", "x-role": "admin", # client-supplied role must be ignored }, env=env, ) self.assertEqual(principal.role, "controller") def test_access_proxy_unmapped_subject_defaults_to_viewer(self): principal = console_authz.resolve_principal( headers={ console_authz.ACCESS_SUBJECT_HEADER: "stranger@example.com" }, env={console_authz.AUTH_MODE_ENV: "access_proxy"}, ) self.assertEqual(principal.role, console_authz.VIEWER) def test_malformed_role_map_does_not_raise_and_denies(self): principal = console_authz.resolve_principal( headers={console_authz.ACCESS_SUBJECT_HEADER: "ops@example.com"}, env={ console_authz.AUTH_MODE_ENV: "access_proxy", console_authz.ROLE_MAP_ENV: "{not json", }, ) self.assertEqual(principal.role, console_authz.VIEWER) def test_probe_auth_is_opt_in(self): self.assertFalse(console_authz.probe_auth_required(env={})) self.assertTrue( console_authz.probe_auth_required( env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"} ) ) def test_probe_auth_is_declared_but_not_yet_enforced(self): """Phase 1 declares the probe-auth policy; no route enforces it yet. The flag exists so the Phase 2 action framework has a declared policy to honour instead of inventing a second one. Pinning the current not-enforced status here means wiring it later is a deliberate change that updates this test and the documentation together, rather than a silent behaviour shift. The documentation must say so plainly, because an operator who sets the variable believing it protects a probe is worse off than one who knows it does not. """ import inspect from webui import app as webui_app source = inspect.getsource(webui_app) self.assertNotIn( "probe_auth_required", source, msg=( "webui.app now consults probe_auth_required, so probe auth is " "no longer merely declared. Update the 'Probe authentication' " "section of docs/webui-authz-audit.md, which states it " "enforces nothing, and replace this test with real " "enforcement coverage." ), ) self.assertIn( "enforces nothing today", AUTHZ_DOC.read_text(encoding="utf-8"), ) class TestRedaction(unittest.TestCase): """AC2 — required redaction units: token, keychain, password patterns.""" def test_token_assignment_is_redacted(self): out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl") self.assertIn(REDACTED, out) self.assertNotIn("abcd1234efgh5678ijkl", out) def test_password_assignment_is_redacted(self): out = redact_text("password: hunter2supersecret") self.assertIn(REDACTED, out) self.assertNotIn("hunter2supersecret", out) def test_keychain_reference_is_redacted(self): out = redact_text("keychain:gitea-prgs-token") self.assertIn(REDACTED, out) self.assertNotIn("gitea-prgs-token", out) def test_keychain_command_is_redacted(self): out = redact_text("security find-generic-password -s gitea -w") self.assertIn(REDACTED, out) self.assertNotIn("find-generic-password -s gitea", out) def test_bearer_credential_is_redacted(self): out = redact_text("Authorization: Bearer abcdef1234567890abcdef") self.assertNotIn("abcdef1234567890abcdef", out) def test_jwt_is_redacted(self): token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop" out = redact_text(f"session={token}") self.assertNotIn(token, out) def test_private_key_block_is_redacted(self): pem = ( "-----BEGIN RSA PRIVATE KEY-----\n" "MIIEowIBAAKCAQEAsecretmaterial\n" "-----END RSA PRIVATE KEY-----" ) out = redact_text(pem) self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out) def test_api_key_assignment_is_redacted(self): out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"') self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out) def test_nested_payload_is_redacted_recursively(self): payload = { "token": "abc123456789", "nested": {"note": "password=letmein12345"}, "list": ["keychain:some-entry"], "safe": "plain text", } out = redact_payload(payload) self.assertEqual(out["token"], REDACTED) self.assertNotIn("letmein12345", json.dumps(out)) self.assertNotIn("some-entry", json.dumps(out)) self.assertEqual(out["safe"], "plain text") def test_scan_reports_findings_before_and_none_after(self): dirty = "password: hunter2supersecret" self.assertTrue(scan_for_secrets(dirty)) self.assertEqual(scan_for_secrets(redact_text(dirty)), []) def test_non_strings_pass_through_untouched(self): self.assertEqual(redact_text(42), 42) self.assertEqual( redact_payload({"n": 1, "b": True}), {"n": 1, "b": True} ) def test_policy_is_documented_and_declares_redact_before_persist(self): policy = redaction_policy() self.assertTrue(policy["redact_before_persist"]) self.assertIn("audit_records", policy["applies_to"]) self.assertTrue(policy["console_rules"]) def test_policy_statement_contains_no_secret_material(self): self.assertEqual(scan_for_secrets(redaction_policy()), []) class TestAuditSchema(unittest.TestCase): """AC3 — audit event schema, required fields, and retention defaults.""" def _event(self, action_id="merge_pr", **kwargs): return console_audit.build_event( action_id=action_id, result=console_audit.RESULT_DENIED, decision=console_authz.authorize(action_id, _principal("operator")), target={"kind": "pr", "ref": "#123"}, request_id="req-test", **kwargs, ) def test_every_required_field_is_present(self): event = self._event() for field in console_audit.REQUIRED_FIELDS: with self.subTest(field=field): self.assertIn(field, event) def test_actor_carries_who_and_how_they_were_identified(self): event = self._event() for field in console_audit.REQUIRED_ACTOR_FIELDS: with self.subTest(field=field): self.assertIn(field, event["actor"]) def test_correlation_ids_are_present(self): event = self._event() for field in console_audit.REQUIRED_CORRELATION_FIELDS: with self.subTest(field=field): self.assertIn(field, event["correlation"]) self.assertEqual(event["correlation"]["request_id"], "req-test") self.assertEqual(event["correlation"]["mcp_task"], "merge_pr") def test_timestamp_is_timezone_aware_utc_iso8601(self): now = datetime.datetime( 2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc ) event = self._event(now=now) self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00") parsed = datetime.datetime.fromisoformat(event["timestamp"]) self.assertIsNotNone(parsed.tzinfo) def test_retention_defaults_by_class(self): self.assertEqual( console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90 ) self.assertEqual( console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED], 365, ) self.assertEqual( console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS], 730, ) def test_break_glass_action_retains_longest(self): event = self._event("merge_pr") self.assertEqual( event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS ) def test_routine_write_uses_standard_retention(self): event = self._event("comment_issue") self.assertEqual( event["retention"]["class"], console_audit.RETENTION_STANDARD ) def test_unknown_action_retains_as_privileged_not_standard(self): """Conservative direction: keep an unclassifiable record longer.""" self.assertEqual( console_audit.retention_class_for(None), console_audit.RETENTION_PRIVILEGED, ) def test_retention_expiry_matches_declared_days(self): now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc) event = self._event("comment_issue", now=now) expires = datetime.datetime.fromisoformat( event["retention"]["expires_at"] ) self.assertEqual((expires - now).days, 90) def test_invalid_result_degrades_to_failed(self): event = console_audit.build_event(action_id="merge_pr", result="banana") self.assertEqual(event["result"], console_audit.RESULT_FAILED) def test_denied_result_is_representable(self): """An authorization denial has no MCP-side mutation record.""" self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS) def test_event_is_redacted_before_it_is_returned(self): event = console_audit.build_event( action_id="merge_pr", result=console_audit.RESULT_DENIED, detail="failed with token=abcdef1234567890", metadata={"password": "hunter2supersecret"}, ) serialized = json.dumps(event) self.assertNotIn("abcdef1234567890", serialized) self.assertNotIn("hunter2supersecret", serialized) self.assertTrue(event["redacted"]) def test_audit_policy_reports_schema_and_retention(self): policy = console_audit.audit_policy() self.assertTrue(policy["append_only"]) self.assertTrue(policy["redact_before_persist"]) self.assertEqual( policy["retention_defaults_days"], console_audit.RETENTION_DAYS ) class TestAuditSink(unittest.TestCase): """Append-only persistence behaviour.""" def test_write_is_a_noop_when_sink_is_unconfigured(self): saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None) try: self.assertFalse(console_audit.audit_enabled()) self.assertFalse(console_audit.write_event({"schema_version": 1})) finally: if saved is not None: os.environ[console_audit.AUDIT_LOG_ENV] = saved def test_records_append_one_json_line_each(self): with tempfile.TemporaryDirectory() as tmp: sink = os.path.join(tmp, "console-audit.jsonl") for _ in range(3): event = console_audit.build_event( action_id="merge_pr", result=console_audit.RESULT_DENIED ) self.assertTrue(console_audit.write_event(event, path=sink)) with open(sink, encoding="utf-8") as handle: lines = [json.loads(line) for line in handle if line.strip()] self.assertEqual(len(lines), 3) self.assertEqual(len({line["event_id"] for line in lines}), 3) def test_a_record_that_still_carries_a_secret_is_not_persisted(self): with tempfile.TemporaryDirectory() as tmp: sink = os.path.join(tmp, "console-audit.jsonl") leaky = { "schema_version": 1, "detail": "password: hunter2supersecret", } self.assertFalse(console_audit.write_event(leaky, path=sink)) self.assertFalse(os.path.exists(sink)) def test_write_never_raises_on_a_bad_path(self): self.assertFalse( console_audit.write_event( {"schema_version": 1}, path="/nonexistent-dir/audit.jsonl" ) ) def test_simulated_privileged_preview_creates_an_audit_record(self): """Required test: audit record creation for a privileged preview.""" with tempfile.TemporaryDirectory() as tmp: sink = os.path.join(tmp, "console-audit.jsonl") os.environ[console_audit.AUDIT_LOG_ENV] = sink try: decision = console_authz.authorize( "merge_pr", _principal("controller") ) outcome = console_audit.record_event( action_id="merge_pr", result=console_audit.RESULT_PREVIEWED, decision=decision, target={"kind": "pr", "ref": "#123"}, request_id="req-preview", ) finally: os.environ.pop(console_audit.AUDIT_LOG_ENV, None) self.assertTrue(outcome["written"]) with open(sink, encoding="utf-8") as handle: record = json.loads(handle.read().strip()) self.assertEqual(record["action"], "merge_pr") self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED) self.assertEqual(record["action_class"], "privileged") self.assertTrue(record["decision"]["allowed"]) self.assertFalse(record["decision"]["execution_enabled"]) self.assertEqual(record["actor"]["role"], "controller") def test_decision_block_survives_redaction(self): """Regression: naming it 'authorization' collided with a secret hint. ``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the HTTP header), so a block under that key was replaced wholesale by the placeholder and the record lost its decision entirely. """ event = console_audit.build_event( action_id="merge_pr", result=console_audit.RESULT_DENIED, decision=console_authz.authorize("merge_pr", _principal("admin")), ) self.assertIsInstance(event["decision"], dict) self.assertIn("allowed", event["decision"]) class TestConsoleRoutes(unittest.TestCase): """AC4 — the wired Phase 2 integration points, still fail-closed.""" def setUp(self): self.client = TestClient(create_app(bind_host="127.0.0.1")) def test_unauthenticated_write_stub_is_denied(self): """Required test: default-deny for unauthenticated write stubs.""" response = self.client.post( "/api/actions/merge_pr/attempt", json={"pr_number": 99} ) self.assertEqual(response.status_code, 403) body = response.json() self.assertFalse(body["success"]) authorization = body["authorization"] self.assertFalse(authorization["allowed"]) self.assertEqual( authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED ) self.assertFalse(authorization["execution_enabled"]) def test_preview_reports_an_authorization_decision(self): response = self.client.get("/api/actions/merge_pr/preview?pr_number=7") self.assertEqual(response.status_code, 200) authorization = response.json()["authorization"] self.assertFalse(authorization["allowed"]) self.assertTrue(authorization["dual_control"]) self.assertEqual(authorization["required_role"], "controller") def test_unknown_action_preview_still_404s(self): response = self.client.get("/api/actions/no_such_action/preview") self.assertEqual(response.status_code, 404) def test_security_model_endpoint_publishes_all_three_policies(self): response = self.client.get("/api/console/security-model") self.assertEqual(response.status_code, 200) body = response.json() self.assertIn("rbac", body) self.assertIn("redaction", body) self.assertIn("audit", body) self.assertEqual(body["rbac"]["default_decision"], "deny") def test_security_model_endpoint_leaks_no_secrets(self): response = self.client.get("/api/console/security-model") self.assertEqual(scan_for_secrets(response.json()), []) def test_security_model_rejects_writes(self): response = self.client.post("/api/console/security-model", json={}) self.assertEqual(response.status_code, 405) def test_existing_read_routes_are_unaffected(self): for path in ("/", "/health", "/actions", "/api/actions"): with self.subTest(path=path): self.assertEqual(self.client.get(path).status_code, 200) class TestAuthzAuditDoc(unittest.TestCase): """The model must be written down, not only coded.""" @classmethod def setUpClass(cls): cls.text = ( AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else "" ) def test_doc_exists(self): self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}") def test_doc_covers_each_required_section(self): for heading in ( "Identity sources", "Role matrix", "Privileged actions", "Secret redaction", "Audit event schema", "Retention", "Phase 2 integration", "Local-dev mode", ): with self.subTest(heading=heading): self.assertIn(heading, self.text) def test_doc_names_every_role(self): for role in console_authz.ROLE_ORDER: with self.subTest(role=role): self.assertIn(role, self.text) def test_doc_names_every_console_action(self): for action_id in console_authz.ACTIONS: with self.subTest(action=action_id): self.assertIn(action_id, self.text) def test_doc_states_retention_defaults(self): for days in console_audit.RETENTION_DAYS.values(): with self.subTest(days=days): self.assertIn(str(days), self.text) def test_doc_warns_local_dev_is_insecure(self): self.assertIn("INSECURE", self.text.upper()) def test_doc_states_default_deny(self): self.assertIn("deny", self.text.lower()) def test_doc_contains_no_secret_material(self): self.assertEqual(scan_for_secrets(self.text), []) def test_deployment_doc_links_to_the_model(self): deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8") self.assertIn("webui-authz-audit", deployment) if __name__ == "__main__": # pragma: no cover unittest.main()