feat: audit-log Gitea MCP mutating actions with profile metadata (#18)

Add durable, opt-in audit logging for every mutating Gitea MCP action so an
operator can see which execution profile and authenticated Gitea user
performed (or was blocked from / failed) each mutation.

- New gitea_audit.py: pure, no-network module — recursive secret redaction
  (token/password/authorization keys; token/Basic/Bearer value runs),
  build_event (timestamp, action, result, profile, audit label, authenticated
  username, repo, issue/PR, target branch, head SHA, redacted request
  metadata), and an append-only JSON Lines sink.
- mcp_server.py: _audit helper + _audited context manager (simple mutations)
  and an _audit_pr_result decorator (gated review/merge tools, reading their
  own result dict) wired into create_issue, create_pr, edit_pr, close_issue,
  commit_files, delete_branch, create_label, set_issue_labels, mark_issue
  (label/unlabel), gitea_submit_pr_review, and gitea_merge_pr.
- Outcomes recorded as allowed/blocked/failed/succeeded; blocked and failed
  eligibility checks are logged, not just successes.

Off by default: records are written only when GITEA_AUDIT_LOG is set. When it
is unset every audit path short-circuits — no records, no extra API calls — so
existing tool behaviour and API call sequences are unchanged. Auditing never
raises; sink writes are best-effort. Tokens are never written.

Docs: README env table + audit note, .env.example placeholder.
Tests: tests/test_audit.py (19 cases) — redaction, event build, sink writes,
per-tool success/failure/blocked records, secret-free output, off-by-default
no-op, and audit-failure-never-breaks-action.

Closes #18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 22:20:51 -04:00
parent 20dd717b9c
commit c3c48fb7c2
5 changed files with 635 additions and 15 deletions
+195 -12
View File
@@ -15,6 +15,8 @@ Configuration (mcp_config.json):
"""
import os
import sys
import functools
import contextlib
import subprocess
# Resolve the project root. MCP clients must launch this script directly with
@@ -38,6 +40,7 @@ from gitea_auth import ( # noqa: E402
repo_api_url,
get_profile,
)
import gitea_audit # noqa: E402
mcp = FastMCP("gitea-tools", instructions=(
"Gitea issue tracker and PR management for dadeschools and prgs instances. "
@@ -70,6 +73,144 @@ def _auth(host: str) -> str:
return header
# ── Audit logging (#18) ─────────────────────────────────────────────────────────
# Mutating actions emit a structured audit record (profile + authenticated user
# + outcome) when GITEA_AUDIT_LOG is configured. When it is not, every helper
# below short-circuits and performs NO work — no extra API calls, no I/O — so
# existing tool behaviour and API call sequences are unchanged.
_UNSET = object()
# Best-effort identity cache keyed by host, so an enabled audit trail resolves
# the authenticated username at most once per host per process.
_IDENTITY_CACHE: dict = {}
def _authenticated_username(host: str):
"""Resolve the authenticated Gitea username for *host* (cached, fail-soft).
Read-only. Returns None if the identity cannot be determined; never raises
and never surfaces credential material.
"""
if host in _IDENTITY_CACHE:
return _IDENTITY_CACHE[host]
user = None
try:
header = get_auth_header(host)
if header:
who = api_request("GET", f"https://{host}/api/v1/user", header)
user = (who or {}).get("login")
except Exception:
user = None
_IDENTITY_CACHE[host] = user
return user
def _audit(action: str, *, host, remote, result, org=None, repo=None,
reason=None, request_metadata=None, issue_number=None,
pr_number=None, target_branch=None, head_sha=None, username=_UNSET):
"""Emit one audit record for a mutating action. No-op unless auditing is on.
Never raises — auditing must not break the action it records.
"""
if not gitea_audit.audit_enabled():
return
try:
profile = get_profile()
if username is _UNSET:
username = _authenticated_username(host) if host else None
event = gitea_audit.build_event(
action=action,
result=result,
remote=remote,
server=(f"https://{host}" if host else None),
repository=repo,
issue_number=issue_number,
pr_number=pr_number,
profile_name=profile["profile_name"],
audit_label=profile["audit_label"],
authenticated_username=username,
target_branch=target_branch,
head_sha=head_sha,
reason=reason,
request_metadata=request_metadata,
)
gitea_audit.write_event(event)
except Exception:
pass # best-effort; never break the action
@contextlib.contextmanager
def _audited(action: str, *, host, remote, org=None, repo=None,
request_metadata=None, issue_number=None, pr_number=None,
target_branch=None):
"""Wrap a mutating API call: audit SUCCEEDED on return, FAILED on exception.
When auditing is off this yields immediately with no bookkeeping.
"""
if not gitea_audit.audit_enabled():
yield
return
try:
yield
except Exception as exc:
_audit(action, host=host, remote=remote, org=org, repo=repo,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
request_metadata=request_metadata, issue_number=issue_number,
pr_number=pr_number, target_branch=target_branch)
raise
_audit(action, host=host, remote=remote, org=org, repo=repo,
result=gitea_audit.SUCCEEDED, request_metadata=request_metadata,
issue_number=issue_number, pr_number=pr_number,
target_branch=target_branch)
def _audit_pr_result(action: str):
"""Decorator for gated PR tools that return a result dict.
Reads the tool's own result dict (authenticated_user, profile, reasons,
performed) to emit an audit record classifying the outcome as SUCCEEDED,
BLOCKED, or FAILED. No extra API calls: identity comes from the result.
"""
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
try:
if isinstance(result, dict) and gitea_audit.audit_enabled():
reasons = [str(x) for x in (result.get("reasons") or [])]
if result.get("performed"):
status = gitea_audit.SUCCEEDED
elif any("failed:" in x.lower() for x in reasons):
# "failed:" marks a surfaced exception (e.g. "merge
# failed: <err>"); a bare gate message like "… failed
# (fail closed)" is a policy block, not an execution error.
status = gitea_audit.FAILED
else:
status = gitea_audit.BLOCKED
remote = result.get("remote")
host = REMOTES[remote]["host"] if remote in REMOTES else None
_audit(
action,
host=host,
remote=remote,
result=status,
reason="; ".join(reasons) or None,
pr_number=result.get("pr_number"),
head_sha=result.get("head_sha"),
username=result.get("authenticated_user"),
request_metadata={
"requested_action": result.get("requested_action"),
"merge_method": result.get("merge_method"),
},
)
except Exception:
pass # best-effort; never break the tool
return result
return wrapper
return decorate
# ── Tools ─────────────────────────────────────────────────────────────────────
@mcp.tool()
@@ -97,7 +238,16 @@ def gitea_create_issue(
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues"
data = api_request("POST", url, auth, {"title": title, "body": body})
try:
data = api_request("POST", url, auth, {"title": title, "body": body})
except Exception as exc:
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
request_metadata={"title": title})
raise
_audit("create_issue", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.SUCCEEDED, issue_number=data["number"],
request_metadata={"title": title})
return {"number": data["number"], "url": data["html_url"]}
@@ -131,7 +281,17 @@ def gitea_create_pr(
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls"
payload = {"title": title, "body": body, "head": head, "base": base}
data = api_request("POST", url, auth, payload)
meta = {"title": title, "head": head, "base": base}
try:
data = api_request("POST", url, auth, payload)
except Exception as exc:
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.FAILED, reason=_redact(str(exc)),
target_branch=head, request_metadata=meta)
raise
_audit("create_pr", host=h, remote=remote, org=o, repo=r,
result=gitea_audit.SUCCEEDED, pr_number=data["number"],
target_branch=head, request_metadata=meta)
return {"number": data["number"], "url": data["html_url"]}
@@ -396,6 +556,7 @@ def _redact(text: str) -> str:
@mcp.tool()
@_audit_pr_result("submit_pr_review")
def gitea_submit_pr_review(
pr_number: int,
action: str,
@@ -575,7 +736,9 @@ def gitea_edit_pr(
if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
data = api_request("PATCH", url, auth, payload)
with _audited("edit_pr", host=h, remote=remote, org=o, repo=r,
pr_number=pr_number, request_metadata={"fields": sorted(payload)}):
data = api_request("PATCH", url, auth, payload)
return {
"success": True,
"number": data["number"],
@@ -663,7 +826,12 @@ def gitea_commit_files(
if new_branch is not None:
payload["new_branch"] = new_branch
data = api_request("POST", url, auth, payload)
with _audited("commit_files", host=h, remote=remote, org=o, repo=r,
target_branch=(new_branch or branch),
request_metadata={"message": message,
"paths": [f.get("path") for f in files],
"operations": [f.get("operation") for f in files]}):
data = api_request("POST", url, auth, payload)
return {
"success": True,
"commit": data.get("commit", {}).get("sha", ""),
@@ -676,6 +844,7 @@ _MERGE_METHODS = ("merge", "squash", "rebase")
@mcp.tool()
@_audit_pr_result("merge_pr")
def gitea_merge_pr(
pr_number: int,
confirmation: str = "",
@@ -952,7 +1121,9 @@ def gitea_delete_branch(
import urllib.parse
encoded_branch = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded_branch}"
api_request("DELETE", url, auth)
with _audited("delete_branch", host=h, remote=remote, org=o, repo=r,
target_branch=branch, request_metadata={"branch": branch}):
api_request("DELETE", url, auth)
return {"success": True, "message": f"Remote branch '{branch}' deleted."}
@@ -979,7 +1150,9 @@ def gitea_close_issue(
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
api_request("PATCH", url, auth, {"state": "closed"})
with _audited("close_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"state": "closed"}):
api_request("PATCH", url, auth, {"state": "closed"})
return {"success": True, "message": f"Issue #{issue_number} closed."}
@@ -1232,12 +1405,18 @@ def gitea_mark_issue(
)
if action == "start":
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
{"labels": [label_id]})
with _audited("label_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "add", "label": "status:in-progress"}):
api_request("POST", f"{base}/issues/{issue_number}/labels", auth,
{"labels": [label_id]})
return {"success": True, "message": f"Issue #{issue_number} claimed."}
else:
api_request("DELETE",
f"{base}/issues/{issue_number}/labels/{label_id}", auth)
with _audited("unlabel_issue", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number,
request_metadata={"op": "remove", "label": "status:in-progress"}):
api_request("DELETE",
f"{base}/issues/{issue_number}/labels/{label_id}", auth)
return {"success": True, "message": f"Issue #{issue_number} released."}
@@ -1301,7 +1480,9 @@ def gitea_create_label(
"color": color,
"description": description,
}
return api_request("POST", f"{base}/labels", auth, payload)
with _audited("create_label", host=h, remote=remote, org=o, repo=r,
request_metadata={"name": name}):
return api_request("POST", f"{base}/labels", auth, payload)
@mcp.tool()
@@ -1350,7 +1531,9 @@ def gitea_set_issue_labels(
)
# 3. PUT the labels to the issue
res = api_request("PUT", f"{base}/issues/{issue_number}/labels", auth, {"labels": label_ids})
with _audited("set_issue_labels", host=h, remote=remote, org=o, repo=r,
issue_number=issue_number, request_metadata={"labels": labels}):
res = api_request("PUT", f"{base}/issues/{issue_number}/labels", auth, {"labels": label_ids})
return res