Files
dify/api/controllers/openapi/_audit.py
T
583615e412 feat: support personal access token (#45540)
Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: Eldar Dadashov <52511672+eldardada@users.noreply.github.com>
Co-authored-by: Byron.wang <byron@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
2026-11-11 03:00:24 +00:00

149 lines
3.9 KiB
Python

"""Audit emission for openapi app-run endpoints.
Pattern: logger.info with extra={"audit": True, "event": "app.run.openapi", ...}
matches the existing oauth_device convention. The EE OTel exporter consults
its own allowlist to decide whether to ship the line.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from contextlib import suppress
from uuid import UUID
logger = logging.getLogger(__name__)
EVENT_APP_RUN_OPENAPI = "app.run.openapi"
EVENT_OPENAPI_WRONG_SURFACE_DENIED = "openapi.wrong_surface_denied"
# Review this list when adding audited targets; a route parameter name alone
# must never opt request data into audit logs. Form tokens are intentionally absent.
PERSONAL_AUDIT_TARGET_FIELDS = frozenset(
{
"app_id",
"workspace_id",
"run_id",
"task_id",
"import_id",
"member_id",
"session_id",
"env_id",
"version_id",
"skill_id",
"knowledge_space_id",
"credential_id",
}
)
def personal_audit_targets(targets: Mapping[str, object]) -> dict[str, str]:
"""Return only explicitly approved UUID targets; discard other request values."""
result = {}
for key in sorted(PERSONAL_AUDIT_TARGET_FIELDS & targets.keys()):
value = targets[key]
if isinstance(value, str):
with suppress(ValueError):
result[key] = str(UUID(value))
return result
def emit_app_run(
*,
app_id: str,
tenant_id: str,
caller_kind: str,
mode: str,
surface: str,
) -> None:
logger.info(
"audit: %s app_id=%s tenant_id=%s caller_kind=%s mode=%s surface=%s",
EVENT_APP_RUN_OPENAPI,
app_id,
tenant_id,
caller_kind,
mode,
surface,
extra={
"audit": True,
"event": EVENT_APP_RUN_OPENAPI,
"app_id": app_id,
"tenant_id": tenant_id,
"caller_kind": caller_kind,
"mode": mode,
"surface": surface,
},
)
def emit_wrong_surface(
*,
subject_type: str | None,
attempted_path: str,
client_id: str | None,
token_id: str | None,
) -> None:
logger.warning(
"audit: %s subject_type=%s attempted_path=%s",
EVENT_OPENAPI_WRONG_SURFACE_DENIED,
subject_type,
attempted_path,
extra={
"audit": True,
"event": EVENT_OPENAPI_WRONG_SURFACE_DENIED,
"subject_type": subject_type,
"attempted_path": attempted_path,
"client_id": client_id,
"token_id": token_id,
},
)
def emit_personal_request(
*,
token_id: str,
account_id: str,
operation: str,
target_route: str,
target_ids: dict[str, str],
http_status: int,
streaming: bool,
trace_id: str | None,
) -> None:
"""Audit authenticated PAT requests without headers, bodies or exception text.
A successful SSE response records admission only, not execution completion.
trace_id is absent when no server trace was assigned.
"""
outcome = (
"failed"
if http_status >= 500
else "denied"
if http_status >= 400
else "stream_opened"
if streaming
else "succeeded"
)
logger.info(
"audit: pat.request.openapi token_id=%s account_id=%s operation=%s target=%s ids=%s outcome=%s status=%s",
token_id,
account_id,
operation,
target_route,
target_ids,
outcome,
http_status,
extra={
"audit": True,
"event": "pat.request.openapi",
"token_id": token_id,
"account_id": account_id,
"operation": operation,
"target_route": target_route,
"target_ids": target_ids,
"outcome": outcome,
"http_status": http_status,
"trace_id": trace_id,
},
)