forked from retoor/devplacepy
feat: add audit log tables, indexes, and CLI/content recording hooks
This commit is contained in:
@@ -4,6 +4,23 @@ from devplacepy.database import get_table
|
||||
from devplacepy.utils import strip_html
|
||||
|
||||
|
||||
def _audit_cli(event_key, summary, metadata=None, target_type=None, target_uid=None, target_label=None, links=None):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
event_key,
|
||||
actor_kind="cli",
|
||||
actor_role="system",
|
||||
origin="cli",
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
target_label=target_label,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
links=links,
|
||||
)
|
||||
|
||||
|
||||
def cmd_role_get(args):
|
||||
users = get_table("users")
|
||||
user = users.find_one(username=args.username)
|
||||
@@ -25,7 +42,19 @@ def cmd_role_set(args):
|
||||
print(f"User '{args.username}' not found")
|
||||
sys.exit(1)
|
||||
|
||||
old_role = user.get("role")
|
||||
users.update({"uid": user["uid"], "role": role.capitalize()}, ["uid"])
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.role.set",
|
||||
f"CLI set role of user {args.username} from {old_role} to {role.capitalize()}",
|
||||
metadata={"old": old_role, "new": role.capitalize()},
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(f"User '{args.username}' role set to '{role}'")
|
||||
|
||||
|
||||
@@ -49,6 +78,16 @@ def cmd_apikey_reset(args):
|
||||
new_key = generate_uid()
|
||||
users.update({"uid": user["uid"], "api_key": new_key}, ["uid"])
|
||||
clear_user_cache(user["uid"])
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.apikey.reset",
|
||||
f"CLI regenerated the API key of user {args.username}",
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(new_key)
|
||||
|
||||
|
||||
@@ -56,6 +95,11 @@ def cmd_apikey_backfill(args):
|
||||
from devplacepy.database import backfill_api_keys
|
||||
|
||||
updated = backfill_api_keys()
|
||||
_audit_cli(
|
||||
"cli.apikey.backfill",
|
||||
f"CLI backfilled API keys for {updated} users",
|
||||
metadata={"count": updated},
|
||||
)
|
||||
print(f"Assigned API keys to {updated} user(s) without one")
|
||||
|
||||
|
||||
@@ -70,11 +114,13 @@ def cmd_devii_reset_quota(args):
|
||||
if args.all:
|
||||
count = table.count()
|
||||
table.delete()
|
||||
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (all)", metadata={"scope": "all", "rows_removed": count})
|
||||
print(f"Reset all AI quotas ({count} ledger rows deleted)")
|
||||
return
|
||||
if args.guests:
|
||||
count = table.count(owner_kind="guest")
|
||||
table.delete(owner_kind="guest")
|
||||
_audit_cli("cli.devii.quota.reset", "CLI reset AI quota (guests)", metadata={"scope": "guests", "rows_removed": count})
|
||||
print(f"Reset all guest AI quotas ({count} ledger rows deleted)")
|
||||
return
|
||||
if not args.username:
|
||||
@@ -86,19 +132,33 @@ def cmd_devii_reset_quota(args):
|
||||
sys.exit(1)
|
||||
count = table.count(owner_kind="user", owner_id=user["uid"])
|
||||
table.delete(owner_kind="user", owner_id=user["uid"])
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
_audit_cli(
|
||||
"cli.devii.quota.reset",
|
||||
f"CLI reset AI quota for {args.username}",
|
||||
metadata={"scope": "user", "rows_removed": count},
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=args.username,
|
||||
links=[audit.target("user", user["uid"], args.username)],
|
||||
)
|
||||
print(f"Reset AI quota for '{args.username}' ({count} ledger rows deleted)")
|
||||
|
||||
|
||||
def cmd_news_clear(args):
|
||||
from devplacepy.database import db
|
||||
|
||||
deleted = {}
|
||||
for table in ("news", "news_images", "news_sync"):
|
||||
if table in db.tables:
|
||||
count = db[table].count()
|
||||
db[table].delete()
|
||||
deleted[table] = count
|
||||
print(f"Deleted {count} rows from '{table}'")
|
||||
else:
|
||||
print(f"Table '{table}' does not exist, skipping")
|
||||
_audit_cli("cli.news.clear", "CLI cleared all news data", metadata={"deleted": deleted})
|
||||
print("News data cleared")
|
||||
|
||||
|
||||
@@ -118,6 +178,7 @@ def cmd_news_sanitize(args):
|
||||
{"id": row["id"], "description": desc, "content": content}, ["id"]
|
||||
)
|
||||
updated += 1
|
||||
_audit_cli("cli.news.sanitize", f"CLI sanitized {updated} news articles", metadata={"count": updated})
|
||||
print(f"Sanitized {updated} news article(s)")
|
||||
|
||||
|
||||
@@ -138,6 +199,11 @@ def cmd_attachments_prune(args):
|
||||
]
|
||||
for att in orphans:
|
||||
delete_attachment(att["uid"])
|
||||
_audit_cli(
|
||||
"cli.attachments.prune",
|
||||
f"CLI pruned {len(orphans)} orphan attachments",
|
||||
metadata={"count": len(orphans), "hours": args.hours},
|
||||
)
|
||||
print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h")
|
||||
|
||||
|
||||
@@ -170,6 +236,7 @@ def cmd_zips_prune(args):
|
||||
_remove_zip_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.zips.prune", f"CLI pruned {removed} expired zip jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired zip job(s)")
|
||||
|
||||
|
||||
@@ -180,6 +247,7 @@ def cmd_zips_clear(args):
|
||||
for job in jobs:
|
||||
_remove_zip_artifacts(job)
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.zips.clear", f"CLI cleared all zip jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} zip job(s) and their archives")
|
||||
|
||||
|
||||
@@ -200,6 +268,7 @@ def cmd_forks_prune(args):
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.forks.prune", f"CLI pruned {removed} expired fork jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired fork job(s)")
|
||||
|
||||
|
||||
@@ -209,6 +278,7 @@ def cmd_forks_clear(args):
|
||||
jobs = queue.list_jobs(kind="fork")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
_audit_cli("cli.forks.clear", f"CLI cleared all fork jobs ({len(jobs)})", metadata={"count": len(jobs)})
|
||||
print(f"Cleared {len(jobs)} fork job(s)")
|
||||
|
||||
|
||||
@@ -231,6 +301,7 @@ def cmd_containers_reconcile(args):
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
|
||||
asyncio.run(ContainerService().run_once())
|
||||
_audit_cli("cli.containers.reconcile", "CLI ran one container reconcile pass")
|
||||
print("Reconcile pass complete")
|
||||
|
||||
|
||||
@@ -244,6 +315,7 @@ def cmd_containers_prune(args):
|
||||
await get_backend().image_prune()
|
||||
|
||||
asyncio.run(run())
|
||||
_audit_cli("cli.containers.prune", "CLI reaped orphan containers and dangling images")
|
||||
print("Reaped orphans and pruned dangling images")
|
||||
|
||||
|
||||
@@ -267,6 +339,7 @@ def cmd_containers_prune_builds(args):
|
||||
return removed
|
||||
|
||||
removed = asyncio.run(run())
|
||||
_audit_cli("cli.containers.prune_builds", "CLI removed legacy images and build tables", metadata={"removed": removed})
|
||||
print(
|
||||
f"Removed {removed} legacy per-project image(s) and cleared the dockerfiles/builds tables"
|
||||
)
|
||||
@@ -286,6 +359,7 @@ def cmd_containers_gc_workspaces(args):
|
||||
if child.is_dir() and child.name not in active:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
removed += 1
|
||||
_audit_cli("cli.containers.gc_workspaces", f"CLI removed {removed} unused workspace dirs", metadata={"count": removed})
|
||||
print(
|
||||
f"Removed {removed} unused workspace director{'y' if removed == 1 else 'ies'}"
|
||||
)
|
||||
|
||||
@@ -29,6 +29,9 @@ from devplacepy.utils import (
|
||||
award_rewards,
|
||||
create_mention_notifications,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
CREATE_METADATA_KEYS = ("project_type", "is_private", "language", "topic", "status")
|
||||
|
||||
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
|
||||
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
|
||||
@@ -79,6 +82,7 @@ def create_content_item(
|
||||
badge: str,
|
||||
mention_text: str,
|
||||
attachment_uids: list | None,
|
||||
request=None,
|
||||
) -> tuple[str, str]:
|
||||
uid = generate_uid()
|
||||
slug = make_combined_slug(slug_source, uid)
|
||||
@@ -97,6 +101,26 @@ def create_content_item(
|
||||
link_attachments(attachment_uids, target_type, uid)
|
||||
create_mention_notifications(mention_text, user["uid"], f"/{table_name}/{slug}")
|
||||
logger.info(f"{target_type} {uid} created by {user['username']}")
|
||||
label = fields.get("title") or slug
|
||||
links = [audit.target(target_type, uid, label)]
|
||||
if fields.get("project_uid"):
|
||||
links.append(audit.project(fields["project_uid"]))
|
||||
metadata = {
|
||||
key: fields[key] for key in CREATE_METADATA_KEYS if fields.get(key) is not None
|
||||
}
|
||||
if attachment_uids:
|
||||
metadata["attachment_count"] = len(attachment_uids)
|
||||
audit.record(
|
||||
request,
|
||||
f"{target_type}.create",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=uid,
|
||||
target_label=label,
|
||||
summary=f"{user['username']} created {target_type} {label}",
|
||||
metadata=metadata or None,
|
||||
links=links,
|
||||
)
|
||||
return uid, slug
|
||||
|
||||
|
||||
@@ -136,12 +160,24 @@ def edit_content_item(
|
||||
slug: str,
|
||||
update_fields: dict,
|
||||
redirect_fail: str,
|
||||
target_type: str | None = None,
|
||||
):
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
|
||||
kind = target_type or table_name.rstrip("s")
|
||||
table = get_table(table_name)
|
||||
item = resolve_by_slug(table, slug)
|
||||
if not is_owner(item, user):
|
||||
audit.record(
|
||||
request,
|
||||
f"{kind}.edit",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type=kind,
|
||||
target_uid=item["uid"] if item else slug,
|
||||
target_label=item.get("title") if item else slug,
|
||||
summary=f"{user['username']} denied editing {kind} {slug}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=redirect_fail, status_code=302)
|
||||
@@ -151,6 +187,18 @@ def edit_content_item(
|
||||
}
|
||||
table.update({"uid": item["uid"], **update_fields}, ["uid"])
|
||||
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
|
||||
label = update_fields.get("title") or item.get("title") or item["uid"]
|
||||
audit.record(
|
||||
request,
|
||||
f"{kind}.edit",
|
||||
user=user,
|
||||
target_type=kind,
|
||||
target_uid=item["uid"],
|
||||
target_label=label,
|
||||
summary=f"{user['username']} edited {kind} {label}",
|
||||
metadata={"changed_fields": sorted(update_fields.keys())},
|
||||
links=[audit.target(kind, item["uid"], label)],
|
||||
)
|
||||
url = f"/{table_name}/{item['slug'] or item['uid']}"
|
||||
return action_result(
|
||||
request, url, data={"uid": item["uid"], "slug": item.get("slug"), "url": url}
|
||||
@@ -171,10 +219,21 @@ def delete_content_item(
|
||||
table = get_table(table_name)
|
||||
item = resolve_by_slug(table, slug)
|
||||
if not is_owner(item, user):
|
||||
audit.record(
|
||||
request,
|
||||
f"{target_type}.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type=target_type,
|
||||
target_uid=item["uid"] if item else slug,
|
||||
target_label=item.get("title") if item else slug,
|
||||
summary=f"{user['username']} denied deleting {target_type} {slug}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
if is_owner(item, user):
|
||||
item_label = item.get("title") or item["uid"]
|
||||
delete_attachments_for(target_type, [item["uid"]])
|
||||
comment_uids = []
|
||||
if "comments" in db.tables:
|
||||
@@ -205,6 +264,17 @@ def delete_content_item(
|
||||
delete_inline_image(item.get(inline_image_field))
|
||||
table.delete(id=item["id"])
|
||||
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
f"{target_type}.delete",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=item["uid"],
|
||||
target_label=item_label,
|
||||
summary=f"{user['username']} deleted {target_type} {item_label}",
|
||||
metadata={"comment_count": len(comment_uids)},
|
||||
links=[audit.target(target_type, item["uid"], item_label)],
|
||||
)
|
||||
return action_result(request, redirect_url)
|
||||
|
||||
|
||||
|
||||
@@ -266,6 +266,21 @@ def init_db():
|
||||
["enabled", "next_run_at"],
|
||||
)
|
||||
|
||||
from devplacepy.services.audit import store as audit_store
|
||||
|
||||
audit_store.ensure_tables()
|
||||
_index(db, "audit_log", "idx_audit_created_at", ["created_at"])
|
||||
_index(db, "audit_log", "idx_audit_event_key", ["event_key"])
|
||||
_index(db, "audit_log", "idx_audit_category", ["category"])
|
||||
_index(db, "audit_log", "idx_audit_actor_uid", ["actor_uid"])
|
||||
_index(db, "audit_log", "idx_audit_actor_role", ["actor_role"])
|
||||
_index(db, "audit_log", "idx_audit_origin", ["origin"])
|
||||
_index(db, "audit_log", "idx_audit_target", ["target_type", "target_uid"])
|
||||
_index(db, "audit_log", "idx_audit_result", ["result"])
|
||||
_index(db, "audit_log_links", "idx_audit_links_audit", ["audit_uid"])
|
||||
_index(db, "audit_log_links", "idx_audit_links_object", ["object_type", "object_uid"])
|
||||
_index(db, "audit_log_links", "idx_audit_links_relation", ["relation"])
|
||||
|
||||
if "news" in tables:
|
||||
for article in db["news"].find(status=None):
|
||||
was_featured = article.get("featured", 0)
|
||||
@@ -347,6 +362,7 @@ def init_db():
|
||||
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
|
||||
"customization_enabled": "1",
|
||||
"customization_js_enabled": "1",
|
||||
"audit_log_retention_days": "90",
|
||||
}
|
||||
for key, value in operational_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
|
||||
@@ -3030,6 +3030,7 @@ four ways to sign requests.
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
<<<<<<< HEAD
|
||||
id="admin-media",
|
||||
method="GET",
|
||||
path="/admin/media",
|
||||
@@ -3080,6 +3081,69 @@ four ways to sign requests.
|
||||
field("uid", "path", "string", True, "", "Attachment uid to purge."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
=======
|
||||
id="admin-audit-log",
|
||||
method="GET",
|
||||
path="/admin/audit-log",
|
||||
title="Audit log",
|
||||
summary=(
|
||||
"Paginated, filterable audit event list (newest first, 25 per page). "
|
||||
"Negotiates HTML or JSON. Every state-changing action on the platform "
|
||||
"is recorded here with actor, origin, target, change, and result."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("page", "query", "int", False, "1", "Page number."),
|
||||
field("event_key", "query", "string", False, "post.create", "Filter by exact event key."),
|
||||
field("category", "query", "string", False, "admin", "Filter by category (auth, content, admin, container, ...)."),
|
||||
field("actor_role", "query", "string", False, "admin", "Filter by actor role at action time."),
|
||||
field("actor_uid", "query", "string", False, "", "Filter by acting user uid."),
|
||||
field("origin", "query", "string", False, "web", "Filter by origin (web, api, devii, cli, service, scheduler)."),
|
||||
field("result", "query", "string", False, "denied", "Filter by result (success, failure, denied)."),
|
||||
field("q", "query", "string", False, "", "Free-text search over summary, event key, and target."),
|
||||
field("date_from", "query", "string", False, "", "ISO date lower bound (inclusive)."),
|
||||
field("date_to", "query", "string", False, "", "ISO date upper bound (inclusive)."),
|
||||
],
|
||||
sample_response={
|
||||
"entries": [
|
||||
{
|
||||
"uid": "AUDIT_UID",
|
||||
"created_at": "2026-06-11T20:00:00+00:00",
|
||||
"event_key": "admin.setting.update",
|
||||
"category": "admin",
|
||||
"actor_username": "ADMIN",
|
||||
"actor_role": "admin",
|
||||
"origin": "web",
|
||||
"via_agent": 0,
|
||||
"target_type": "setting",
|
||||
"old_value": "0",
|
||||
"new_value": "1",
|
||||
"result": "success",
|
||||
}
|
||||
],
|
||||
"pagination": {"page": 1, "total": 1, "total_pages": 1},
|
||||
"filters": {},
|
||||
"options": {"category": ["admin", "auth", "content"]},
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-audit-event",
|
||||
method="GET",
|
||||
path="/admin/audit-log/{uid}",
|
||||
title="Audit event",
|
||||
summary="A single audit event with its full row and every related-object link.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("uid", "path", "string", True, "AUDIT_UID", "Audit event uid.")],
|
||||
sample_response={
|
||||
"event": {"uid": "AUDIT_UID", "event_key": "container.instance.start", "result": "success"},
|
||||
"links": [
|
||||
{"relation": "actor", "object_type": "user", "object_uid": "USER_UID"},
|
||||
{"relation": "instance", "object_type": "instance", "object_uid": "INSTANCE_UID"},
|
||||
],
|
||||
},
|
||||
>>>>>>> 973021c (Update)
|
||||
),
|
||||
endpoint(
|
||||
id="admin-analytics",
|
||||
@@ -3402,6 +3466,8 @@ _PAGE_RESPONSES = {
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
"admin-settings-get": schemas.AdminSettingsOut,
|
||||
"admin-audit-log": schemas.AuditLogOut,
|
||||
"admin-audit-event": schemas.AuditEventOut,
|
||||
}
|
||||
|
||||
_ACTION_RESPONSES = {
|
||||
|
||||
@@ -71,6 +71,8 @@ from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -333,6 +335,13 @@ async def rate_limit_middleware(request: Request, call_next):
|
||||
window_start = now - window
|
||||
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
|
||||
if len(_rate_limit_store[ip]) >= limit:
|
||||
audit.record(
|
||||
request,
|
||||
"security.rate_limit.block",
|
||||
result="denied",
|
||||
summary=f"request from {ip} blocked by rate limit",
|
||||
metadata={"ip": ip, "limit": limit, "window_seconds": window},
|
||||
)
|
||||
retry_after = {"Retry-After": str(window)}
|
||||
if wants_json(request):
|
||||
response = json_error(429, "Rate limit exceeded. Try again later.")
|
||||
@@ -363,6 +372,13 @@ async def maintenance_middleware(request: Request, call_next):
|
||||
"maintenance_message",
|
||||
"DevPlace is undergoing scheduled maintenance. Please check back shortly.",
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"security.maintenance.block",
|
||||
user=user,
|
||||
result="denied",
|
||||
summary="non-admin request blocked by maintenance mode",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(503, message)
|
||||
seo_ctx = base_seo_context(
|
||||
@@ -390,6 +406,7 @@ async def startup():
|
||||
service_manager.register(ZipService())
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(AuditService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
if acquire_service_lock():
|
||||
service_manager.set_lock_owner(True)
|
||||
|
||||
+306
-2
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Request, Form
|
||||
@@ -30,7 +31,14 @@ from devplacepy.schemas import (
|
||||
AdminSettingsOut,
|
||||
AdminMediaOut,
|
||||
GatewayUsageOut,
|
||||
AuditLogOut,
|
||||
AuditEventOut,
|
||||
)
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.database import get_users_by_uids
|
||||
from devplacepy.services.audit import query as audit_query
|
||||
from devplacepy.services.audit import record as audit
|
||||
from urllib.parse import urlencode
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.openai_gateway.analytics import (
|
||||
build_analytics,
|
||||
@@ -42,6 +50,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_metadata(raw):
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return {"raw": str(raw)}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def admin_index(request: Request):
|
||||
require_admin(request)
|
||||
@@ -159,11 +178,40 @@ async def admin_user_role(
|
||||
admin = require_admin(request)
|
||||
role = data.role.capitalize()
|
||||
if uid == admin["uid"]:
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.role.change",
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=admin.get("username"),
|
||||
summary=f"admin {admin['username']} cannot change their own role",
|
||||
links=[audit.target("user", uid, admin.get("username"))],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
old_role = target_user.get("role") if target_user else None
|
||||
users.update({"uid": uid, "role": role}, ["uid"])
|
||||
clear_user_cache(uid)
|
||||
logger.info(f"Admin {admin['username']} set user {uid} role to {role}")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.role.change",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target_user.get("username") if target_user else None,
|
||||
old_value=old_role,
|
||||
new_value=role,
|
||||
summary=f"admin {admin['username']} changed role of {target_user.get('username') if target_user else uid} to {role}",
|
||||
links=[
|
||||
audit.target(
|
||||
"user", uid, target_user.get("username") if target_user else None
|
||||
)
|
||||
],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@@ -173,8 +221,23 @@ async def admin_user_password(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
users.update({"uid": uid, "password_hash": hash_password(data.password)}, ["uid"])
|
||||
logger.info(f"Admin {admin['username']} changed password for user {uid}")
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.password.reset",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target_user.get("username") if target_user else None,
|
||||
summary=f"admin {admin['username']} reset the password of {target_user.get('username') if target_user else uid}",
|
||||
links=[
|
||||
audit.target(
|
||||
"user", uid, target_user.get("username") if target_user else None
|
||||
)
|
||||
],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@@ -182,6 +245,17 @@ async def admin_user_password(
|
||||
async def admin_user_toggle(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
if uid == admin["uid"]:
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.active.disable",
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=admin.get("username"),
|
||||
summary=f"admin {admin['username']} cannot disable their own account",
|
||||
links=[audit.target("user", uid, admin.get("username"))],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
user = users.find_one(uid=uid)
|
||||
@@ -192,6 +266,17 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
logger.info(
|
||||
f"Admin {admin['username']} {'disabled' if not new_state else 'enabled'} user {uid}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.active.enable" if new_state else "admin.user.active.disable",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=user.get("username"),
|
||||
new_value=1 if new_state else 0,
|
||||
summary=f"admin {admin['username']} {'enabled' if new_state else 'disabled'} user {user.get('username')}",
|
||||
links=[audit.target("user", uid, user.get("username"))],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@@ -203,6 +288,22 @@ async def admin_user_reset_ai_quota(request: Request, uid: str):
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset AI quota for user {uid} ({removed} ledger rows)"
|
||||
)
|
||||
target_user = get_table("users").find_one(uid=uid)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.ai_quota.reset",
|
||||
user=admin,
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target_user.get("username") if target_user else None,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset the AI quota of {target_user.get('username') if target_user else uid}",
|
||||
links=[
|
||||
audit.target(
|
||||
"user", uid, target_user.get("username") if target_user else None
|
||||
)
|
||||
],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@@ -253,6 +354,13 @@ async def admin_reset_guest_ai_quota(request: Request):
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset all guest AI quotas ({removed} ledger rows)"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.ai_quota.reset_guests",
|
||||
user=admin,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset all guest AI quotas",
|
||||
)
|
||||
return action_result(request, "/admin/ai-usage")
|
||||
|
||||
|
||||
@@ -264,6 +372,13 @@ async def admin_reset_all_ai_quota(request: Request):
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset ALL AI quotas ({removed} ledger rows)"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.ai_quota.reset_all",
|
||||
user=admin,
|
||||
metadata={"rows_removed": removed},
|
||||
summary=f"admin {admin['username']} reset all AI quotas",
|
||||
)
|
||||
return action_result(request, "/admin/ai-usage")
|
||||
|
||||
|
||||
@@ -298,6 +413,125 @@ async def admin_settings(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-log", response_class=HTMLResponse)
|
||||
async def admin_audit_log(
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
event_key: str = "",
|
||||
category: str = "",
|
||||
actor_role: str = "",
|
||||
actor_uid: str = "",
|
||||
origin: str = "",
|
||||
result: str = "",
|
||||
q: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
):
|
||||
admin = require_admin(request)
|
||||
filters = {
|
||||
"event_key": event_key,
|
||||
"category": category,
|
||||
"actor_role": actor_role,
|
||||
"actor_uid": actor_uid,
|
||||
"origin": origin,
|
||||
"result": result,
|
||||
"q": q,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
}
|
||||
active = {key: value for key, value in filters.items() if value}
|
||||
rows, pagination = audit_query.list_events(filters, page)
|
||||
user_uids = [
|
||||
row["actor_uid"]
|
||||
for row in rows
|
||||
if row.get("actor_kind") == "user" and row.get("actor_uid")
|
||||
]
|
||||
users_by_uid = get_users_by_uids(user_uids)
|
||||
entries = []
|
||||
for row in rows:
|
||||
entry = dict(row)
|
||||
entry["actor_user"] = users_by_uid.get(row.get("actor_uid"))
|
||||
entry["time_ago"] = time_ago(row["created_at"]) if row.get("created_at") else ""
|
||||
entry["metadata"] = _parse_metadata(row.get("metadata"))
|
||||
entries.append(entry)
|
||||
filter_qs = urlencode(active)
|
||||
pagination_query = (filter_qs + "&") if filter_qs else ""
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Audit Log - Admin",
|
||||
description="Platform audit trail of every state-changing action.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Audit Log", "url": "/admin/audit-log"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_audit_log.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"entries": entries,
|
||||
"pagination": pagination,
|
||||
"pagination_query": pagination_query,
|
||||
"filters": active,
|
||||
"options": audit_query.filter_options(),
|
||||
"admin_section": "audit-log",
|
||||
},
|
||||
model=AuditLogOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-log/{uid}", response_class=HTMLResponse)
|
||||
async def admin_audit_event(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
data = audit_query.get_event_with_links(uid)
|
||||
if data is None:
|
||||
raise not_found("Audit event not found")
|
||||
event = dict(data["event"])
|
||||
if event.get("actor_kind") == "user" and event.get("actor_uid"):
|
||||
event["actor_user"] = get_users_by_uids([event["actor_uid"]]).get(
|
||||
event["actor_uid"]
|
||||
)
|
||||
event["metadata"] = _parse_metadata(event.get("metadata"))
|
||||
event["metadata_pretty"] = (
|
||||
json.dumps(event["metadata"], indent=2) if event["metadata"] else ""
|
||||
)
|
||||
event["time_ago"] = (
|
||||
time_ago(event["created_at"]) if event.get("created_at") else ""
|
||||
)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Audit Event - Admin",
|
||||
description="A single audit event with related objects.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Audit Log", "url": "/admin/audit-log"},
|
||||
{"name": "Event", "url": f"/admin/audit-log/{uid}"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_audit_event.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"event": event,
|
||||
"links": data["links"],
|
||||
"admin_section": "audit-log",
|
||||
},
|
||||
model=AuditEventOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/news", response_class=HTMLResponse)
|
||||
async def admin_news(request: Request, page: int = 1):
|
||||
admin = require_admin(request)
|
||||
@@ -358,10 +592,23 @@ async def admin_news_toggle(request: Request, uid: str):
|
||||
article = news_table.find_one(uid=uid)
|
||||
if article:
|
||||
current = article.get("featured", 0)
|
||||
news_table.update({"uid": uid, "featured": 0 if current else 1}, ["uid"])
|
||||
new_value = 0 if current else 1
|
||||
news_table.update({"uid": uid, "featured": new_value}, ["uid"])
|
||||
logger.info(
|
||||
f"Admin {admin['username']} toggled news {uid} featured={'off' if current else 'on'}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"news.featured.toggle",
|
||||
user=admin,
|
||||
target_type="news",
|
||||
target_uid=uid,
|
||||
target_label=article.get("title"),
|
||||
old_value=current,
|
||||
new_value=new_value,
|
||||
summary=f"admin {admin['username']} toggled featured for news {article.get('title')}",
|
||||
links=[audit.target("news", uid, article.get("title"))],
|
||||
)
|
||||
return action_result(request, "/admin/news")
|
||||
|
||||
|
||||
@@ -375,6 +622,18 @@ async def admin_news_publish(request: Request, uid: str):
|
||||
new_status = "draft" if current == "published" else "published"
|
||||
news_table.update({"uid": uid, "status": new_status}, ["uid"])
|
||||
logger.info(f"Admin {admin['username']} set news {uid} status={new_status}")
|
||||
audit.record(
|
||||
request,
|
||||
"news.publish.toggle",
|
||||
user=admin,
|
||||
target_type="news",
|
||||
target_uid=uid,
|
||||
target_label=article.get("title"),
|
||||
old_value=current,
|
||||
new_value=new_status,
|
||||
summary=f"admin {admin['username']} set news {article.get('title')} to {new_status}",
|
||||
links=[audit.target("news", uid, article.get("title"))],
|
||||
)
|
||||
return action_result(request, "/admin/news")
|
||||
|
||||
|
||||
@@ -385,10 +644,23 @@ async def admin_news_landing(request: Request, uid: str):
|
||||
article = news_table.find_one(uid=uid)
|
||||
if article:
|
||||
current = article.get("show_on_landing", 0)
|
||||
news_table.update({"uid": uid, "show_on_landing": 0 if current else 1}, ["uid"])
|
||||
new_value = 0 if current else 1
|
||||
news_table.update({"uid": uid, "show_on_landing": new_value}, ["uid"])
|
||||
logger.info(
|
||||
f"Admin {admin['username']} toggled news {uid} landing={'on' if not current else 'off'}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"news.landing.toggle",
|
||||
user=admin,
|
||||
target_type="news",
|
||||
target_uid=uid,
|
||||
target_label=article.get("title"),
|
||||
old_value=current,
|
||||
new_value=new_value,
|
||||
summary=f"admin {admin['username']} toggled landing visibility for news {article.get('title')}",
|
||||
links=[audit.target("news", uid, article.get("title"))],
|
||||
)
|
||||
return action_result(request, "/admin/news")
|
||||
|
||||
|
||||
@@ -399,10 +671,23 @@ async def admin_news_delete(request: Request, uid: str):
|
||||
article = news_table.find_one(uid=uid)
|
||||
if article:
|
||||
images_table = get_table("news_images")
|
||||
image_count = 0
|
||||
for img in images_table.find(news_uid=uid):
|
||||
images_table.delete(id=img["id"])
|
||||
image_count += 1
|
||||
news_table.delete(id=article["id"])
|
||||
logger.info(f"Admin {admin['username']} deleted news {uid}")
|
||||
audit.record(
|
||||
request,
|
||||
"news.delete",
|
||||
user=admin,
|
||||
target_type="news",
|
||||
target_uid=uid,
|
||||
target_label=article.get("title"),
|
||||
metadata={"image_count": image_count},
|
||||
summary=f"admin {admin['username']} deleted news article {article.get('title')}",
|
||||
links=[audit.target("news", uid, article.get("title"))],
|
||||
)
|
||||
return action_result(request, "/admin/news")
|
||||
|
||||
|
||||
@@ -412,14 +697,33 @@ async def admin_settings_save(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
settings = get_table("site_settings")
|
||||
changes = []
|
||||
for key, value in data.model_dump().items():
|
||||
existing = settings.find_one(key=key)
|
||||
if existing:
|
||||
if value == "":
|
||||
continue
|
||||
old_value = existing.get("value")
|
||||
if str(old_value) == str(value):
|
||||
continue
|
||||
settings.update({"id": existing["id"], "key": key, "value": value}, ["id"])
|
||||
changes.append((key, old_value, value))
|
||||
else:
|
||||
settings.insert({"uid": generate_uid(), "key": key, "value": value})
|
||||
changes.append((key, None, value))
|
||||
clear_settings_cache()
|
||||
logger.info(f"Admin {admin['username']} updated settings")
|
||||
for key, old_value, new_value in changes:
|
||||
audit.record(
|
||||
request,
|
||||
"admin.setting.update",
|
||||
user=admin,
|
||||
target_type="setting",
|
||||
target_uid=key,
|
||||
target_label=key,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
summary=f"admin {admin['username']} updated setting {key}",
|
||||
links=[audit.setting(key)],
|
||||
)
|
||||
return action_result(request, "/admin/settings")
|
||||
|
||||
@@ -27,6 +27,7 @@ from devplacepy.models import (
|
||||
from devplacepy.config import SECONDS_PER_DAY
|
||||
from devplacepy.responses import respond, action_result, wants_json, json_error
|
||||
from devplacepy.schemas import AuthPageOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -155,6 +156,19 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
||||
key="session", value=token, max_age=max_age, httponly=True, samesite="lax"
|
||||
)
|
||||
logger.info(f"User {username} signed up")
|
||||
role = "Admin" if is_first else "Member"
|
||||
audit.record(
|
||||
request,
|
||||
"auth.signup",
|
||||
user={"uid": uid, "username": username, "role": role},
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=username,
|
||||
new_value=role,
|
||||
metadata={"first_user": is_first},
|
||||
summary=f"user {username} registered account",
|
||||
links=[audit.target("user", uid, username)],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -172,6 +186,15 @@ async def login(request: Request, data: Annotated[LoginForm, Form()]):
|
||||
errors.append("Invalid email or password")
|
||||
|
||||
if errors:
|
||||
audit.record(
|
||||
request,
|
||||
"auth.login.failure",
|
||||
user=None,
|
||||
actor_kind="guest",
|
||||
result="failure",
|
||||
metadata={"email": email},
|
||||
summary=f"failed login attempt for {email}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(401, "; ".join(errors), errors=errors)
|
||||
seo_ctx = base_seo_context(request, title="Sign In", robots="noindex,nofollow")
|
||||
@@ -201,6 +224,17 @@ async def login(request: Request, data: Annotated[LoginForm, Form()]):
|
||||
key="session", value=token, max_age=max_age, httponly=True, samesite="lax"
|
||||
)
|
||||
logger.info(f"User {user['username']} logged in")
|
||||
audit.record(
|
||||
request,
|
||||
"auth.login.success",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
metadata={"remember_me": remember_me, "max_age_seconds": max_age},
|
||||
summary=f"user {user['username']} logged in",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -247,6 +281,28 @@ async def forgot_password(
|
||||
}
|
||||
)
|
||||
logger.info(f"Password reset requested for {email}")
|
||||
audit.record(
|
||||
request,
|
||||
"auth.password.forgot_request",
|
||||
user=None,
|
||||
actor_kind="guest",
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
metadata={"email": email},
|
||||
summary=f"password reset requested for {email}",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
else:
|
||||
audit.record(
|
||||
request,
|
||||
"auth.password.forgot_request",
|
||||
user=None,
|
||||
actor_kind="guest",
|
||||
result="failure",
|
||||
metadata={"email": email},
|
||||
summary=f"password reset requested for unknown email {email}",
|
||||
)
|
||||
|
||||
if wants_json(request):
|
||||
return JSONResponse({"ok": True, "sent": True})
|
||||
@@ -319,11 +375,30 @@ async def reset_password(
|
||||
)
|
||||
resets.update({"id": matched["id"], "used": True}, ["id"])
|
||||
logger.info(f"Password reset completed for user {matched['user_uid']}")
|
||||
reset_user = users.find_one(uid=matched["user_uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"auth.password.reset_complete",
|
||||
user=reset_user,
|
||||
actor_kind="user",
|
||||
target_type="user",
|
||||
target_uid=matched["user_uid"],
|
||||
target_label=reset_user.get("username") if reset_user else None,
|
||||
summary=f"password reset completed for user {reset_user.get('username') if reset_user else matched['user_uid']}",
|
||||
links=[
|
||||
audit.target(
|
||||
"user",
|
||||
matched["user_uid"],
|
||||
reset_user.get("username") if reset_user else None,
|
||||
)
|
||||
],
|
||||
)
|
||||
return action_result(request, "/auth/login")
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
user = get_current_user(request)
|
||||
token = request.cookies.get("session")
|
||||
if token:
|
||||
sessions = get_table("sessions")
|
||||
@@ -331,6 +406,17 @@ async def logout(request: Request):
|
||||
if session:
|
||||
sessions.delete(id=session["id"])
|
||||
clear_session_cache(token)
|
||||
if user:
|
||||
audit.record(
|
||||
request,
|
||||
"auth.logout",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user.get("username"),
|
||||
summary=f"user {user.get('username')} logged out",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
response = action_result(request, "/")
|
||||
response.delete_cookie("session")
|
||||
return response
|
||||
|
||||
@@ -8,6 +8,7 @@ from devplacepy.utils import generate_uid, require_user, time_ago
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import SavedOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -111,6 +112,16 @@ async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
|
||||
)
|
||||
saved = True
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"bookmark.add" if saved else "bookmark.remove",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
summary=f"{user['username']} {'bookmarked' if saved else 'removed bookmark from'} {target_type} {target_uid}",
|
||||
links=[audit.target(target_type, target_uid)],
|
||||
)
|
||||
|
||||
if request.headers.get("x-requested-with") == "fetch":
|
||||
return JSONResponse({"saved": saved})
|
||||
return RedirectResponse(
|
||||
|
||||
@@ -16,6 +16,7 @@ from devplacepy.utils import (
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import BugsOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -92,4 +93,14 @@ async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
||||
|
||||
create_mention_notifications(description, user["uid"], f"/bugs?highlight={bug_uid}")
|
||||
logger.info(f"Bug report created by {user['username']}: {title}")
|
||||
audit.record(
|
||||
request,
|
||||
"bug.create",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_uid=bug_uid,
|
||||
target_label=title,
|
||||
summary=f"{user['username']} reported a bug: {title}",
|
||||
links=[audit.target("bug", bug_uid, title)],
|
||||
)
|
||||
return action_result(request, "/bugs", data={"uid": bug_uid})
|
||||
|
||||
@@ -16,6 +16,7 @@ from devplacepy.utils import (
|
||||
)
|
||||
from devplacepy.models import CommentForm
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -79,6 +80,21 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
||||
|
||||
create_mention_notifications(content, user["uid"], comment_url)
|
||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||
comment_links = [
|
||||
audit.target("comment", comment_uid),
|
||||
audit.parent(target_type, target_uid),
|
||||
]
|
||||
if parent_uid:
|
||||
comment_links.append(audit.link("parent_comment", "comment", parent_uid))
|
||||
audit.record(
|
||||
request,
|
||||
f"comment.create.{target_type}",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} commented on {target_type} {target_uid}: {content}",
|
||||
links=comment_links,
|
||||
)
|
||||
return action_result(
|
||||
request, comment_url, data={"uid": comment_uid, "url": comment_url}
|
||||
)
|
||||
@@ -90,6 +106,15 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
comments = get_table("comments")
|
||||
comment = comments.find_one(uid=comment_uid)
|
||||
if not is_owner(comment, user):
|
||||
audit.record(
|
||||
request,
|
||||
"comment.delete",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} denied deleting comment {comment_uid}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
@@ -100,5 +125,14 @@ async def delete_comment(request: Request, comment_uid: str):
|
||||
delete_engagement("comment", [comment_uid])
|
||||
comments.delete(id=comment["id"])
|
||||
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"comment.delete",
|
||||
user=user,
|
||||
target_type="comment",
|
||||
target_uid=comment_uid,
|
||||
summary=f"{user['username']} deleted a comment under {target_type} {target_uid}",
|
||||
links=[audit.target("comment", comment_uid), audit.parent(target_type, target_uid)],
|
||||
)
|
||||
redirect_url = resolve_object_url(target_type, target_uid)
|
||||
return action_result(request, redirect_url)
|
||||
|
||||
@@ -31,11 +31,29 @@ from devplacepy.services.containers.api import ContainerError
|
||||
from devplacepy.services.containers.backend.base import WORKSPACE_MOUNT
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import Schedule, from_iso
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audit_instance(request, user, event_key, inst, project=None, summary=None, metadata=None):
|
||||
links = [audit.instance(inst["uid"], inst.get("name"))]
|
||||
if project:
|
||||
links.append(audit.project(project["uid"], project.get("title")))
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
metadata=metadata,
|
||||
summary=summary or f"{user['username']} {event_key} instance {inst.get('name')}",
|
||||
links=links,
|
||||
)
|
||||
|
||||
|
||||
def _project(project_slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
@@ -128,6 +146,11 @@ async def create_instance(
|
||||
)
|
||||
except ContainerError as exc:
|
||||
return _fail(exc)
|
||||
_audit_instance(
|
||||
request, user, "container.instance.create", inst, project,
|
||||
summary=f"admin {user['username']} created container instance {inst.get('name')} in project {project.get('title')}",
|
||||
metadata={"ports": data.ports, "ingress_slug": data.ingress_slug, "restart_policy": data.restart_policy},
|
||||
)
|
||||
return action_result(
|
||||
request, f"/projects/{_slug(project)}/containers", data={"instance": inst}
|
||||
)
|
||||
@@ -163,6 +186,10 @@ async def delete_instance(request: Request, project_slug: str, uid: str):
|
||||
project = _project(project_slug)
|
||||
inst = _instance(project, uid)
|
||||
api.mark_for_removal(inst, actor=("user", user["uid"]))
|
||||
_audit_instance(
|
||||
request, user, "container.instance.delete", inst, project,
|
||||
summary=f"admin {user['username']} removed container instance {inst.get('name')}",
|
||||
)
|
||||
return action_result(
|
||||
request, f"/projects/{_slug(project)}/containers", data={"uid": uid}
|
||||
)
|
||||
@@ -190,6 +217,11 @@ async def instance_exec(
|
||||
user["uid"],
|
||||
{"command": data.command, "exit_code": result.exit_code},
|
||||
)
|
||||
_audit_instance(
|
||||
request, user, "container.instance.exec", inst, project,
|
||||
summary=f"admin {user['username']} ran a command in instance {inst.get('name')}",
|
||||
metadata={"command": data.command, "exit_code": result.exit_code},
|
||||
)
|
||||
return JSONResponse({"exit_code": result.exit_code, "output": result.output})
|
||||
|
||||
|
||||
@@ -228,6 +260,11 @@ async def instance_sync(request: Request, project_slug: str, uid: str):
|
||||
count = await api.sync_workspace(inst, user)
|
||||
except ContainerError as exc:
|
||||
return _fail(exc)
|
||||
_audit_instance(
|
||||
request, user, "container.instance.sync", inst, project,
|
||||
summary=f"admin {user['username']} synced the workspace of instance {inst.get('name')}",
|
||||
metadata={"files_imported": count},
|
||||
)
|
||||
return action_result(
|
||||
request, f"/projects/{_slug(project)}/containers", data={"imported": count}
|
||||
)
|
||||
@@ -240,7 +277,7 @@ async def create_schedule(
|
||||
uid: str,
|
||||
data: Annotated[ContainerScheduleForm, Form()],
|
||||
):
|
||||
require_admin(request)
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
inst = _instance(project, uid)
|
||||
try:
|
||||
@@ -255,6 +292,17 @@ async def create_schedule(
|
||||
sched = api.add_schedule(inst, data.action, schedule)
|
||||
except (ContainerError, ValueError) as exc:
|
||||
return json_error(400, str(exc))
|
||||
audit.record(
|
||||
request,
|
||||
"container.schedule.create",
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
metadata={"action": data.action, "kind": data.kind, "cron": data.cron},
|
||||
summary=f"admin {user['username']} scheduled {data.action} on instance {inst.get('name')}",
|
||||
links=[audit.instance(inst["uid"], inst.get("name")), audit.schedule(sched.get("uid") if isinstance(sched, dict) else None)],
|
||||
)
|
||||
return action_result(
|
||||
request, f"/projects/{_slug(project)}/containers", data={"schedule": sched}
|
||||
)
|
||||
@@ -262,10 +310,21 @@ async def create_schedule(
|
||||
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete")
|
||||
async def delete_schedule(request: Request, project_slug: str, uid: str, sid: str):
|
||||
require_admin(request)
|
||||
user = require_admin(request)
|
||||
project = _project(project_slug)
|
||||
_instance(project, uid)
|
||||
inst = _instance(project, uid)
|
||||
store.delete_schedule(sid)
|
||||
audit.record(
|
||||
request,
|
||||
"container.schedule.delete",
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
metadata={"schedule_uid": sid},
|
||||
summary=f"admin {user['username']} deleted a schedule on instance {inst.get('name')}",
|
||||
links=[audit.instance(inst["uid"], inst.get("name")), audit.schedule(sid)],
|
||||
)
|
||||
return action_result(
|
||||
request, f"/projects/{_slug(project)}/containers", data={"uid": sid}
|
||||
)
|
||||
@@ -286,6 +345,10 @@ async def instance_action(request: Request, project_slug: str, uid: str):
|
||||
api.request_restart(inst, actor=actor)
|
||||
else:
|
||||
api.set_desired_state(inst, _ACTIONS[action], actor=actor)
|
||||
_audit_instance(
|
||||
request, user, f"container.instance.{action}", inst, project,
|
||||
summary=f"admin {user['username']} {action} container instance {inst.get('name')}",
|
||||
)
|
||||
return action_result(
|
||||
request,
|
||||
f"/projects/{_slug(project)}/containers",
|
||||
@@ -342,6 +405,17 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
)
|
||||
os.close(slave)
|
||||
loop = asyncio.get_event_loop()
|
||||
audit.record(
|
||||
websocket,
|
||||
"container.instance.shell.open",
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
metadata={"session": session or None},
|
||||
summary=f"admin {user['username']} opened an interactive shell on instance {inst.get('name')}",
|
||||
links=[audit.instance(inst["uid"], inst.get("name"))],
|
||||
)
|
||||
|
||||
async def pump_out():
|
||||
try:
|
||||
@@ -392,3 +466,13 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
os.close(master)
|
||||
audit.record(
|
||||
websocket,
|
||||
"container.instance.shell.close",
|
||||
user=user,
|
||||
target_type="instance",
|
||||
target_uid=inst["uid"],
|
||||
target_label=inst.get("name"),
|
||||
summary=f"admin {user['username']} closed the shell on instance {inst.get('name')}",
|
||||
links=[audit.instance(inst["uid"], inst.get("name"))],
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from devplacepy.utils import (
|
||||
get_current_user,
|
||||
is_admin,
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger("devii.router")
|
||||
|
||||
@@ -173,6 +174,12 @@ async def clippy_proxy(request: Request):
|
||||
headers["Authorization"] = f"Bearer {cfg['devii_ai_key']}"
|
||||
async with httpx.AsyncClient(timeout=45.0) as client:
|
||||
upstream = await client.post(cfg["devii_ai_url"], content=body, headers=headers)
|
||||
audit.record(
|
||||
request,
|
||||
"ai.clippy.chat",
|
||||
summary="clippy AI chat",
|
||||
metadata={"status_code": upstream.status_code},
|
||||
)
|
||||
return JSONResponse(
|
||||
content=upstream.json()
|
||||
if upstream.headers.get("content-type", "").startswith("application/json")
|
||||
@@ -229,6 +236,18 @@ async def devii_ws(websocket: WebSocket):
|
||||
limit = svc.daily_limit_for(owner_kind, owner_is_admin)
|
||||
spent = svc.spent_24h(owner_kind, owner_id)
|
||||
if limit > 0 and spent >= limit:
|
||||
audit.record_system(
|
||||
"ai.quota.exceeded",
|
||||
actor_kind=owner_kind if owner_kind == "guest" else "user",
|
||||
actor_uid=owner_id if owner_kind == "user" else None,
|
||||
actor_username=username,
|
||||
actor_role="admin" if owner_is_admin else ("guest" if owner_kind == "guest" else "member"),
|
||||
origin="devii",
|
||||
via_agent=1,
|
||||
result="denied",
|
||||
summary=f"AI request by {username or owner_id} blocked - 24h quota reached",
|
||||
metadata={"limit_usd": limit, "owner_kind": owner_kind},
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
|
||||
@@ -168,6 +168,13 @@ DOCS_PAGES = [
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
# Devii internals - technical reference for the Devii assistant (admins only)
|
||||
{
|
||||
"slug": "audit-log",
|
||||
"title": "Audit Log",
|
||||
"kind": "prose",
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
{
|
||||
"slug": "devii-internals",
|
||||
"title": "Devii internals",
|
||||
|
||||
@@ -10,6 +10,7 @@ from devplacepy.utils import (
|
||||
XP_FOLLOW,
|
||||
)
|
||||
from devplacepy.responses import action_result
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -47,6 +48,16 @@ async def follow_user(request: Request, username: str):
|
||||
award_rewards(target["uid"], XP_FOLLOW)
|
||||
|
||||
logger.info(f"{user['username']} followed {username}")
|
||||
audit.record(
|
||||
request,
|
||||
"follow.follow",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=username,
|
||||
summary=f"{user['username']} followed {username}",
|
||||
links=[audit.target("user", target["uid"], username)],
|
||||
)
|
||||
return action_result(request, f"/profile/{username}")
|
||||
|
||||
|
||||
@@ -63,5 +74,15 @@ async def unfollow_user(request: Request, username: str):
|
||||
if existing:
|
||||
follows.delete(id=existing["id"])
|
||||
logger.info(f"{user['username']} unfollowed {username}")
|
||||
audit.record(
|
||||
request,
|
||||
"follow.unfollow",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=username,
|
||||
summary=f"{user['username']} unfollowed {username}",
|
||||
links=[audit.target("user", target["uid"], username)],
|
||||
)
|
||||
|
||||
return action_result(request, f"/profile/{username}")
|
||||
|
||||
@@ -191,6 +191,7 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
|
||||
"First Gist",
|
||||
description or "",
|
||||
data.attachment_uids,
|
||||
request,
|
||||
)
|
||||
url = f"/gists/{gist_slug}"
|
||||
return action_result(request, url, data={"uid": uid, "slug": gist_slug, "url": url})
|
||||
@@ -216,6 +217,7 @@ async def edit_gist(
|
||||
"language": language,
|
||||
},
|
||||
"/gists",
|
||||
target_type="gist",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from devplacepy.utils import (
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import MessagesOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -127,6 +128,17 @@ async def messages_page(request: Request, with_uid: str = None, search: str = ""
|
||||
if with_uid:
|
||||
messages, other_user = get_conversation_messages(user["uid"], with_uid)
|
||||
current_conversation = with_uid
|
||||
audit.record(
|
||||
request,
|
||||
"message.read_on_view",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=with_uid,
|
||||
target_label=other_user.get("username") if other_user else None,
|
||||
metadata={"message_count": len(messages)},
|
||||
summary=f"{user['username']} read messages from {other_user.get('username') if other_user else with_uid}",
|
||||
links=[audit.target("user", with_uid, other_user.get("username") if other_user else None)],
|
||||
)
|
||||
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -219,6 +231,19 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
|
||||
)
|
||||
|
||||
logger.info(f"Message {msg_uid} sent from {user['username']} to {receiver_uid}")
|
||||
receiver = get_table("users").find_one(uid=receiver_uid)
|
||||
audit.record(
|
||||
request,
|
||||
"message.send",
|
||||
user=user,
|
||||
target_type="message",
|
||||
target_uid=msg_uid,
|
||||
summary=f"{user['username']} sent a message to {receiver.get('username') if receiver else receiver_uid}: {content}",
|
||||
links=[
|
||||
audit.target("message", msg_uid),
|
||||
audit.recipient(receiver_uid, receiver.get("username") if receiver else None),
|
||||
],
|
||||
)
|
||||
return action_result(
|
||||
request, f"/messages?with_uid={receiver_uid}", data={"uid": msg_uid}
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from devplacepy.utils import require_user, get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import NotificationsOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -139,6 +140,15 @@ async def open_notification(request: Request, notification_uid: str):
|
||||
if not n["read"]:
|
||||
notifications_table.update({"id": n["id"], "read": True}, ["id"])
|
||||
clear_unread_cache(user["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"notification.open",
|
||||
user=user,
|
||||
target_type="notification",
|
||||
target_uid=notification_uid,
|
||||
summary=f"{user['username']} opened a notification",
|
||||
links=[audit.target("notification", notification_uid)],
|
||||
)
|
||||
return action_result(request, n.get("target_url") or "/notifications")
|
||||
|
||||
|
||||
@@ -150,6 +160,15 @@ async def mark_read(request: Request, notification_uid: str):
|
||||
if n and n["user_uid"] == user["uid"]:
|
||||
notifications_table.update({"id": n["id"], "read": True}, ["id"])
|
||||
clear_unread_cache(user["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"notification.read.one",
|
||||
user=user,
|
||||
target_type="notification",
|
||||
target_uid=notification_uid,
|
||||
summary=f"{user['username']} marked a notification read",
|
||||
links=[audit.target("notification", notification_uid)],
|
||||
)
|
||||
return action_result(request, "/notifications")
|
||||
|
||||
|
||||
@@ -162,4 +181,10 @@ async def mark_all_read(request: Request):
|
||||
u=user["uid"],
|
||||
)
|
||||
clear_unread_cache(user["uid"])
|
||||
audit.record(
|
||||
request,
|
||||
"notification.read.all",
|
||||
user=user,
|
||||
summary=f"{user['username']} marked all notifications read",
|
||||
)
|
||||
return action_result(request, "/notifications")
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, get_poll_for_post
|
||||
from devplacepy.utils import generate_uid, require_user
|
||||
from devplacepy.models import PollVoteForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -29,10 +30,12 @@ async def vote_poll(
|
||||
if existing:
|
||||
if existing["option_uid"] == data.option_uid:
|
||||
poll_votes.delete(id=existing["id"])
|
||||
poll_event = "poll.vote.clear"
|
||||
else:
|
||||
poll_votes.update(
|
||||
{"id": existing["id"], "option_uid": data.option_uid}, ["id"]
|
||||
)
|
||||
poll_event = "poll.vote.change"
|
||||
else:
|
||||
poll_votes.insert(
|
||||
{
|
||||
@@ -43,6 +46,20 @@ async def vote_poll(
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
poll_event = "poll.vote.cast"
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
poll_event,
|
||||
user=user,
|
||||
target_type="poll",
|
||||
target_uid=poll_uid,
|
||||
old_value=existing["option_uid"] if existing else None,
|
||||
new_value=None if poll_event == "poll.vote.clear" else data.option_uid,
|
||||
metadata={"question": poll.get("question")},
|
||||
summary=f"{user['username']} {poll_event.split('.')[-1]} vote in poll {poll.get('question')}",
|
||||
links=[audit.poll(poll_uid, poll.get("question")), audit.option(data.option_uid, option.get("text"))],
|
||||
)
|
||||
|
||||
result = get_poll_for_post(poll["post_uid"], user)
|
||||
if request.headers.get("x-requested-with") == "fetch":
|
||||
|
||||
@@ -33,6 +33,7 @@ from devplacepy.seo import (
|
||||
)
|
||||
from devplacepy.attachments import save_inline_image
|
||||
from devplacepy.models import PostForm, PostEditForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -75,14 +76,17 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
|
||||
"First Post",
|
||||
content,
|
||||
data.attachment_uids,
|
||||
request,
|
||||
)
|
||||
|
||||
create_poll(uid, user, data.poll_question, data.poll_options)
|
||||
create_poll(uid, user, data.poll_question, data.poll_options, request)
|
||||
url = f"/posts/{post_slug}"
|
||||
return action_result(request, url, data={"uid": uid, "slug": post_slug, "url": url})
|
||||
|
||||
|
||||
def create_poll(post_uid: str, user: dict, question: str, options: list[str]) -> None:
|
||||
def create_poll(
|
||||
post_uid: str, user: dict, question: str, options: list[str], request=None
|
||||
) -> None:
|
||||
question = question.strip()
|
||||
labels = [option.strip() for option in options if option.strip()][:6]
|
||||
if not question or len(labels) < 2:
|
||||
@@ -108,6 +112,17 @@ def create_poll(post_uid: str, user: dict, question: str, options: list[str]) ->
|
||||
for index, label in enumerate(labels)
|
||||
]
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"poll.create",
|
||||
user=user,
|
||||
target_type="poll",
|
||||
target_uid=poll_uid,
|
||||
target_label=question,
|
||||
metadata={"option_count": len(labels), "question": question},
|
||||
summary=f"{user['username']} added a poll to post {post_uid}",
|
||||
links=[audit.poll(poll_uid, question), audit.parent("post", post_uid)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{post_slug}", response_class=HTMLResponse)
|
||||
@@ -206,6 +221,7 @@ async def edit_post(
|
||||
"topic": data.topic,
|
||||
},
|
||||
"/feed",
|
||||
target_type="post",
|
||||
)
|
||||
if data.poll_question.strip():
|
||||
post = resolve_by_slug(get_table("posts"), post_slug)
|
||||
@@ -214,7 +230,9 @@ async def edit_post(
|
||||
and post["user_uid"] == user["uid"]
|
||||
and not get_table("polls").find_one(post_uid=post["uid"])
|
||||
):
|
||||
create_poll(post["uid"], user, data.poll_question, data.poll_options)
|
||||
create_poll(
|
||||
post["uid"], user, data.poll_question, data.poll_options, request
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from devplacepy.seo import (
|
||||
profile_page_schema,
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -347,6 +348,17 @@ async def update_profile(request: Request, data: Annotated[ProfileForm, Form()])
|
||||
clear_user_cache(user["uid"])
|
||||
|
||||
logger.info(f"Profile updated for {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"profile.update",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
metadata={"fields": ["bio", "location", "git_link", "website"]},
|
||||
summary=f"{user['username']} updated their profile",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return action_result(request, f"/profile/{user['username']}")
|
||||
|
||||
|
||||
@@ -357,6 +369,16 @@ async def regenerate_api_key(request: Request):
|
||||
get_table("users").update({"uid": user["uid"], "api_key": new_key}, ["uid"])
|
||||
clear_user_cache(user["uid"])
|
||||
logger.info(f"API key regenerated for {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"profile.api_key.regenerate",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user["username"],
|
||||
summary=f"{user['username']} regenerated their API key",
|
||||
links=[audit.target("user", user["uid"], user["username"])],
|
||||
)
|
||||
return JSONResponse({"api_key": new_key})
|
||||
|
||||
|
||||
@@ -387,6 +409,17 @@ def _toggle_customization(
|
||||
logger.info(
|
||||
f"Customization {category} {'enabled' if value else 'disabled'} for {target['username']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
f"profile.customization.{category}.toggle",
|
||||
target_type="user",
|
||||
target_uid=target["uid"],
|
||||
target_label=target["username"],
|
||||
old_value=0 if value else 1,
|
||||
new_value=1 if value else 0,
|
||||
summary=f"{'enabled' if value else 'disabled'} {category} customizations for {target['username']}",
|
||||
links=[audit.target("user", target["uid"], target["username"])],
|
||||
)
|
||||
url = f"/profile/{target['username']}"
|
||||
return action_result(request, url, data={"url": url, column: not value})
|
||||
|
||||
|
||||
@@ -25,11 +25,27 @@ from devplacepy.seo import base_seo_context
|
||||
from devplacepy.utils import get_current_user, require_user, not_found
|
||||
from devplacepy import project_files
|
||||
from devplacepy.project_files import ProjectFileError
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audit_file(request, project, user, event_key, path, result="success", metadata=None):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
result=result,
|
||||
target_type="file",
|
||||
target_uid=f"{project['uid']}:{path}",
|
||||
target_label=path,
|
||||
metadata=metadata,
|
||||
summary=f"{(user or {}).get('username', 'someone')} {event_key} {path} in project {project.get('title')}",
|
||||
links=[audit.project(project["uid"], project.get("title")), audit.target("file", f"{project['uid']}:{path}", path)],
|
||||
)
|
||||
|
||||
|
||||
def _load_project(project_slug: str) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
@@ -54,7 +70,9 @@ def _deny(request: Request, project: dict):
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
|
||||
|
||||
def _fail(request: Request, project: dict, exc: ProjectFileError):
|
||||
def _fail(request: Request, project: dict, exc: ProjectFileError, user=None, event_key=None, path=""):
|
||||
if event_key and project.get("read_only"):
|
||||
_audit_file(request, project, user, event_key, path, result="denied")
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
@@ -127,6 +145,18 @@ async def project_files_zip(request: Request, project_slug: str, path: str = "")
|
||||
owner_id,
|
||||
name,
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"files.zip.request",
|
||||
user=user,
|
||||
actor_kind="user" if user else "guest",
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
metadata={"subpath": subpath},
|
||||
summary=f"requested a zip of {subpath or 'whole tree'} in project {project.get('title')}",
|
||||
links=[audit.project(project["uid"], project.get("title")), audit.job(uid)],
|
||||
)
|
||||
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
|
||||
|
||||
|
||||
@@ -153,24 +183,29 @@ async def project_file_write(
|
||||
project = _load_project(project_slug)
|
||||
if not is_owner(project, user):
|
||||
return _deny(request, project)
|
||||
existed = project_files.get_node(project["uid"], data.path) is not None
|
||||
write_event = "file.write.overwrite" if existed else "file.write.create"
|
||||
try:
|
||||
node = project_files.write_text_file(
|
||||
project["uid"], user, data.path, data.content
|
||||
)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, write_event, data.path)
|
||||
logger.info("project %s file written: %s", project["uid"], node["path"])
|
||||
_audit_file(request, project, user, write_event, node["path"])
|
||||
return action_result(
|
||||
request, _files_url(project), data=project_files.node_to_dict(node)
|
||||
)
|
||||
|
||||
|
||||
def _edit(request: Request, project: dict, mutate) -> JSONResponse:
|
||||
def _edit(request: Request, project: dict, mutate, user=None, event_key=None, metadata=None) -> JSONResponse:
|
||||
try:
|
||||
node = mutate()
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, event_key)
|
||||
logger.info("project %s file edited: %s", project["uid"], node["path"])
|
||||
if event_key:
|
||||
_audit_file(request, project, user, event_key, node["path"], metadata=metadata)
|
||||
return action_result(
|
||||
request, _files_url(project), data=project_files.node_to_dict(node)
|
||||
)
|
||||
@@ -192,6 +227,9 @@ async def project_file_replace_lines(
|
||||
lambda: project_files.replace_lines(
|
||||
project["uid"], data.path, data.start, data.end, data.content
|
||||
),
|
||||
user,
|
||||
"file.replace_lines",
|
||||
{"line_start": data.start, "line_end": data.end},
|
||||
)
|
||||
|
||||
|
||||
@@ -211,6 +249,9 @@ async def project_file_insert_lines(
|
||||
lambda: project_files.insert_lines(
|
||||
project["uid"], data.path, data.at, data.content
|
||||
),
|
||||
user,
|
||||
"file.insert_lines",
|
||||
{"insert_at": data.at},
|
||||
)
|
||||
|
||||
|
||||
@@ -230,6 +271,9 @@ async def project_file_delete_lines(
|
||||
lambda: project_files.delete_lines(
|
||||
project["uid"], data.path, data.start, data.end
|
||||
),
|
||||
user,
|
||||
"file.delete_lines",
|
||||
{"line_start": data.start, "line_end": data.end},
|
||||
)
|
||||
|
||||
|
||||
@@ -245,6 +289,8 @@ async def project_file_append(
|
||||
request,
|
||||
project,
|
||||
lambda: project_files.append_lines(project["uid"], data.path, data.content),
|
||||
user,
|
||||
"file.append",
|
||||
)
|
||||
|
||||
|
||||
@@ -265,8 +311,12 @@ async def project_file_upload(request: Request, project_slug: str):
|
||||
project["uid"], user, target_dir, file.filename, content
|
||||
)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, "file.upload", target_dir)
|
||||
logger.info("project %s file uploaded: %s", project["uid"], node["path"])
|
||||
_audit_file(
|
||||
request, project, user, "file.upload", node["path"],
|
||||
metadata={"size": len(content), "filename": file.filename},
|
||||
)
|
||||
return action_result(
|
||||
request, _files_url(project), data=project_files.node_to_dict(node)
|
||||
)
|
||||
@@ -283,7 +333,8 @@ async def project_file_mkdir(
|
||||
try:
|
||||
node = project_files.make_dir(project["uid"], user, data.path)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, "dir.create", data.path)
|
||||
_audit_file(request, project, user, "dir.create", node["path"])
|
||||
return action_result(
|
||||
request, _files_url(project), data=project_files.node_to_dict(node)
|
||||
)
|
||||
@@ -302,7 +353,21 @@ async def project_file_move(
|
||||
project["uid"], user, data.from_path, data.to_path
|
||||
)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, "file.move", data.from_path)
|
||||
audit.record(
|
||||
request,
|
||||
"file.move",
|
||||
user=user,
|
||||
target_type="file",
|
||||
target_uid=f"{project['uid']}:{data.to_path}",
|
||||
target_label=data.to_path,
|
||||
summary=f"{user['username']} moved {data.from_path} to {data.to_path} in project {project.get('title')}",
|
||||
links=[
|
||||
audit.project(project["uid"], project.get("title")),
|
||||
audit.source("file", f"{project['uid']}:{data.from_path}", data.from_path),
|
||||
audit.destination("file", f"{project['uid']}:{data.to_path}", data.to_path),
|
||||
],
|
||||
)
|
||||
return action_result(
|
||||
request, _files_url(project), data=project_files.node_to_dict(node)
|
||||
)
|
||||
@@ -319,5 +384,6 @@ async def project_file_delete(
|
||||
try:
|
||||
project_files.delete_node(project["uid"], data.path)
|
||||
except ProjectFileError as exc:
|
||||
return _fail(request, project, exc)
|
||||
return _fail(request, project, exc, user, "file.delete", data.path)
|
||||
_audit_file(request, project, user, "file.delete", data.path)
|
||||
return action_result(request, _files_url(project), data={"path": data.path})
|
||||
|
||||
@@ -43,6 +43,7 @@ from devplacepy.seo import (
|
||||
)
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import ProjectsOut, ProjectDetailOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -223,6 +224,17 @@ async def zip_project(request: Request, project_slug: str):
|
||||
owner_id,
|
||||
project.get("title") or "project",
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"project.zip.request",
|
||||
user=user,
|
||||
actor_kind="user" if user else "guest",
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
summary=f"requested a zip of project {project.get('title')}",
|
||||
links=[audit.target("project", project["uid"], project.get("title")), audit.job(uid)],
|
||||
)
|
||||
return JSONResponse({"uid": uid, "status_url": f"/zips/{uid}"})
|
||||
|
||||
|
||||
@@ -248,6 +260,17 @@ async def fork_project(
|
||||
user["uid"],
|
||||
title,
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"project.fork.request",
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=source["uid"],
|
||||
target_label=source.get("title"),
|
||||
metadata={"new_title": title},
|
||||
summary=f"{user['username']} requested a fork of project {source.get('title')}",
|
||||
links=[audit.source("project", source["uid"], source.get("title")), audit.job(uid)],
|
||||
)
|
||||
return JSONResponse({"uid": uid, "status_url": f"/forks/{uid}"})
|
||||
|
||||
|
||||
@@ -285,6 +308,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Form()])
|
||||
"First Project",
|
||||
description,
|
||||
data.attachment_uids,
|
||||
request,
|
||||
)
|
||||
url = f"/projects/{project_slug}"
|
||||
return action_result(
|
||||
@@ -312,19 +336,40 @@ async def edit_project(
|
||||
"status": data.status,
|
||||
},
|
||||
"/projects",
|
||||
target_type="project",
|
||||
)
|
||||
|
||||
|
||||
_FLAG_EVENTS = {
|
||||
("is_private", True): "project.visibility.private",
|
||||
("is_private", False): "project.visibility.public",
|
||||
("read_only", True): "project.readonly.enable",
|
||||
("read_only", False): "project.readonly.disable",
|
||||
}
|
||||
|
||||
|
||||
def _set_project_flag(request: Request, project_slug: str, field: str, value: bool):
|
||||
user = require_user(request)
|
||||
projects = get_table("projects")
|
||||
project = resolve_by_slug(projects, project_slug)
|
||||
event_key = _FLAG_EVENTS[(field, bool(value))]
|
||||
if not is_owner(project, user):
|
||||
from devplacepy.responses import wants_json, json_error
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="project",
|
||||
target_uid=project["uid"] if project else project_slug,
|
||||
target_label=project.get("title") if project else project_slug,
|
||||
summary=f"{user['username']} denied changing {field} on project {project_slug}",
|
||||
)
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
|
||||
old_value = 1 if project.get(field) else 0
|
||||
projects.update({"uid": project["uid"], field: 1 if value else 0}, ["uid"])
|
||||
logger.info(
|
||||
"project %s %s set to %s by %s",
|
||||
@@ -333,6 +378,18 @@ def _set_project_flag(request: Request, project_slug: str, field: str, value: bo
|
||||
bool(value),
|
||||
user["username"],
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=user,
|
||||
target_type="project",
|
||||
target_uid=project["uid"],
|
||||
target_label=project.get("title"),
|
||||
old_value=old_value,
|
||||
new_value=1 if value else 0,
|
||||
summary=f"{user['username']} set {field} of project {project.get('title')} to {bool(value)}",
|
||||
links=[audit.target("project", project["uid"], project.get("title"))],
|
||||
)
|
||||
url = f"/projects/{project['slug'] or project['uid']}"
|
||||
return action_result(
|
||||
request, url, data={"uid": project["uid"], field: bool(value), "url": url}
|
||||
|
||||
@@ -10,6 +10,7 @@ from starlette.responses import Response
|
||||
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -84,6 +85,16 @@ async def proxy_http(request: Request, slug: str, path: str = ""):
|
||||
return Response(
|
||||
"the published container has no reachable port", status_code=502
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"proxy.access",
|
||||
target_type="instance",
|
||||
target_uid=instance["uid"],
|
||||
target_label=instance.get("name"),
|
||||
metadata={"slug": slug, "path": path, "method": request.method},
|
||||
summary=f"request proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
prefix = f"/p/{slug}"
|
||||
url = f"http://{host}:{port}/{path}"
|
||||
headers = _forward_headers(request, prefix)
|
||||
@@ -132,6 +143,17 @@ async def proxy_ws(websocket: WebSocket, slug: str, path: str = ""):
|
||||
if websocket.url.query:
|
||||
upstream_url += f"?{websocket.url.query}"
|
||||
await websocket.accept()
|
||||
audit.record(
|
||||
websocket,
|
||||
"proxy.access",
|
||||
user=None,
|
||||
target_type="instance",
|
||||
target_uid=instance["uid"],
|
||||
target_label=instance.get("name"),
|
||||
metadata={"slug": slug, "path": path, "protocol": "websocket"},
|
||||
summary=f"websocket proxied to instance {instance.get('name')} via ingress {slug}",
|
||||
links=[audit.instance(instance["uid"], instance.get("name"))],
|
||||
)
|
||||
try:
|
||||
async with websockets.connect(
|
||||
upstream_url, open_timeout=10, max_size=None
|
||||
|
||||
@@ -6,6 +6,8 @@ from fastapi.responses import FileResponse, JSONResponse
|
||||
from devplacepy import push
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import require_user_api
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -53,6 +55,17 @@ async def push_register(request: Request) -> JSONResponse:
|
||||
except Exception as exc:
|
||||
logger.warning("Welcome push failed for %s: %s", user["uid"], exc)
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"push.subscribe" if created else "push.update",
|
||||
user=user,
|
||||
target_type="user",
|
||||
target_uid=user["uid"],
|
||||
target_label=user.get("username"),
|
||||
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
|
||||
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
|
||||
links=[audit.target("user", user["uid"], user.get("username"))],
|
||||
)
|
||||
return JSONResponse({"registered": True})
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from devplacepy.database import get_table, db
|
||||
from devplacepy.utils import generate_uid, require_user
|
||||
from devplacepy.models import ReactionForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -35,6 +36,7 @@ async def react(
|
||||
)
|
||||
if existing:
|
||||
reactions.delete(id=existing["id"])
|
||||
reaction_event = "reaction.remove"
|
||||
else:
|
||||
reactions.insert(
|
||||
{
|
||||
@@ -46,6 +48,18 @@ async def react(
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
reaction_event = "reaction.add"
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
reaction_event,
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
metadata={"emoji": emoji},
|
||||
summary=f"{user['username']} {'removed' if existing else 'added'} {emoji} on {target_type} {target_uid}",
|
||||
links=[audit.target(target_type, target_uid)],
|
||||
)
|
||||
|
||||
counts = {
|
||||
row["emoji"]: row["c"]
|
||||
|
||||
@@ -5,6 +5,7 @@ from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin, not_found
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -88,45 +89,94 @@ async def service_detail_data(request: Request, name: str):
|
||||
return JSONResponse({"service": svc.describe()})
|
||||
|
||||
|
||||
def _audit_service(request, admin, name, event_key, summary, **kwargs):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
target_type="service",
|
||||
target_uid=name,
|
||||
target_label=name,
|
||||
summary=summary,
|
||||
links=[audit.service_link(name)],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{name}/start")
|
||||
async def service_start(request: Request, name: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not service_manager.set_enabled(name, True):
|
||||
return JSONResponse({"ok": False, "error": "unknown service"}, status_code=404)
|
||||
_audit_service(
|
||||
request, admin, name, "service.start",
|
||||
f"admin {admin['username']} started service {name}", new_value="enabled",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{name}/stop")
|
||||
async def service_stop(request: Request, name: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not service_manager.set_enabled(name, False):
|
||||
return JSONResponse({"ok": False, "error": "unknown service"}, status_code=404)
|
||||
_audit_service(
|
||||
request, admin, name, "service.stop",
|
||||
f"admin {admin['username']} stopped service {name}", new_value="disabled",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{name}/run")
|
||||
async def service_run(request: Request, name: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not service_manager.send_command(name, "run"):
|
||||
return JSONResponse({"ok": False, "error": "unknown service"}, status_code=404)
|
||||
_audit_service(
|
||||
request, admin, name, "service.run_now",
|
||||
f"admin {admin['username']} triggered an immediate run of service {name}",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{name}/clear-logs")
|
||||
async def service_clear_logs(request: Request, name: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not service_manager.send_command(name, "clear"):
|
||||
return JSONResponse({"ok": False, "error": "unknown service"}, status_code=404)
|
||||
_audit_service(
|
||||
request, admin, name, "service.logs.clear",
|
||||
f"admin {admin['username']} cleared logs for service {name}",
|
||||
)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/{name}/config")
|
||||
async def service_config(request: Request, name: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
svc = service_manager.get_service(name)
|
||||
before = svc.get_config() if svc is not None else {}
|
||||
form = await request.form()
|
||||
result = service_manager.save_config(name, dict(form))
|
||||
if result is None:
|
||||
return JSONResponse({"ok": False, "error": "unknown service"}, status_code=404)
|
||||
if not result["ok"]:
|
||||
return JSONResponse(result, status_code=400)
|
||||
after = svc.get_config() if svc is not None else {}
|
||||
for key, new_value in after.items():
|
||||
old_value = before.get(key)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
audit.record(
|
||||
request,
|
||||
"service.config.update",
|
||||
user=admin,
|
||||
target_type="service",
|
||||
target_uid=name,
|
||||
target_label=name,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
summary=f"admin {admin['username']} updated {key} of service {name}",
|
||||
links=[audit.service_link(name), audit.setting(key)],
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
@@ -13,6 +13,8 @@ from devplacepy.attachments import (
|
||||
is_extension_allowed,
|
||||
RemoteFetchError,
|
||||
)
|
||||
from urllib.parse import urlparse
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -49,6 +51,17 @@ async def upload_file(request: Request):
|
||||
logger.info(
|
||||
f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.upload",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=result.get("uid"),
|
||||
target_label=file.filename,
|
||||
metadata={"size": len(content), "mime": result.get("mime_type")},
|
||||
summary=f"{user['username']} uploaded attachment {file.filename}",
|
||||
links=[audit.attachment_link(result.get("uid"), file.filename)],
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@@ -63,6 +76,17 @@ async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Form(
|
||||
logger.info(
|
||||
f"URL attached: {data.url} ({result['file_size']} bytes) -> {result['url']}"
|
||||
)
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.upload_url",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=result.get("uid"),
|
||||
target_label=result.get("filename"),
|
||||
metadata={"source_host": urlparse(data.url).hostname, "size": result.get("file_size"), "mime": result.get("mime_type")},
|
||||
summary=f"{user['username']} attached a remote file from {urlparse(data.url).hostname}",
|
||||
links=[audit.attachment_link(result.get("uid"), result.get("filename"))],
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@@ -76,4 +100,14 @@ async def delete_attachment_route(request: Request, attachment_uid: str):
|
||||
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
||||
_delete_attachment(attachment_uid)
|
||||
logger.info(f"Attachment {attachment_uid} deleted by {user['username']}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.delete",
|
||||
user=user,
|
||||
target_type="attachment",
|
||||
target_uid=attachment_uid,
|
||||
target_label=att.get("filename"),
|
||||
summary=f"{user['username']} deleted attachment {att.get('filename') or attachment_uid}",
|
||||
links=[audit.attachment_link(attachment_uid, att.get("filename"))],
|
||||
)
|
||||
return JSONResponse({"status": "deleted"})
|
||||
|
||||
@@ -17,6 +17,7 @@ from devplacepy.utils import (
|
||||
XP_UPVOTE,
|
||||
)
|
||||
from devplacepy.models import VoteForm
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -39,10 +40,13 @@ async def vote(
|
||||
user_uid=user["uid"], target_uid=target_uid, target_type=target_type
|
||||
)
|
||||
|
||||
old_value = int(existing["value"]) if existing else 0
|
||||
did_upvote = False
|
||||
new_value = value
|
||||
if existing:
|
||||
if int(existing["value"]) == value:
|
||||
votes.delete(id=existing["id"])
|
||||
new_value = 0
|
||||
else:
|
||||
votes.update({"id": existing["id"], "value": value}, ["id"])
|
||||
did_upvote = value == 1
|
||||
@@ -65,8 +69,25 @@ async def vote(
|
||||
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
|
||||
vote_links = [audit.target(target_type, target_uid)]
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
vote_links.append(audit.author(owner_uid))
|
||||
audit.record(
|
||||
request,
|
||||
f"vote.{target_type}.{direction}",
|
||||
user=user,
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
metadata={"value_old": old_value, "value_new": new_value, "net": net},
|
||||
summary=f"{user['username']} {direction} vote on {target_type} {target_uid}",
|
||||
links=vote_links,
|
||||
)
|
||||
|
||||
if did_upvote and target_type in NOTIFY_ON_VOTE:
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
target_url = resolve_object_url(target_type, target_uid)
|
||||
create_notification(
|
||||
|
||||
@@ -647,3 +647,52 @@ class AuthPageOut(_Out):
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
# ---------- audit log ----------
|
||||
|
||||
|
||||
class AuditLinkOut(_Out):
|
||||
uid: str = ""
|
||||
relation: str = ""
|
||||
object_type: str = ""
|
||||
object_uid: str = ""
|
||||
object_label: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class AuditEntryOut(_Out):
|
||||
uid: str = ""
|
||||
created_at: Optional[str] = None
|
||||
event_key: str = ""
|
||||
category: str = ""
|
||||
actor_kind: str = ""
|
||||
actor_uid: Optional[str] = None
|
||||
actor_username: Optional[str] = None
|
||||
actor_role: str = ""
|
||||
origin: str = ""
|
||||
via_agent: int = 0
|
||||
request_method: Optional[str] = None
|
||||
request_path: Optional[str] = None
|
||||
actor_ip: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_label: Optional[str] = None
|
||||
old_value: Optional[str] = None
|
||||
new_value: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
metadata: Optional[Any] = None
|
||||
result: str = "success"
|
||||
|
||||
|
||||
class AuditLogOut(_Out):
|
||||
entries: list[AuditEntryOut] = []
|
||||
pagination: Optional[dict] = None
|
||||
filters: dict = {}
|
||||
options: dict = {}
|
||||
|
||||
|
||||
class AuditEventOut(_Out):
|
||||
event: Optional[AuditEntryOut] = None
|
||||
links: list[AuditLinkOut] = []
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.audit import store, record, query, categories
|
||||
from devplacepy.services.audit.service import AuditService
|
||||
|
||||
__all__ = ["store", "record", "query", "categories", "AuditService"]
|
||||
@@ -0,0 +1,49 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"auth": "auth",
|
||||
"profile": "account",
|
||||
"follow": "social",
|
||||
"push": "push",
|
||||
"notification": "notification",
|
||||
"message": "message",
|
||||
"post": "content",
|
||||
"comment": "content",
|
||||
"gist": "content",
|
||||
"bug": "content",
|
||||
"vote": "engagement",
|
||||
"reaction": "engagement",
|
||||
"bookmark": "engagement",
|
||||
"poll": "engagement",
|
||||
"project": "project",
|
||||
"file": "project_files",
|
||||
"dir": "project_files",
|
||||
"files": "project_files",
|
||||
"job.zip": "project_files",
|
||||
"attachment": "attachment",
|
||||
"news": "news",
|
||||
"admin": "admin",
|
||||
"service": "service",
|
||||
"container": "container",
|
||||
"proxy": "ingress",
|
||||
"ai": "ai",
|
||||
"devii": "devii",
|
||||
"cli": "cli",
|
||||
"reward": "reward",
|
||||
"security": "security",
|
||||
}
|
||||
|
||||
EVENT_RESULTS = ("success", "failure", "denied")
|
||||
ACTOR_KINDS = ("user", "guest", "system", "cli", "service")
|
||||
ORIGINS = ("web", "api", "devii", "cli", "service", "scheduler")
|
||||
|
||||
|
||||
def category_for(event_key: str) -> str:
|
||||
if not event_key:
|
||||
return "other"
|
||||
parts = event_key.split(".")
|
||||
for size in range(len(parts), 0, -1):
|
||||
prefix = ".".join(parts[:size])
|
||||
if prefix in CATEGORY_BY_PREFIX:
|
||||
return CATEGORY_BY_PREFIX[prefix]
|
||||
return parts[0]
|
||||
@@ -0,0 +1,93 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.cache import TTLCache
|
||||
from devplacepy.database import db, build_pagination
|
||||
from devplacepy.services.audit import store
|
||||
from devplacepy.services.audit.store import AUDIT_TABLE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FILTERABLE = ("event_key", "category", "actor_role", "actor_uid", "origin", "result")
|
||||
|
||||
_options_cache = TTLCache(ttl=60, max_size=8)
|
||||
|
||||
|
||||
def _build_where(filters: dict) -> tuple[str, dict]:
|
||||
clauses: list[str] = []
|
||||
params: dict = {}
|
||||
for key in FILTERABLE:
|
||||
value = filters.get(key)
|
||||
if value:
|
||||
clauses.append(f"{key} = :{key}")
|
||||
params[key] = value
|
||||
query_text = (filters.get("q") or "").strip()
|
||||
if query_text:
|
||||
clauses.append("(summary LIKE :q OR event_key LIKE :q OR target_label LIKE :q)")
|
||||
params["q"] = f"%{query_text}%"
|
||||
date_from = (filters.get("date_from") or "").strip()
|
||||
if date_from:
|
||||
clauses.append("created_at >= :date_from")
|
||||
params["date_from"] = date_from
|
||||
date_to = (filters.get("date_to") or "").strip()
|
||||
if date_to:
|
||||
clauses.append("created_at <= :date_to")
|
||||
params["date_to"] = date_to + "~"
|
||||
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
return where, params
|
||||
|
||||
|
||||
def list_events(filters: dict, page: int, per_page: int = 25) -> tuple[list, dict]:
|
||||
if AUDIT_TABLE not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
where, params = _build_where(filters)
|
||||
total_rows = list(
|
||||
db.query(f"SELECT COUNT(*) AS c FROM {AUDIT_TABLE}{where}", **params)
|
||||
)
|
||||
total = int(total_rows[0]["c"]) if total_rows else 0
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT * FROM {AUDIT_TABLE}{where} "
|
||||
f"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
**params,
|
||||
)
|
||||
)
|
||||
return rows, pagination
|
||||
|
||||
|
||||
def _distinct(column: str) -> list[str]:
|
||||
if AUDIT_TABLE not in db.tables:
|
||||
return []
|
||||
rows = db.query(
|
||||
f"SELECT DISTINCT {column} AS value FROM {AUDIT_TABLE} "
|
||||
f"WHERE {column} IS NOT NULL AND {column} != '' ORDER BY {column}"
|
||||
)
|
||||
return [row["value"] for row in rows]
|
||||
|
||||
|
||||
def filter_options() -> dict:
|
||||
cached = _options_cache.get("options")
|
||||
if cached is not None:
|
||||
return cached
|
||||
options = {
|
||||
"event_key": _distinct("event_key"),
|
||||
"category": _distinct("category"),
|
||||
"actor_role": _distinct("actor_role"),
|
||||
"origin": _distinct("origin"),
|
||||
"result": _distinct("result"),
|
||||
}
|
||||
_options_cache.set("options", options)
|
||||
return options
|
||||
|
||||
|
||||
def get_event_with_links(uid: str) -> Optional[dict]:
|
||||
event = store.get_event(uid)
|
||||
if not event:
|
||||
return None
|
||||
return {"event": event, "links": store.get_links(uid)}
|
||||
@@ -0,0 +1,327 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from devplacepy.utils import strip_html
|
||||
from devplacepy.services.audit import store
|
||||
from devplacepy.services.audit.categories import category_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNSET = object()
|
||||
SUMMARY_LIMIT = 140
|
||||
|
||||
|
||||
def link(
|
||||
relation: str,
|
||||
object_type: str,
|
||||
object_uid: Optional[str],
|
||||
object_label: Optional[str] = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"relation": relation,
|
||||
"object_type": object_type,
|
||||
"object_uid": object_uid,
|
||||
"object_label": object_label,
|
||||
}
|
||||
|
||||
|
||||
def actor(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("actor", "user", uid, label)
|
||||
|
||||
|
||||
def target(object_type: str, uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("target", object_type, uid, label)
|
||||
|
||||
|
||||
def parent(object_type: str, uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("parent", object_type, uid, label)
|
||||
|
||||
|
||||
def author(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("author", "user", uid, label)
|
||||
|
||||
|
||||
def recipient(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("recipient", "user", uid, label)
|
||||
|
||||
|
||||
def mention(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("mention", "user", uid, label)
|
||||
|
||||
|
||||
def project(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("project", "project", uid, label)
|
||||
|
||||
|
||||
def attachment_link(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("attachment", "attachment", uid, label)
|
||||
|
||||
|
||||
def source(object_type: str, uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("source", object_type, uid, label)
|
||||
|
||||
|
||||
def destination(object_type: str, uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("destination", object_type, uid, label)
|
||||
|
||||
|
||||
def schedule(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("schedule", "schedule", uid, label)
|
||||
|
||||
|
||||
def instance(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("instance", "instance", uid, label)
|
||||
|
||||
|
||||
def setting(key: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("setting", "setting", key, label or key)
|
||||
|
||||
|
||||
def service_link(name: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("target", "service", name, label or name)
|
||||
|
||||
|
||||
def job(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("job", "job", uid, label)
|
||||
|
||||
|
||||
def poll(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("poll", "poll", uid, label)
|
||||
|
||||
|
||||
def option(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("option", "poll_option", uid, label)
|
||||
|
||||
|
||||
def task(uid: Optional[str], label: Optional[str] = None) -> dict:
|
||||
return link("target", "task", uid, label)
|
||||
|
||||
|
||||
def _actor_from(user: Optional[dict]) -> dict:
|
||||
if not user:
|
||||
return {
|
||||
"actor_kind": "guest",
|
||||
"actor_uid": None,
|
||||
"actor_username": None,
|
||||
"actor_role": "guest",
|
||||
}
|
||||
return {
|
||||
"actor_kind": "user",
|
||||
"actor_uid": user.get("uid"),
|
||||
"actor_username": user.get("username"),
|
||||
"actor_role": "admin" if user.get("role") == "Admin" else "member",
|
||||
}
|
||||
|
||||
|
||||
def _request_fields(request) -> dict:
|
||||
fields: dict = {
|
||||
"request_method": None,
|
||||
"request_path": None,
|
||||
"actor_ip": None,
|
||||
"user_agent": None,
|
||||
"devii": False,
|
||||
}
|
||||
if request is None:
|
||||
return fields
|
||||
try:
|
||||
fields["request_method"] = getattr(request, "method", None)
|
||||
url = getattr(request, "url", None)
|
||||
fields["request_path"] = url.path if url is not None else None
|
||||
client = getattr(request, "client", None)
|
||||
fields["actor_ip"] = client.host if client is not None else None
|
||||
headers = getattr(request, "headers", None)
|
||||
if headers is not None:
|
||||
fields["user_agent"] = headers.get("user-agent")
|
||||
fields["devii"] = headers.get("x-devii-agent") == "1"
|
||||
except Exception as exc:
|
||||
logger.debug("audit request field read failed: %s", exc)
|
||||
return fields
|
||||
|
||||
|
||||
def _sanitize_summary(text: Optional[str], limit: int = SUMMARY_LIMIT) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
cleaned = strip_html(str(text))
|
||||
if not cleaned:
|
||||
return None
|
||||
if len(cleaned) > limit:
|
||||
return cleaned[: limit - 3].rstrip() + "..."
|
||||
return cleaned
|
||||
|
||||
|
||||
def _coerce_scalar(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _write(
|
||||
*,
|
||||
event_key: str,
|
||||
base_actor: dict,
|
||||
request_fields: dict,
|
||||
target_type: Optional[str],
|
||||
target_uid: Optional[str],
|
||||
target_label: Optional[str],
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
summary: Optional[str],
|
||||
metadata: Optional[dict],
|
||||
result: str,
|
||||
origin: Optional[str],
|
||||
via_agent: Optional[int],
|
||||
links: Optional[list[dict]],
|
||||
category: Optional[str],
|
||||
) -> Optional[str]:
|
||||
resolved_origin = origin
|
||||
if resolved_origin is None:
|
||||
resolved_origin = "devii" if request_fields.get("devii") else "web"
|
||||
resolved_via = via_agent
|
||||
if resolved_via is None:
|
||||
resolved_via = 1 if request_fields.get("devii") else 0
|
||||
row = {
|
||||
"event_key": event_key,
|
||||
"category": category or category_for(event_key),
|
||||
"actor_kind": base_actor.get("actor_kind"),
|
||||
"actor_uid": base_actor.get("actor_uid"),
|
||||
"actor_username": base_actor.get("actor_username"),
|
||||
"actor_role": base_actor.get("actor_role"),
|
||||
"origin": resolved_origin,
|
||||
"via_agent": int(resolved_via),
|
||||
"request_method": request_fields.get("request_method"),
|
||||
"request_path": request_fields.get("request_path"),
|
||||
"actor_ip": request_fields.get("actor_ip"),
|
||||
"user_agent": request_fields.get("user_agent"),
|
||||
"target_type": target_type,
|
||||
"target_uid": target_uid,
|
||||
"target_label": _sanitize_summary(target_label, 200),
|
||||
"old_value": _coerce_scalar(old_value),
|
||||
"new_value": _coerce_scalar(new_value),
|
||||
"summary": _sanitize_summary(summary),
|
||||
"metadata": json.dumps(metadata) if metadata else None,
|
||||
"result": result,
|
||||
}
|
||||
all_links = list(links or [])
|
||||
if base_actor.get("actor_uid") and not any(
|
||||
item.get("relation") == "actor" for item in all_links
|
||||
):
|
||||
all_links.append(
|
||||
actor(base_actor["actor_uid"], base_actor.get("actor_username"))
|
||||
)
|
||||
audit_uid = store.insert_event(row)
|
||||
store.insert_links(audit_uid, all_links)
|
||||
logger.debug(
|
||||
"audit %s by %s/%s result=%s origin=%s",
|
||||
event_key,
|
||||
row["actor_kind"],
|
||||
row["actor_username"],
|
||||
result,
|
||||
resolved_origin,
|
||||
)
|
||||
return audit_uid
|
||||
|
||||
|
||||
def record(
|
||||
request,
|
||||
event_key: str,
|
||||
*,
|
||||
user=_UNSET,
|
||||
actor_kind: Optional[str] = None,
|
||||
target_type: Optional[str] = None,
|
||||
target_uid: Optional[str] = None,
|
||||
target_label: Optional[str] = None,
|
||||
old_value: Any = None,
|
||||
new_value: Any = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
result: str = "success",
|
||||
origin: Optional[str] = None,
|
||||
via_agent: Optional[int] = None,
|
||||
links: Optional[list[dict]] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
if user is _UNSET:
|
||||
from devplacepy.utils import get_current_user
|
||||
|
||||
try:
|
||||
user = get_current_user(request)
|
||||
except Exception:
|
||||
user = None
|
||||
base_actor = _actor_from(user)
|
||||
if actor_kind:
|
||||
base_actor["actor_kind"] = actor_kind
|
||||
return _write(
|
||||
event_key=event_key,
|
||||
base_actor=base_actor,
|
||||
request_fields=_request_fields(request),
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
target_label=target_label,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
result=result,
|
||||
origin=origin,
|
||||
via_agent=via_agent,
|
||||
links=links,
|
||||
category=category,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("audit.record failed for %s: %s", event_key, exc)
|
||||
return None
|
||||
|
||||
|
||||
def record_system(
|
||||
event_key: str,
|
||||
*,
|
||||
actor_kind: str = "system",
|
||||
actor_uid: Optional[str] = None,
|
||||
actor_username: Optional[str] = None,
|
||||
actor_role: str = "system",
|
||||
target_type: Optional[str] = None,
|
||||
target_uid: Optional[str] = None,
|
||||
target_label: Optional[str] = None,
|
||||
old_value: Any = None,
|
||||
new_value: Any = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
result: str = "success",
|
||||
origin: str = "service",
|
||||
via_agent: int = 0,
|
||||
links: Optional[list[dict]] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
base_actor = {
|
||||
"actor_kind": actor_kind,
|
||||
"actor_uid": actor_uid,
|
||||
"actor_username": actor_username,
|
||||
"actor_role": actor_role,
|
||||
}
|
||||
return _write(
|
||||
event_key=event_key,
|
||||
base_actor=base_actor,
|
||||
request_fields={},
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
target_label=target_label,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
summary=summary,
|
||||
metadata=metadata,
|
||||
result=result,
|
||||
origin=origin,
|
||||
via_agent=via_agent,
|
||||
links=links,
|
||||
category=category,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("audit.record_system failed for %s: %s", event_key, exc)
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.audit import store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RETENTION_KEY = "audit_log_retention_days"
|
||||
DEFAULT_RETENTION_DAYS = 90
|
||||
|
||||
|
||||
class AuditService(BaseService):
|
||||
title = "Audit retention"
|
||||
description = "Prunes audit_log rows and their links older than the retention window."
|
||||
default_enabled = True
|
||||
min_interval = 3600
|
||||
config_fields = [
|
||||
ConfigField(
|
||||
RETENTION_KEY,
|
||||
"Retention (days)",
|
||||
type="int",
|
||||
default=DEFAULT_RETENTION_DAYS,
|
||||
minimum=0,
|
||||
help="Audit rows older than this are pruned. 0 disables pruning.",
|
||||
group="General",
|
||||
)
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("audit", interval_seconds=86400)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
days = get_int_setting(RETENTION_KEY, DEFAULT_RETENTION_DAYS)
|
||||
if days <= 0:
|
||||
self.log("Retention disabled (0 days); nothing pruned")
|
||||
return
|
||||
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||
removed_links, removed_events = store.sweep(cutoff)
|
||||
self.log(
|
||||
f"Pruned {removed_events} audit rows and {removed_links} links older than {days}d"
|
||||
)
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
from devplacepy.database import db
|
||||
|
||||
if store.AUDIT_TABLE not in db.tables:
|
||||
return {"total_events": 0}
|
||||
rows = list(db.query(f"SELECT COUNT(*) AS c FROM {store.AUDIT_TABLE}"))
|
||||
return {"total_events": int(rows[0]["c"]) if rows else 0}
|
||||
@@ -0,0 +1,186 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIT_TABLE = "audit_log"
|
||||
LINKS_TABLE = "audit_log_links"
|
||||
|
||||
EVENT_COLUMNS = (
|
||||
"uid",
|
||||
"created_at",
|
||||
"event_key",
|
||||
"category",
|
||||
"actor_kind",
|
||||
"actor_uid",
|
||||
"actor_username",
|
||||
"actor_role",
|
||||
"origin",
|
||||
"via_agent",
|
||||
"request_method",
|
||||
"request_path",
|
||||
"actor_ip",
|
||||
"user_agent",
|
||||
"target_type",
|
||||
"target_uid",
|
||||
"target_label",
|
||||
"old_value",
|
||||
"new_value",
|
||||
"summary",
|
||||
"metadata",
|
||||
"result",
|
||||
)
|
||||
|
||||
LINK_COLUMNS = (
|
||||
"uid",
|
||||
"audit_uid",
|
||||
"relation",
|
||||
"object_type",
|
||||
"object_uid",
|
||||
"object_label",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _empty_event() -> dict:
|
||||
row: dict = {column: None for column in EVENT_COLUMNS}
|
||||
row["via_agent"] = 0
|
||||
return row
|
||||
|
||||
|
||||
def _empty_link() -> dict:
|
||||
return {column: None for column in LINK_COLUMNS}
|
||||
|
||||
|
||||
def ensure_tables() -> None:
|
||||
try:
|
||||
if AUDIT_TABLE not in db.tables:
|
||||
sentinel = _empty_event()
|
||||
sentinel["uid"] = "__audit_sentinel__"
|
||||
sentinel["created_at"] = now()
|
||||
sentinel["event_key"] = "__sentinel__"
|
||||
sentinel["category"] = "system"
|
||||
sentinel["actor_kind"] = "system"
|
||||
sentinel["actor_role"] = "system"
|
||||
sentinel["origin"] = "system"
|
||||
sentinel["result"] = "success"
|
||||
table = get_table(AUDIT_TABLE)
|
||||
table.insert(sentinel)
|
||||
table.delete(uid="__audit_sentinel__")
|
||||
if LINKS_TABLE not in db.tables:
|
||||
sentinel = _empty_link()
|
||||
sentinel["uid"] = "__audit_link_sentinel__"
|
||||
sentinel["audit_uid"] = "__audit_sentinel__"
|
||||
sentinel["relation"] = "actor"
|
||||
sentinel["object_type"] = "user"
|
||||
sentinel["object_uid"] = "__sentinel__"
|
||||
sentinel["created_at"] = now()
|
||||
table = get_table(LINKS_TABLE)
|
||||
table.insert(sentinel)
|
||||
table.delete(uid="__audit_link_sentinel__")
|
||||
except Exception as exc:
|
||||
logger.warning("audit ensure_tables failed: %s", exc)
|
||||
|
||||
|
||||
def insert_event(row: dict) -> str:
|
||||
record = _empty_event()
|
||||
record.update({key: value for key, value in row.items() if key in EVENT_COLUMNS})
|
||||
if not record.get("uid"):
|
||||
record["uid"] = generate_uid()
|
||||
if not record.get("created_at"):
|
||||
record["created_at"] = now()
|
||||
if record.get("via_agent") is None:
|
||||
record["via_agent"] = 0
|
||||
get_table(AUDIT_TABLE).insert(record)
|
||||
return record["uid"]
|
||||
|
||||
|
||||
def insert_links(audit_uid: str, links: list[dict]) -> int:
|
||||
if not links:
|
||||
return 0
|
||||
table = get_table(LINKS_TABLE)
|
||||
stamp = now()
|
||||
count = 0
|
||||
for link in links:
|
||||
record = _empty_link()
|
||||
record.update(
|
||||
{key: value for key, value in link.items() if key in LINK_COLUMNS}
|
||||
)
|
||||
record["uid"] = generate_uid()
|
||||
record["audit_uid"] = audit_uid
|
||||
record["created_at"] = stamp
|
||||
if not record.get("object_uid"):
|
||||
continue
|
||||
table.insert(record)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def get_event(uid: str) -> Optional[dict]:
|
||||
if AUDIT_TABLE not in db.tables:
|
||||
return None
|
||||
return get_table(AUDIT_TABLE).find_one(uid=uid)
|
||||
|
||||
|
||||
def get_links(audit_uid: str) -> list[dict]:
|
||||
if LINKS_TABLE not in db.tables:
|
||||
return []
|
||||
return list(
|
||||
get_table(LINKS_TABLE).find(audit_uid=audit_uid, order_by=["created_at"])
|
||||
)
|
||||
|
||||
|
||||
def sweep(cutoff: str, batch_size: int = 5000, max_batches: int = 100) -> tuple[int, int]:
|
||||
if AUDIT_TABLE not in db.tables:
|
||||
return (0, 0)
|
||||
removed_links = 0
|
||||
removed_events = 0
|
||||
for _ in range(max_batches):
|
||||
rows = list(
|
||||
db.query(
|
||||
f"SELECT uid FROM {AUDIT_TABLE} WHERE created_at < :cutoff "
|
||||
f"ORDER BY created_at LIMIT :limit",
|
||||
cutoff=cutoff,
|
||||
limit=batch_size,
|
||||
)
|
||||
)
|
||||
uids = [row["uid"] for row in rows]
|
||||
if not uids:
|
||||
break
|
||||
placeholders, params = _in_clause(uids)
|
||||
if LINKS_TABLE in db.tables:
|
||||
link_rows = list(
|
||||
db.query(
|
||||
f"SELECT COUNT(*) AS c FROM {LINKS_TABLE} "
|
||||
f"WHERE audit_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
)
|
||||
removed_links += int(link_rows[0]["c"]) if link_rows else 0
|
||||
db.query(
|
||||
f"DELETE FROM {LINKS_TABLE} WHERE audit_uid IN ({placeholders})",
|
||||
**params,
|
||||
)
|
||||
db.query(
|
||||
f"DELETE FROM {AUDIT_TABLE} WHERE uid IN ({placeholders})", **params
|
||||
)
|
||||
removed_events += len(uids)
|
||||
if len(uids) < batch_size:
|
||||
break
|
||||
return (removed_links, removed_events)
|
||||
|
||||
|
||||
def _in_clause(uids: list[str], prefix: str = "u") -> tuple[str, dict]:
|
||||
placeholders = ", ".join(f":{prefix}{index}" for index in range(len(uids)))
|
||||
params = {f"{prefix}{index}": uid for index, uid in enumerate(uids)}
|
||||
return placeholders, params
|
||||
@@ -7,10 +7,25 @@ from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.services.containers.runtime import get_backend
|
||||
from devplacepy.services.devii.tasks.schedule import next_run, now_utc, to_iso
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
AUTO_RESTART = ("always", "on-failure", "unless-stopped")
|
||||
|
||||
|
||||
def _audit_reconcile(inst, action, summary, metadata=None):
|
||||
audit.record_system(
|
||||
"container.reconcile.action",
|
||||
actor_kind="service",
|
||||
origin="scheduler",
|
||||
target_type="instance",
|
||||
target_uid=inst.get("uid"),
|
||||
target_label=inst.get("name"),
|
||||
metadata={**(metadata or {}), "reconcile_action": action},
|
||||
summary=summary,
|
||||
links=[audit.instance(inst.get("uid"), inst.get("name"))],
|
||||
)
|
||||
|
||||
|
||||
class ContainerService(BaseService):
|
||||
default_enabled = False
|
||||
min_interval = 1
|
||||
@@ -59,6 +74,11 @@ class ContainerService(BaseService):
|
||||
try:
|
||||
await backend.rm(row.container_id, force=True)
|
||||
self.log(f"reaped orphan container {row.name}")
|
||||
_audit_reconcile(
|
||||
{"uid": uid, "name": row.name},
|
||||
"orphan_reap",
|
||||
f"reconciler reaped orphan container {row.name}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log(f"orphan rm failed for {row.name}: {exc}")
|
||||
|
||||
@@ -152,6 +172,7 @@ class ContainerService(BaseService):
|
||||
store.update_instance(inst["uid"], changes)
|
||||
store.record_event(inst, "start", "reconciler", "")
|
||||
self.log(f"launched instance {inst['name']}")
|
||||
_audit_reconcile(inst, "start", f"reconciler started instance {inst['name']}")
|
||||
|
||||
async def _capture_net(self, backend, cid: str) -> tuple:
|
||||
try:
|
||||
@@ -197,6 +218,12 @@ class ContainerService(BaseService):
|
||||
"",
|
||||
{"exit_code": exit_code, "logs": logs},
|
||||
)
|
||||
_audit_reconcile(
|
||||
inst,
|
||||
"policy_restart",
|
||||
f"reconciler restarted instance {inst['name']} per {policy} policy",
|
||||
{"exit_code": exit_code},
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
self.log(f"policy restart {inst['name']} failed: {exc}")
|
||||
@@ -229,6 +256,12 @@ class ContainerService(BaseService):
|
||||
)
|
||||
store.update_instance(inst["uid"], {"desired_state": desired})
|
||||
store.record_event(inst, f"schedule_{action}", "scheduler", "")
|
||||
_audit_reconcile(
|
||||
inst,
|
||||
f"schedule_{action}",
|
||||
f"reconciler fired scheduled {action} on instance {inst['name']}",
|
||||
{"schedule_uid": sched["uid"]},
|
||||
)
|
||||
cols = json.loads(sched.get("schedule_json") or "{}")
|
||||
run_count = int(sched.get("run_count") or 0) + 1
|
||||
upcoming = next_run(
|
||||
|
||||
@@ -67,6 +67,36 @@ def _is_destructive_command(arguments: dict[str, Any]) -> bool:
|
||||
return bool(DESTRUCTIVE_COMMAND.search(str(arguments.get("command", ""))))
|
||||
|
||||
|
||||
_DEVII_MECHANIC_EVENTS = {
|
||||
"update_behavior": "devii.behavior.update",
|
||||
"tool_create": "devii.tool.create",
|
||||
"tool_update": "devii.tool.update",
|
||||
"tool_delete": "devii.tool.delete",
|
||||
"create_task": "devii.task.create",
|
||||
"update_task": "devii.task.update",
|
||||
"delete_task": "devii.task.delete",
|
||||
"reflect": "devii.lesson.reflect",
|
||||
"forget_lessons": "devii.lesson.forget",
|
||||
"customize_set_css": "devii.customization.css.set",
|
||||
"customize_set_js": "devii.customization.js.set",
|
||||
"customize_reset": "devii.customization.reset",
|
||||
}
|
||||
|
||||
_DEVII_CONTAINER_EVENTS = {
|
||||
"container_create_instance": "container.instance.create",
|
||||
"container_exec": "container.instance.exec",
|
||||
"container_schedule": "container.schedule.create",
|
||||
}
|
||||
|
||||
|
||||
def _safe_args(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in arguments.items():
|
||||
text = str(value)
|
||||
out[key] = text if len(text) <= 200 else text[:200] + "..."
|
||||
return out
|
||||
|
||||
|
||||
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
||||
if _is_confirmed(arguments):
|
||||
return None
|
||||
@@ -148,6 +178,8 @@ class Dispatcher:
|
||||
self._avatar = avatar
|
||||
self._browser = browser
|
||||
self._is_admin = is_admin
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._fetch = FetchController(settings)
|
||||
self._docs = DocsController(settings)
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
@@ -219,6 +251,7 @@ class Dispatcher:
|
||||
logger.info("Resource cache hit for %s (%s)", name, resource_key)
|
||||
return cached
|
||||
result = await self._run(action, arguments)
|
||||
self._audit_mechanic(action, arguments)
|
||||
if action.handler == "chunks":
|
||||
return result
|
||||
return wrap_if_large(
|
||||
@@ -231,6 +264,30 @@ class Dispatcher:
|
||||
logger.exception("Dispatch %s crashed", name)
|
||||
return unexpected_result(exc)
|
||||
|
||||
def _audit_mechanic(self, action: Action, arguments: dict[str, Any]) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
name = action.name
|
||||
event_key = _DEVII_MECHANIC_EVENTS.get(name)
|
||||
if event_key is None and action.handler == "container":
|
||||
if name == "container_instance_action":
|
||||
event_key = f"container.instance.{arguments.get('action', 'action')}"
|
||||
else:
|
||||
event_key = _DEVII_CONTAINER_EVENTS.get(name)
|
||||
if event_key is None:
|
||||
return
|
||||
actor_kind = "user" if self._owner_kind == "user" else self._owner_kind
|
||||
audit.record_system(
|
||||
event_key,
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=self._owner_id if self._owner_kind == "user" else None,
|
||||
actor_role="admin" if self._is_admin else (actor_kind if actor_kind != "user" else "member"),
|
||||
origin="devii",
|
||||
via_agent=1,
|
||||
summary=f"Devii {name} for {self._owner_id or self._owner_kind}",
|
||||
metadata={"tool": name, "args": _safe_args(arguments)},
|
||||
)
|
||||
|
||||
async def _run(self, action: Action, arguments: dict[str, Any]) -> str:
|
||||
if action.handler == "status":
|
||||
return json.dumps(
|
||||
|
||||
@@ -20,7 +20,11 @@ class PlatformClient:
|
||||
def __init__(
|
||||
self, base_url: str, timeout_seconds: float, api_key: str = ""
|
||||
) -> None:
|
||||
headers = {"User-Agent": "devii/0.1", "Accept": "application/json"}
|
||||
headers = {
|
||||
"User-Agent": "devii/0.1",
|
||||
"Accept": "application/json",
|
||||
"X-Devii-Agent": "1",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-API-Key"] = api_key
|
||||
|
||||
@@ -465,6 +465,26 @@ class DeviiSession:
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
"devii.turn",
|
||||
actor_kind="user" if self.owner_kind == "user" else self.owner_kind,
|
||||
actor_uid=self.owner_id if self.owner_kind == "user" else None,
|
||||
actor_username=self.username,
|
||||
actor_role="user" if self.owner_kind == "user" else self.owner_kind,
|
||||
origin="devii",
|
||||
via_agent=1,
|
||||
result="failure" if error else "success",
|
||||
summary=f"Devii processed a turn for {self.username or self.owner_id}",
|
||||
metadata={
|
||||
"iterations": after["requests"] - before["requests"],
|
||||
"tool_calls": self._turn_tool_calls,
|
||||
"cost_usd": cost_delta,
|
||||
"prompt_tokens": usage["prompt_tokens"],
|
||||
"completion_tokens": usage["completion_tokens"],
|
||||
},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - persistence must never break a turn
|
||||
logger.exception(
|
||||
"Failed to persist turn for %s/%s", self.owner_kind, self.owner_id
|
||||
|
||||
@@ -12,6 +12,27 @@ from .store import TaskStore
|
||||
|
||||
logger = logging.getLogger("devii.tasks.scheduler")
|
||||
|
||||
|
||||
def _audit_task_execute(row: dict[str, Any], result: str, error: str | None) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind = row.get("owner_kind") or "user"
|
||||
owner_id = row.get("owner_id") or ""
|
||||
audit.record_system(
|
||||
"devii.task.execute",
|
||||
actor_kind="user" if owner_kind == "user" else owner_kind,
|
||||
actor_uid=owner_id if owner_kind == "user" else None,
|
||||
actor_role="user" if owner_kind == "user" else owner_kind,
|
||||
origin="scheduler",
|
||||
via_agent=1,
|
||||
result="failure" if error else "success",
|
||||
target_type="task",
|
||||
target_uid=row.get("uid"),
|
||||
summary=f"Devii task {row.get('uid')} for {owner_id or owner_kind} executed",
|
||||
metadata={"run_count": row.get("run_count"), "error": error, "result": (result or "")[:140]},
|
||||
links=[audit.task(row.get("uid"))],
|
||||
)
|
||||
|
||||
PromptExecutor = Callable[[str], Awaitable[str]]
|
||||
EventCallback = Callable[[str, dict[str, Any], str], None]
|
||||
|
||||
@@ -84,7 +105,9 @@ class Scheduler:
|
||||
},
|
||||
)
|
||||
self._on_event("error", row, str(exc))
|
||||
_audit_task_execute(row, "", str(exc))
|
||||
return
|
||||
|
||||
self._store.update(uid, changes)
|
||||
self._on_event("done" if not finished else "finished", row, result)
|
||||
_audit_task_execute(row, result, None)
|
||||
|
||||
@@ -66,6 +66,8 @@ class ForkService(JobService):
|
||||
None,
|
||||
)
|
||||
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
try:
|
||||
item_count = await asyncio.to_thread(
|
||||
self._copy_files, source_uid, new_uid, user, job["uid"]
|
||||
@@ -73,8 +75,36 @@ class ForkService(JobService):
|
||||
record_fork(source_uid, new_uid, forked_by_uid)
|
||||
except Exception:
|
||||
self._rollback(new_uid)
|
||||
audit.record_system(
|
||||
"project.fork.failed",
|
||||
actor_kind="user",
|
||||
actor_uid=forked_by_uid,
|
||||
actor_username=user.get("username"),
|
||||
result="failure",
|
||||
target_type="project",
|
||||
target_uid=source_uid,
|
||||
target_label=source.get("title"),
|
||||
summary=f"fork of project {source.get('title')} by {user.get('username')} failed",
|
||||
links=[audit.source("project", source_uid, source.get("title")), audit.job(job["uid"])],
|
||||
)
|
||||
raise
|
||||
|
||||
audit.record_system(
|
||||
"project.fork.complete",
|
||||
actor_kind="user",
|
||||
actor_uid=forked_by_uid,
|
||||
actor_username=user.get("username"),
|
||||
target_type="project",
|
||||
target_uid=new_uid,
|
||||
target_label=title,
|
||||
metadata={"item_count": item_count},
|
||||
summary=f"fork of project {source.get('title')} by {user.get('username')} completed as {title}",
|
||||
links=[
|
||||
audit.source("project", source_uid, source.get("title")),
|
||||
audit.destination("project", new_uid, title),
|
||||
audit.job(job["uid"]),
|
||||
],
|
||||
)
|
||||
return {
|
||||
"project_uid": new_uid,
|
||||
"project_url": f"/projects/{new_slug}",
|
||||
|
||||
@@ -52,6 +52,23 @@ class ZipService(JobService):
|
||||
finally:
|
||||
await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)
|
||||
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
project_uid = source.get("project_uid", "")
|
||||
audit.record_system(
|
||||
"job.zip.complete",
|
||||
actor_kind="user" if job.get("owner_kind") == "user" else (job.get("owner_kind") or "system"),
|
||||
actor_uid=job.get("owner_id") if job.get("owner_kind") == "user" else None,
|
||||
target_type="project",
|
||||
target_uid=project_uid,
|
||||
metadata={
|
||||
"bytes_out": stats["bytes_out"],
|
||||
"crc32": f"{stats['crc32']:08x}",
|
||||
"item_count": item_count,
|
||||
},
|
||||
summary=f"zip job for project {project_uid} completed",
|
||||
links=[audit.project(project_uid), audit.job(uid)],
|
||||
)
|
||||
return {
|
||||
"download_url": f"/zips/{uid}/download",
|
||||
"local_path": str(final_path),
|
||||
|
||||
@@ -9,6 +9,7 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
from devplacepy.database import get_table, get_setting, internal_gateway_key
|
||||
from devplacepy.services.base import BaseService, ConfigField
|
||||
from devplacepy.utils import generate_uid, make_combined_slug, strip_html
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -239,6 +240,27 @@ class NewsService(BaseService):
|
||||
}
|
||||
)
|
||||
new_count += 1
|
||||
title = article.get("title", "")
|
||||
audit.record_system(
|
||||
"news.service.ingest",
|
||||
actor_kind="service",
|
||||
target_type="news",
|
||||
target_uid=article_uid,
|
||||
target_label=title,
|
||||
metadata={"source": article.get("feed_name", ""), "grade": grade_val},
|
||||
summary=f"news article {title} ingested",
|
||||
links=[audit.target("news", article_uid, title)],
|
||||
)
|
||||
audit.record_system(
|
||||
"news.service.publish" if auto_published else "news.service.draft",
|
||||
actor_kind="service",
|
||||
target_type="news",
|
||||
target_uid=article_uid,
|
||||
target_label=title,
|
||||
metadata={"grade": grade_val, "threshold": threshold},
|
||||
summary=f"news article {title} {'auto-published' if auto_published else 'held as draft'}",
|
||||
links=[audit.target("news", article_uid, title)],
|
||||
)
|
||||
|
||||
existing_sync = sync_table.find_one(external_id=external_id)
|
||||
if existing_sync:
|
||||
|
||||
@@ -252,9 +252,38 @@ class GatewayUsageLedger:
|
||||
"user_agent": (raw.get("user_agent") or "")[:300],
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit(raw, norm, cost_usd)
|
||||
except Exception as exc:
|
||||
logger.warning("gateway usage record failed: %s", exc)
|
||||
|
||||
def _audit(self, raw: dict, norm: dict, cost_usd: float) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind = raw.get("owner_kind") or "unknown"
|
||||
owner_id = raw.get("owner_id") or "unknown"
|
||||
actor_kind = "guest" if owner_kind == "guest" else ("user" if owner_kind in ("user", "admin") else "system")
|
||||
audit.record_system(
|
||||
"ai.gateway.call",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=owner_id if actor_kind == "user" else None,
|
||||
actor_role="admin" if owner_kind == "admin" else (actor_kind if actor_kind != "user" else "member"),
|
||||
origin="api",
|
||||
result="success" if raw.get("success") else "failure",
|
||||
summary=f"LLM call by {owner_kind}/{owner_id} (model {raw.get('model') or ''})",
|
||||
metadata={
|
||||
"model": raw.get("model"),
|
||||
"backend": raw.get("backend"),
|
||||
"prompt_tokens": norm["prompt"],
|
||||
"completion_tokens": norm["completion"],
|
||||
"total_tokens": norm["total"],
|
||||
"cost_usd": round(cost_usd, 8),
|
||||
"status_code": raw.get("status_code"),
|
||||
"error_category": raw.get("error_category"),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
},
|
||||
)
|
||||
|
||||
def sample_concurrency(self, in_flight: int) -> None:
|
||||
try:
|
||||
get_table(GATEWAY_CONCURRENCY).insert(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.audit-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.audit-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.audit-filter-field label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audit-filter-field select,
|
||||
.audit-filter-field input {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-filter-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.audit-badge-origin {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.audit-badge-devii {
|
||||
margin-left: 0.25rem;
|
||||
background: rgba(255, 107, 53, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.audit-result {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.audit-result-success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.audit-result-failure {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.audit-result-denied {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: var(--warning, #eab308);
|
||||
}
|
||||
|
||||
.audit-actor-ip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.audit-detail-grid dt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.audit-detail-grid dd {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.audit-meta pre {
|
||||
background: var(--bg-card-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.audit-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.audit-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.audit-value-change {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.audit-value-old {
|
||||
color: var(--danger);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.audit-value-new {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.audit-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.125rem 0;
|
||||
}
|
||||
|
||||
.audit-detail-grid dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
{% set pagination_prefix = pagination_query | default("") %}
|
||||
{% if pagination and pagination.total > 0 and pagination.total_pages > 1 %}
|
||||
{% set _base = pagination_base | default('?page=') %}
|
||||
<div class="pagination">
|
||||
<span class="pagination-info">{{ pagination.total }} items · Page {{ pagination.page }} of {{ pagination.total_pages }}</span>
|
||||
<div class="pagination-controls">
|
||||
{% if pagination.has_prev %}
|
||||
<<<<<<< HEAD
|
||||
<a href="{{ _base }}{{ pagination.prev_page }}" class="pagination-btn"><span class="icon">←</span>Previous</a>
|
||||
=======
|
||||
<a href="?{{ pagination_prefix }}page={{ pagination.prev_page }}" class="pagination-btn"><span class="icon">←</span>Previous</a>
|
||||
>>>>>>> 973021c (Update)
|
||||
{% else %}
|
||||
<span class="pagination-btn pagination-btn-disabled"><span class="icon">←</span>Previous</span>
|
||||
{% endif %}
|
||||
@@ -12,7 +17,11 @@
|
||||
<div class="pagination-pages">
|
||||
{% for p in range(1, pagination.total_pages + 1) %}
|
||||
{% if p == 1 or p == pagination.total_pages or (p >= pagination.page - 2 and p <= pagination.page + 2) %}
|
||||
<<<<<<< HEAD
|
||||
<a href="{{ _base }}{{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}">{{ p }}</a>
|
||||
=======
|
||||
<a href="?{{ pagination_prefix }}page={{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}">{{ p }}</a>
|
||||
>>>>>>> 973021c (Update)
|
||||
{% elif p == pagination.page - 3 or p == pagination.page + 3 %}
|
||||
<span class="pagination-ellipsis">…</span>
|
||||
{% endif %}
|
||||
@@ -20,7 +29,11 @@
|
||||
</div>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<<<<<<< HEAD
|
||||
<a href="{{ _base }}{{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">→</span></a>
|
||||
=======
|
||||
<a href="?{{ pagination_prefix }}page={{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">→</span></a>
|
||||
>>>>>>> 973021c (Update)
|
||||
{% else %}
|
||||
<span class="pagination-btn pagination-btn-disabled">Next <span class="icon">→</span></span>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/audit.css">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<a href="/admin/audit-log" class="audit-back-link"><span class="icon">←</span> Back to audit log</a>
|
||||
|
||||
<div class="admin-toolbar">
|
||||
<h2><span class="audit-badge">{{ event['event_key'] }}</span></h2>
|
||||
<span class="audit-result audit-result-{{ event.get('result', 'success') }}">{{ event.get('result', 'success') }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="audit-detail-grid">
|
||||
<dt>Time</dt>
|
||||
<dd>{{ format_date(event['created_at'], include_time=True) }} ({{ event.get('time_ago', '') }})</dd>
|
||||
|
||||
<dt>Category</dt>
|
||||
<dd>{{ event.get('category', '') }}</dd>
|
||||
|
||||
<dt>Actor</dt>
|
||||
<dd>
|
||||
{% if event.get('actor_user') %}
|
||||
{% set _user = event['actor_user'] %}{% set _size = 24 %}{% set _size_class = "sm" %}
|
||||
<span class="admin-user-cell">{% include "_avatar_link.html" %}{% include "_user_link.html" %}</span>
|
||||
{% else %}
|
||||
{{ event.get('actor_username') or event.get('actor_kind', 'system') }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt>Role</dt>
|
||||
<dd>{{ event.get('actor_role', '') }}</dd>
|
||||
|
||||
<dt>Origin</dt>
|
||||
<dd>{{ event.get('origin', '') }}{% if event.get('via_agent') %} <span class="audit-badge audit-badge-devii">via Devii agent</span>{% endif %}</dd>
|
||||
|
||||
{% if event.get('request_method') or event.get('request_path') %}
|
||||
<dt>Request</dt>
|
||||
<dd>{{ event.get('request_method', '') }} {{ event.get('request_path', '') }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.get('actor_ip') %}
|
||||
<dt>IP</dt>
|
||||
<dd><span class="audit-actor-ip">{{ event['actor_ip'] }}</span></dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.get('user_agent') %}
|
||||
<dt>User agent</dt>
|
||||
<dd>{{ event['user_agent'] }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.get('target_type') or event.get('target_uid') %}
|
||||
<dt>Target</dt>
|
||||
<dd>{{ event.get('target_label') or '' }} <span class="admin-text-muted">{{ event.get('target_type', '') }} {{ event.get('target_uid', '') }}</span></dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.get('old_value') is not none or event.get('new_value') is not none %}
|
||||
<dt>Change</dt>
|
||||
<dd class="audit-value-change">
|
||||
{% if event.get('old_value') is not none %}<span class="audit-value-old">{{ event['old_value'] }}</span> → {% endif %}
|
||||
{% if event.get('new_value') is not none %}<span class="audit-value-new">{{ event['new_value'] }}</span>{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if event.get('summary') %}
|
||||
<dt>Summary</dt>
|
||||
<dd>{{ event['summary'] }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if event.get('metadata_pretty') %}
|
||||
<h3 class="audit-section-title">Metadata</h3>
|
||||
<div class="audit-meta">
|
||||
<pre>{{ event['metadata_pretty'] }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="audit-section-title">Related objects</h3>
|
||||
{% if links %}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Relation</th>
|
||||
<th>Type</th>
|
||||
<th>Label</th>
|
||||
<th>UID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in links %}
|
||||
<tr>
|
||||
<td><span class="audit-badge">{{ l['relation'] }}</span></td>
|
||||
<td>{{ l['object_type'] }}</td>
|
||||
<td>{{ l.get('object_label') or '' }}</td>
|
||||
<td class="admin-text-muted">{{ l['object_uid'] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="admin-text-muted">No related objects linked.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/audit.css">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>Audit Log</h2>
|
||||
<span class="admin-count">{{ pagination.total }} events</span>
|
||||
</div>
|
||||
|
||||
<form method="get" action="/admin/audit-log" class="audit-filter-bar">
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-q">Search</label>
|
||||
<input type="text" id="audit-q" name="q" value="{{ filters.get('q', '') }}" placeholder="summary, key, target">
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-category">Category</label>
|
||||
<select id="audit-category" name="category">
|
||||
<option value="">All</option>
|
||||
{% for value in options.get('category', []) %}
|
||||
<option value="{{ value }}" {% if filters.get('category') == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-event">Event</label>
|
||||
<select id="audit-event" name="event_key">
|
||||
<option value="">All</option>
|
||||
{% for value in options.get('event_key', []) %}
|
||||
<option value="{{ value }}" {% if filters.get('event_key') == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-role">Role</label>
|
||||
<select id="audit-role" name="actor_role">
|
||||
<option value="">All</option>
|
||||
{% for value in options.get('actor_role', []) %}
|
||||
<option value="{{ value }}" {% if filters.get('actor_role') == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-origin">Origin</label>
|
||||
<select id="audit-origin" name="origin">
|
||||
<option value="">All</option>
|
||||
{% for value in options.get('origin', []) %}
|
||||
<option value="{{ value }}" {% if filters.get('origin') == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-result">Result</label>
|
||||
<select id="audit-result" name="result">
|
||||
<option value="">All</option>
|
||||
{% for value in options.get('result', []) %}
|
||||
<option value="{{ value }}" {% if filters.get('result') == value %}selected{% endif %}>{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-from">From</label>
|
||||
<input type="date" id="audit-from" name="date_from" value="{{ filters.get('date_from', '') }}">
|
||||
</div>
|
||||
<div class="audit-filter-field">
|
||||
<label for="audit-to">To</label>
|
||||
<input type="date" id="audit-to" name="date_to" value="{{ filters.get('date_to', '') }}">
|
||||
</div>
|
||||
<div class="audit-filter-actions">
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">🔍</span> Filter</button>
|
||||
<a href="/admin/audit-log" class="admin-btn admin-btn-sm">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Event</th>
|
||||
<th>Actor</th>
|
||||
<th>Role</th>
|
||||
<th>Origin</th>
|
||||
<th>Target</th>
|
||||
<th>Result</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td title="{{ format_date(e['created_at'], include_time=True) }}">{{ e.get('time_ago', '') }}</td>
|
||||
<td><span class="audit-badge">{{ e['event_key'] }}</span></td>
|
||||
<td>
|
||||
{% if e.get('actor_user') %}
|
||||
{% set _user = e['actor_user'] %}{% include "_user_link.html" %}
|
||||
{% elif e.get('actor_ip') %}
|
||||
<span class="audit-actor-ip">{{ e['actor_ip'] }}</span>
|
||||
{% else %}
|
||||
<span class="admin-text-muted">{{ e.get('actor_kind', 'system') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="audit-badge">{{ e.get('actor_role', '') }}</span></td>
|
||||
<td>
|
||||
<span class="audit-badge audit-badge-origin">{{ e.get('origin', '') }}</span>
|
||||
{% if e.get('via_agent') %}<span class="audit-badge audit-badge-devii">devii</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ e.get('target_label') or e.get('target_uid') or '' }}</td>
|
||||
<td><span class="audit-result audit-result-{{ e.get('result', 'success') }}">{{ e.get('result', 'success') }}</span></td>
|
||||
<td><a href="/admin/audit-log/{{ e['uid'] }}" class="admin-btn admin-btn-sm">View</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="admin-text-muted">No audit events match the current filters.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -26,6 +26,9 @@
|
||||
<a href="/admin/ai-usage" class="sidebar-link {% if admin_section == 'ai-usage' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📊</span> AI usage
|
||||
</a>
|
||||
<a href="/admin/audit-log" class="sidebar-link {% if admin_section == 'audit-log' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📋</span> Audit Log
|
||||
</a>
|
||||
<a href="/admin/settings" class="sidebar-link {% if admin_section == 'settings' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🔧</span> Settings
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Audit Log
|
||||
|
||||
The audit log is the authoritative, append-only record of every state-changing action
|
||||
on the platform: who acted, in which role, from where, on which objects, what changed,
|
||||
and when. It is **administrator-only**: the viewer, its routes, and every monetary
|
||||
figure it records are restricted to admins.
|
||||
|
||||
> This page is visible to administrators only.
|
||||
|
||||
## Viewing the log
|
||||
|
||||
- **`/admin/audit-log`** lists events newest-first, 25 per page, with the same
|
||||
pagination and table styling as the rest of the admin panel. The sidebar entry sits
|
||||
just above Settings.
|
||||
- A filter bar narrows the list by free-text search (summary, event key, target),
|
||||
category, event key, actor role, origin, result, and a date range. Filters are
|
||||
carried through pagination links.
|
||||
- **`/admin/audit-log/{uid}`** is the per-event detail page: the full row, the
|
||||
old to new value transition, pretty-printed JSON metadata, and a table of every
|
||||
related object (actor, target, parent, author, recipient, source, destination,
|
||||
schedule, instance, setting, job, and more).
|
||||
|
||||
Both routes negotiate content: send `Accept: application/json` to receive the
|
||||
structured `AuditLogOut` / `AuditEventOut` payload instead of HTML.
|
||||
|
||||
## What is recorded
|
||||
|
||||
Every event carries WHO (`actor_kind`, `actor_uid`, `actor_username`, `actor_role`),
|
||||
FROM WHERE (`origin`, `via_agent`, `request_method`, `request_path`, `actor_ip`,
|
||||
`user_agent`), the primary object (`target_type`, `target_uid`, `target_label`), the
|
||||
change (`old_value` to `new_value`), a sanitised `summary`, event-specific `metadata`,
|
||||
and a `result` of `success`, `failure`, or `denied`. A second table,
|
||||
`audit_log_links`, records every related object with a labelled relation so an event
|
||||
can reference an arbitrary number of objects.
|
||||
|
||||
The complete catalogue of event keys (155 across authentication, content, votes,
|
||||
projects, files, news, admin, services, containers, ingress, AI, the Devii agent, the
|
||||
CLI, rewards, and security) is enumerated in `events.md` at the repository root.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Admin-only.** The list and detail routes are gated by `require_admin`; non-admins
|
||||
receive a redirect (or a 403 for JSON). Financial values (cost, pricing, spend) are
|
||||
recorded for completeness but only surfaced on this admin-only page.
|
||||
- **Append-only.** Rows are never updated. The only deletion is the retention sweep.
|
||||
- **Best-effort.** Recording never raises into a request handler. A failure to write
|
||||
an audit row is logged and swallowed, so the audited action is never blocked.
|
||||
- **Failures and denials are events too.** Authentication failures, authorisation
|
||||
denials, read-only write attempts, self-role-change and self-disable attempts, and
|
||||
quota blocks are all recorded with `result` set to `failure` or `denied`.
|
||||
- **Agent attribution.** When the Devii agent performs a platform mutation, the same
|
||||
event key is recorded with `origin=devii` and `via_agent=1` (the agent's HTTP client
|
||||
sends an `X-Devii-Agent` header), so an auditor can answer "what did the agent do on
|
||||
my behalf" without a parallel taxonomy. Mechanics with no HTTP equivalent get their
|
||||
own `devii.*` keys.
|
||||
|
||||
## Retention
|
||||
|
||||
Rows older than `audit_log_retention_days` (default 90) are pruned daily by the
|
||||
**Audit retention** background service, configurable on the Services page. Set the
|
||||
retention to `0` to disable pruning entirely.
|
||||
</div>
|
||||
+78
-2
@@ -190,7 +190,15 @@ def require_user(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
from devplacepy.responses import wants_json
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"security.authz.denied",
|
||||
user=None,
|
||||
result="denied",
|
||||
summary=f"guest denied access to {request.url.path}",
|
||||
)
|
||||
if _request_has_auth(request) or wants_json(request):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
@@ -209,7 +217,15 @@ def require_admin(request: Request):
|
||||
user = require_user(request)
|
||||
if not is_admin(user):
|
||||
from devplacepy.responses import wants_json
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"security.authz.denied",
|
||||
user=user,
|
||||
result="denied",
|
||||
summary=f"non-admin denied access to {request.url.path}",
|
||||
)
|
||||
if wants_json(request):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -343,20 +359,48 @@ def create_notification(
|
||||
)
|
||||
clear_unread_cache(user_uid)
|
||||
_schedule_push(user_uid, message, target_url)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
"notification.create",
|
||||
actor_kind="system",
|
||||
target_type="notification",
|
||||
target_uid=related_uid,
|
||||
summary=f"notification created: {message}",
|
||||
metadata={"type": notification_type},
|
||||
links=[
|
||||
audit.recipient(user_uid),
|
||||
audit.link("source", "notification", related_uid),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def award_badge(user_uid: str, badge_name: str) -> bool:
|
||||
badges = get_table("badges")
|
||||
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
|
||||
return False
|
||||
badge_uid = generate_uid()
|
||||
badges.insert(
|
||||
{
|
||||
"uid": generate_uid(),
|
||||
"uid": badge_uid,
|
||||
"user_uid": user_uid,
|
||||
"badge_name": badge_name,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
"reward.badge.award",
|
||||
actor_kind="user",
|
||||
actor_uid=user_uid,
|
||||
summary=f"user earned the {badge_name} badge",
|
||||
metadata={"badge": badge_name},
|
||||
links=[
|
||||
audit.actor(user_uid),
|
||||
audit.link("target", "badge", badge_uid, badge_name),
|
||||
],
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -424,7 +468,28 @@ def award_xp(user_uid: str, amount: int) -> dict:
|
||||
users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
|
||||
clear_user_cache(user_uid)
|
||||
leveled_up = new_level > current_level
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
"reward.xp.grant",
|
||||
actor_kind="user",
|
||||
actor_uid=user_uid,
|
||||
new_value=amount,
|
||||
metadata={"amount": amount, "total_xp": new_xp},
|
||||
summary=f"user earned {amount} XP",
|
||||
links=[audit.actor(user_uid)],
|
||||
)
|
||||
if leveled_up:
|
||||
audit.record_system(
|
||||
"reward.level.up",
|
||||
actor_kind="user",
|
||||
actor_uid=user_uid,
|
||||
old_value=current_level,
|
||||
new_value=new_level,
|
||||
metadata={"level": new_level},
|
||||
summary=f"user reached level {new_level}",
|
||||
links=[audit.actor(user_uid)],
|
||||
)
|
||||
create_notification(
|
||||
user_uid,
|
||||
"level",
|
||||
@@ -471,8 +536,19 @@ def check_milestone_badges(user_uid: str) -> list:
|
||||
if "On Fire" not in held:
|
||||
from devplacepy.database import get_streaks
|
||||
|
||||
if get_streaks(user_uid)["current"] >= 7 and award_badge(user_uid, "On Fire"):
|
||||
streak = get_streaks(user_uid)["current"]
|
||||
if streak >= 7 and award_badge(user_uid, "On Fire"):
|
||||
awarded.append("On Fire")
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record_system(
|
||||
"reward.streak.milestone",
|
||||
actor_kind="user",
|
||||
actor_uid=user_uid,
|
||||
metadata={"streak": streak},
|
||||
summary=f"user reached a {streak}-day streak",
|
||||
links=[audit.actor(user_uid)],
|
||||
)
|
||||
for badge_name in awarded:
|
||||
notify_badge(user_uid, badge_name)
|
||||
return awarded
|
||||
|
||||
Reference in New Issue
Block a user