Skip to content

Commit 5eb68cc

Browse files
feat(admin): add audit logging for privileged admin actions (imDarshanGK#578) (imDarshanGK#1114)
Add an append-only AuditLog model and an admin-gated router that records who performed sensitive actions and when. Admin actions (user role updates and account deletion) are logged with actor, target, timestamp, and IP, and exposed through a queryable GET /admin/audit-logs endpoint. Sensitive fields (passwords, tokens, secrets, API keys) are redacted before audit details are persisted. Adds an is_admin flag to User and a require_admin dependency (401 unauthenticated, 403 non-admin). Closes imDarshanGK#578
1 parent de05769 commit 5eb68cc

8 files changed

Lines changed: 479 additions & 2 deletions

File tree

backend/app/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from fastapi.staticfiles import StaticFiles
1717

1818
from .observability import initialise_app_info, prometheus_metrics_middleware
19-
from .routers import analyze, auth, chat, collaboration, debugging, explanation
19+
from .routers import admin, analyze, auth, chat, collaboration, debugging, explanation
2020
from .routers import health as health_router
2121
from .routers import history
2222
from .routers import metrics as metrics_router
@@ -142,6 +142,10 @@ async def lifespan(app: FastAPI):
142142
"name": "Subscription",
143143
"description": "Email newsletter subscription and unsubscription.",
144144
},
145+
{
146+
"name": "Admin",
147+
"description": "Administrator-only operations (user role management, account deletion) and a queryable, append-only audit log of privileged actions.",
148+
},
145149
{
146150
"name": "System",
147151
"description": "Root info, legacy health check, and ping endpoints.",
@@ -216,6 +220,7 @@ async def add_cache_header(request: Request, call_next):
216220
app.include_router(chat.router)
217221
app.include_router(share.router)
218222
app.include_router(user_data.router)
223+
app.include_router(admin.router)
219224
app.include_router(upload_file.router, prefix="/upload", tags=["Upload File"])
220225
app.include_router(
221226
collaboration.router,

backend/app/models.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from datetime import UTC, datetime
22

3-
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
3+
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
44
from sqlalchemy.orm import Mapped, mapped_column, relationship
55

66
from .database import Base
@@ -12,6 +12,7 @@ class User(Base):
1212
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
1313
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
1414
password_hash: Mapped[str] = mapped_column(String(256))
15+
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
1516
created_at: Mapped[datetime] = mapped_column(
1617
DateTime, default=lambda: datetime.now(UTC)
1718
)
@@ -78,3 +79,28 @@ class SharedSnippet(Base):
7879
created_at: Mapped[datetime] = mapped_column(
7980
DateTime, default=lambda: datetime.now(UTC)
8081
)
82+
83+
84+
class AuditLog(Base):
85+
"""Append-only record of a privileged (admin) action.
86+
87+
Rows are written once and never updated or deleted by application code, so
88+
the table acts as an immutable audit trail. ``actor_email`` is denormalised
89+
so the record stays meaningful even if the acting user is later removed.
90+
"""
91+
92+
__tablename__ = "audit_logs"
93+
94+
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
95+
actor_id: Mapped[int | None] = mapped_column(
96+
ForeignKey("users.id", ondelete="SET NULL"), index=True, nullable=True
97+
)
98+
actor_email: Mapped[str] = mapped_column(String(320))
99+
action: Mapped[str] = mapped_column(String(100), index=True)
100+
target_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
101+
target_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
102+
details: Mapped[str | None] = mapped_column(Text, nullable=True)
103+
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
104+
created_at: Mapped[datetime] = mapped_column(
105+
DateTime, default=lambda: datetime.now(UTC), index=True
106+
)

backend/app/routers/admin.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Admin router — privileged actions and the audit-log query API.
2+
3+
Every state-changing endpoint here is gated behind :func:`require_admin` and
4+
records an append-only entry via :func:`record_audit`, giving a tamper-evident
5+
trail of who did what and when.
6+
"""
7+
8+
import json
9+
10+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
11+
from sqlalchemy import select
12+
from sqlalchemy.orm import Session
13+
14+
from ..database import get_db
15+
from ..models import AuditLog, User
16+
from ..schemas import AuditLogRecord, MessageResponse, RoleUpdateRequest
17+
from ..security import require_admin
18+
from ..services.audit import record_audit
19+
20+
router = APIRouter(prefix="/admin", tags=["Admin"])
21+
22+
23+
def _client_ip(request: Request) -> str | None:
24+
return request.client.host if request.client else None
25+
26+
27+
def _to_record(entry: AuditLog) -> AuditLogRecord:
28+
return AuditLogRecord(
29+
id=entry.id,
30+
actor_id=entry.actor_id,
31+
actor_email=entry.actor_email,
32+
action=entry.action,
33+
target_type=entry.target_type,
34+
target_id=entry.target_id,
35+
details=json.loads(entry.details) if entry.details else None,
36+
ip_address=entry.ip_address,
37+
created_at=entry.created_at.isoformat(),
38+
)
39+
40+
41+
@router.get("/audit-logs", response_model=list[AuditLogRecord])
42+
def list_audit_logs(
43+
action: str | None = Query(None, description="Filter by exact action name."),
44+
actor_id: int | None = Query(None, description="Filter by acting admin's id."),
45+
limit: int = Query(50, ge=1, le=200),
46+
offset: int = Query(0, ge=0),
47+
_admin: User = Depends(require_admin),
48+
db: Session = Depends(get_db),
49+
):
50+
"""Return audit entries, newest first. Admin only."""
51+
query = select(AuditLog).order_by(AuditLog.id.desc())
52+
if action is not None:
53+
query = query.where(AuditLog.action == action)
54+
if actor_id is not None:
55+
query = query.where(AuditLog.actor_id == actor_id)
56+
57+
entries = db.execute(query.limit(limit).offset(offset)).scalars().all()
58+
return [_to_record(entry) for entry in entries]
59+
60+
61+
@router.put("/users/{user_id}/role", response_model=MessageResponse)
62+
def update_user_role(
63+
user_id: int,
64+
payload: RoleUpdateRequest,
65+
request: Request,
66+
admin: User = Depends(require_admin),
67+
db: Session = Depends(get_db),
68+
):
69+
"""Grant or revoke a user's admin role, recording the change in the audit log."""
70+
user = db.get(User, user_id)
71+
if user is None:
72+
raise HTTPException(
73+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
74+
)
75+
76+
previous = user.is_admin
77+
user.is_admin = payload.is_admin
78+
record_audit(
79+
db,
80+
actor=admin,
81+
action="user.role_update",
82+
target_type="user",
83+
target_id=user.id,
84+
details={"from": previous, "to": payload.is_admin, "email": user.email},
85+
ip_address=_client_ip(request),
86+
)
87+
db.commit()
88+
return MessageResponse(
89+
message=f"User {user_id} admin role set to {payload.is_admin}."
90+
)
91+
92+
93+
@router.delete("/users/{user_id}", response_model=MessageResponse)
94+
def delete_user(
95+
user_id: int,
96+
request: Request,
97+
admin: User = Depends(require_admin),
98+
db: Session = Depends(get_db),
99+
):
100+
"""Delete a user account, recording the action in the audit log."""
101+
if user_id == admin.id:
102+
raise HTTPException(
103+
status_code=status.HTTP_400_BAD_REQUEST,
104+
detail="Admins cannot delete their own account",
105+
)
106+
107+
user = db.get(User, user_id)
108+
if user is None:
109+
raise HTTPException(
110+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
111+
)
112+
113+
email = user.email
114+
db.delete(user)
115+
record_audit(
116+
db,
117+
actor=admin,
118+
action="user.delete",
119+
target_type="user",
120+
target_id=user_id,
121+
details={"email": email},
122+
ip_address=_client_ip(request),
123+
)
124+
db.commit()
125+
return MessageResponse(message=f"User {user_id} deleted.")

backend/app/schemas.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,57 @@ class MessageResponse(BaseModel):
433433
)
434434

435435

436+
# ── Admin / Audit ─────────────────────────────────────────────────────────────
437+
class RoleUpdateRequest(BaseModel):
438+
"""Request body for promoting or demoting a user's admin role."""
439+
440+
is_admin: bool = Field(
441+
...,
442+
description="Whether the target user should have administrator privileges.",
443+
example=True,
444+
)
445+
446+
447+
class AuditLogRecord(BaseModel):
448+
"""A single immutable audit-trail entry for a privileged action."""
449+
450+
id: int = Field(..., description="Audit entry identifier.", example=1)
451+
actor_id: int | None = Field(
452+
None,
453+
description="User id of the admin who performed the action (null if removed).",
454+
example=42,
455+
)
456+
actor_email: str = Field(
457+
...,
458+
description="Email of the admin who performed the action.",
459+
example="admin@example.com",
460+
)
461+
action: str = Field(
462+
...,
463+
description="Machine-readable action name.",
464+
example="user.role_update",
465+
)
466+
target_type: str | None = Field(
467+
None, description="Type of the entity acted upon.", example="user"
468+
)
469+
target_id: str | None = Field(
470+
None, description="Identifier of the entity acted upon.", example="7"
471+
)
472+
details: dict[str, Any] | None = Field(
473+
None,
474+
description="Additional context for the action, with sensitive fields redacted.",
475+
example={"is_admin": True},
476+
)
477+
ip_address: str | None = Field(
478+
None, description="Source IP address of the request.", example="203.0.113.5"
479+
)
480+
created_at: str = Field(
481+
...,
482+
description="ISO-8601 timestamp of when the action occurred.",
483+
example="2026-06-24T12:30:00+00:00",
484+
)
485+
486+
436487
# ── Health ────────────────────────────────────────────────────────────────────
437488
class HealthResponse(BaseModel):
438489
"""Generic health / status response."""

backend/app/security.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,17 @@ def get_current_user(
9595
)
9696

9797
return user
98+
99+
100+
def require_admin(current_user: User = Depends(get_current_user)) -> User:
101+
"""Allow the request only if the authenticated user is an admin.
102+
103+
Returns the user on success; raises ``403`` otherwise. Layered on top of
104+
``get_current_user`` so unauthenticated callers still receive ``401``.
105+
"""
106+
if not current_user.is_admin:
107+
raise HTTPException(
108+
status_code=status.HTTP_403_FORBIDDEN,
109+
detail="Administrator privileges required",
110+
)
111+
return current_user

backend/app/services/audit.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Audit logging for privileged admin actions.
2+
3+
Writes append-only :class:`~app.models.AuditLog` rows. Sensitive values in the
4+
free-form ``details`` payload are redacted before they are persisted so the
5+
trail never stores secrets, tokens, or password material.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from typing import Any
12+
13+
from sqlalchemy.orm import Session
14+
15+
from ..models import AuditLog, User
16+
17+
# Substrings that mark a field as sensitive; matched case-insensitively against
18+
# the key name. Matching values are replaced with ``REDACTED`` before storage.
19+
_SENSITIVE_KEY_PARTS = (
20+
"password",
21+
"passwd",
22+
"secret",
23+
"token",
24+
"api_key",
25+
"apikey",
26+
"authorization",
27+
"credential",
28+
)
29+
30+
REDACTED = "***REDACTED***"
31+
32+
33+
def _is_sensitive(key: str) -> bool:
34+
lowered = key.lower()
35+
return any(part in lowered for part in _SENSITIVE_KEY_PARTS)
36+
37+
38+
def redact(value: Any) -> Any:
39+
"""Recursively replace sensitive values in dicts/lists with a placeholder."""
40+
if isinstance(value, dict):
41+
return {
42+
k: (REDACTED if _is_sensitive(str(k)) else redact(v))
43+
for k, v in value.items()
44+
}
45+
if isinstance(value, (list, tuple)):
46+
return [redact(item) for item in value]
47+
return value
48+
49+
50+
def record_audit(
51+
db: Session,
52+
*,
53+
actor: User,
54+
action: str,
55+
target_type: str | None = None,
56+
target_id: str | int | None = None,
57+
details: dict[str, Any] | None = None,
58+
ip_address: str | None = None,
59+
) -> AuditLog:
60+
"""Persist a single audit entry for ``actor`` and return it.
61+
62+
``details`` is redacted and serialised to JSON. The caller is responsible
63+
for committing the surrounding transaction.
64+
"""
65+
safe_details = redact(details) if details else None
66+
entry = AuditLog(
67+
actor_id=actor.id,
68+
actor_email=actor.email,
69+
action=action,
70+
target_type=target_type,
71+
target_id=None if target_id is None else str(target_id),
72+
details=json.dumps(safe_details) if safe_details is not None else None,
73+
ip_address=ip_address,
74+
)
75+
db.add(entry)
76+
db.flush()
77+
return entry

0 commit comments

Comments
 (0)