feat(webui): console authorization, RBAC, redaction, and audit model (Closes #633)
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]>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Secret redaction policy for every console surface (#633).
|
||||
|
||||
The MVP already redacts MCP-side mutation records through ``gitea_audit``.
|
||||
This module is the console-facing policy: one redaction pass applied to API
|
||||
payloads, rendered HTML, log lines, and audit records *before* they leave the
|
||||
server or reach persistent storage.
|
||||
|
||||
Design constraints:
|
||||
|
||||
- **Reuse, never fork.** ``gitea_audit.redact`` remains the authority for
|
||||
secret-looking dict keys, ``Authorization`` material, and raw URLs. This
|
||||
module runs that pass first and then applies console-specific patterns for
|
||||
keychain references, key/value assignments, private-key blocks, and JWTs.
|
||||
- **Never raises.** Redaction is a safety control; a malformed payload must
|
||||
degrade to a redacted placeholder rather than propagate an exception.
|
||||
- **Redact before persist.** ``webui.console_audit`` calls this module before
|
||||
writing, so an unredacted record is never durable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import gitea_audit
|
||||
|
||||
REDACTED = gitea_audit.REDACTED
|
||||
|
||||
# Console-specific patterns applied after the shared ``gitea_audit`` pass.
|
||||
# Each keeps the identifying key so an operator can still tell *what* was
|
||||
# removed, and replaces only the secret run itself.
|
||||
_KEYCHAIN_REF = re.compile(r"(?i)\bkeychain:[\w.\-/@]+")
|
||||
_KEYCHAIN_CMD = re.compile(
|
||||
r"(?i)\bsecurity\s+find-(?:generic|internet)-password\b[^\n]*"
|
||||
)
|
||||
_ASSIGNMENT = re.compile(
|
||||
r"(?i)\b(token|password|passwd|secret|api[_-]?key|access[_-]?key|"
|
||||
r"client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)"
|
||||
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
||||
)
|
||||
_ENV_ASSIGNMENT = re.compile(
|
||||
r"(?i)\b(GITEA_(?:TOKEN|PASS|PASSWORD)[A-Z0-9_]*)(\s*=\s*)"
|
||||
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
||||
)
|
||||
_PRIVATE_KEY_BLOCK = re.compile(
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
||||
re.S,
|
||||
)
|
||||
_JWT = re.compile(
|
||||
r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b"
|
||||
)
|
||||
|
||||
# Shapes that mean a payload still carries a secret. ``scan_for_secrets`` uses
|
||||
# these to assert a surface is clean.
|
||||
_DETECTORS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
("keychain_reference", _KEYCHAIN_REF),
|
||||
("keychain_command", _KEYCHAIN_CMD),
|
||||
("credential_assignment", _ASSIGNMENT),
|
||||
("credential_env_assignment", _ENV_ASSIGNMENT),
|
||||
("private_key_block", _PRIVATE_KEY_BLOCK),
|
||||
("json_web_token", _JWT),
|
||||
("bearer_credential", re.compile(r"(?i)\b(?:bearer|basic)\s+\S{8,}")),
|
||||
)
|
||||
|
||||
|
||||
def _mask_assignment(match: re.Match[str]) -> str:
|
||||
"""Keep the key and separator, replace the value."""
|
||||
return f"{match.group(1)}{match.group(2)}{REDACTED}"
|
||||
|
||||
|
||||
def redact_text(text: Any) -> Any:
|
||||
"""Redact secret material from a single string.
|
||||
|
||||
Non-strings are returned unchanged so this is safe to map over mixed
|
||||
payloads. Runs the shared ``gitea_audit`` pass first, then the
|
||||
console-specific patterns.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return text
|
||||
try:
|
||||
out = gitea_audit.redact(text)
|
||||
if not isinstance(out, str): # defensive; redact() returns str for str
|
||||
return REDACTED
|
||||
out = _PRIVATE_KEY_BLOCK.sub(f"{REDACTED}_PRIVATE_KEY", out)
|
||||
out = _ENV_ASSIGNMENT.sub(_mask_assignment, out)
|
||||
out = _ASSIGNMENT.sub(_mask_assignment, out)
|
||||
out = _KEYCHAIN_CMD.sub(f"{REDACTED}_KEYCHAIN_COMMAND", out)
|
||||
out = _KEYCHAIN_REF.sub(f"{REDACTED}_KEYCHAIN_REF", out)
|
||||
out = _JWT.sub(f"{REDACTED}_JWT", out)
|
||||
return out
|
||||
except Exception:
|
||||
# Fail closed: an unredactable string is dropped rather than emitted raw.
|
||||
return REDACTED
|
||||
|
||||
|
||||
def redact_payload(value: Any) -> Any:
|
||||
"""Recursively redact a JSON-able payload for any console surface.
|
||||
|
||||
Secret-looking dict keys are replaced wholesale by the shared
|
||||
``gitea_audit`` policy; every remaining string is run through
|
||||
:func:`redact_text`.
|
||||
"""
|
||||
try:
|
||||
shared = gitea_audit.redact(value)
|
||||
except Exception:
|
||||
return REDACTED
|
||||
return _walk(shared)
|
||||
|
||||
|
||||
def _walk(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {k: _walk(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_walk(v) for v in value]
|
||||
if isinstance(value, str):
|
||||
return redact_text(value)
|
||||
return value
|
||||
|
||||
|
||||
def scan_for_secrets(value: Any) -> list[str]:
|
||||
"""Return detector names that still match *value* after serialization.
|
||||
|
||||
Used to assert an outbound payload or rendered page is clean. An empty
|
||||
list means no known secret shape was found. Already-redacted hits are not
|
||||
findings.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
text = value
|
||||
else:
|
||||
try:
|
||||
text = json.dumps(value, default=str)
|
||||
except Exception:
|
||||
text = str(value)
|
||||
findings: list[str] = []
|
||||
for name, pattern in _DETECTORS:
|
||||
for match in pattern.finditer(text):
|
||||
if REDACTED in match.group(0):
|
||||
continue
|
||||
findings.append(name)
|
||||
break
|
||||
return findings
|
||||
|
||||
|
||||
def redaction_policy() -> dict[str, Any]:
|
||||
"""Machine-readable statement of the redaction rules (never secrets)."""
|
||||
return {
|
||||
"policy_version": 1,
|
||||
"applies_to": [
|
||||
"json_api_responses",
|
||||
"rendered_html",
|
||||
"server_logs",
|
||||
"audit_records",
|
||||
],
|
||||
"ordering": "shared gitea_audit pass, then console patterns",
|
||||
"redact_before_persist": True,
|
||||
"shared_rules": {
|
||||
"source": "gitea_audit.redact",
|
||||
"secret_key_hints": list(gitea_audit._SECRET_KEY_HINTS),
|
||||
"secret_value_prefixes": list(gitea_audit._SECRET_VALUE_PREFIXES),
|
||||
"urls": "credentials, secret query parameters, and real hosts redacted",
|
||||
},
|
||||
"console_rules": [
|
||||
{"name": name, "pattern": pattern.pattern}
|
||||
for name, pattern in _DETECTORS
|
||||
],
|
||||
"placeholder": REDACTED,
|
||||
"failure_mode": "fail closed — unredactable values become the placeholder",
|
||||
}
|
||||
Reference in New Issue
Block a user