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:
+195
-12
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user