Phase 1 of the MCP Control Plane Web Console (#631) defines the model that future gated writes must pass through, and enables none of them. The read-only MVP (#426-#436) ships with no authentication; protection comes from network placement alone (#435). That is adequate while every route is a GET and inadequate the moment Phase 2 wires a write. This lands the authority first, so no write can later be added without something to check it against. webui/console_authz.py Identity sources (none / local-dev / access-proxy), a four-role matrix (viewer, operator, controller, admin), the privileged-action list, and a fail-closed authorize(). Every action maps to a task_key in task_capability_map, so the console cannot invent an authority the MCP layer does not already define. Roles are always server-side configuration, never a client assertion. Deny reasons are closed and enumerated; there is no implicit allow branch, and even an allow reports execution_enabled=false while ACTIVE_PHASE is 1. webui/console_redaction.py One redaction pass for API payloads, rendered HTML, logs, and audit records. Reuses gitea_audit.redact as the shared authority rather than forking it, then adds console patterns for keychain references, credential assignments, PEM private-key blocks, and JWTs. Never raises: an unredactable value degrades to the placeholder rather than being emitted raw. webui/console_audit.py Console-side audit records, which gitea_audit cannot supply: it records MCP mutations and carries no console actor, identity source, correlation id, or retention class, and an authorization denial is not a mutation at all. The two are additive and join on correlation.request_id. Records are redacted at build time, re-scanned at write time, and dropped rather than persisted if they still trip a detector. Retention is per-record; an unknown action is retained as privileged rather than standard. webui/app.py Attaches an authorization block to the existing preview and attempt routes and records the decision. The terminal outcome is unchanged - gated_actions still fails closed for every action - so this cannot loosen anything. Adds GET /api/console/security-model publishing the three policies as JSON. Probe authentication is deliberately declarative in this slice: probe_auth_required() reports operator intent and no route consults it. The documentation says so plainly and a regression test pins the not-enforced status, so wiring it in Phase 2 is a deliberate change rather than a silent one. An operator who sets the variable believing it protects a probe would be worse off than one who knows it does not. Tests: tests/test_webui_console_authz_audit.py - 75 passed, 93 subtests, covering each acceptance criterion and each test the issue requires (redaction units, default-deny for unauthenticated write stubs, audit record creation for a simulated privileged preview). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
704 lines
28 KiB
Python
704 lines
28 KiB
Python
"""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="[email protected]",
|
|
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: "[email protected]",
|
|
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: "[email protected]",
|
|
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(
|
|
{"[email protected]": "controller"}
|
|
),
|
|
}
|
|
principal = console_authz.resolve_principal(
|
|
headers={
|
|
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]",
|
|
"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: "[email protected]"
|
|
},
|
|
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: "[email protected]"},
|
|
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()
|