Skip to content

Commit 2ec47ea

Browse files
authored
Merge pull request #119 from magfest/feature/audit-role-changes
Audit role grants and revokes
2 parents 07f655b + da79214 commit 2ec47ea

4 files changed

Lines changed: 509 additions & 0 deletions

File tree

app/routes/admin/data_upload.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@
4444
UI_GROUP_HOTEL_SERVICES,
4545
)
4646
from app.routes import h
47+
from app.routes.admin.users import _role_code
48+
from app.security_audit import (
49+
log_security_event,
50+
EVENT_USER_MODIFY,
51+
CATEGORY_ADMIN,
52+
SEVERITY_ALERT,
53+
SEVERITY_INFO,
54+
)
4755
from .helpers import (
4856
require_super_admin,
4957
render_admin_config_page,
@@ -1282,6 +1290,7 @@ def user_roles_upload():
12821290

12831291
created = 0
12841292
skipped = 0
1293+
granted_by_user_id: dict[str, dict] = {}
12851294

12861295
for idx, row in df.iterrows():
12871296
user_identifier = _get_cell_value(row, user_col)
@@ -1332,7 +1341,41 @@ def user_roles_upload():
13321341
approval_group_id=approval_group_id,
13331342
)
13341343
db.session.add(role)
1344+
# UserRole.approval_group has load_on_pending=False (the
1345+
# SQLAlchemy default). A lazy load on a pending, unflushed
1346+
# object returns None without querying; it does not wait for
1347+
# autoflush. Flush now so _role_code() below reads the real
1348+
# approval group instead of silently dropping it.
1349+
db.session.flush()
13351350
created += 1
1351+
entry = granted_by_user_id.setdefault(
1352+
user.id, {"user": user, "codes": [], "raw_codes": []}
1353+
)
1354+
entry["codes"].append(_role_code(role))
1355+
entry["raw_codes"].append(role_code)
1356+
1357+
# One audit entry per user touched, logged before the commit so a failure
1358+
# between the two can never record a grant that did not happen.
1359+
for entry in granted_by_user_id.values():
1360+
user = entry["user"]
1361+
granted = entry["codes"]
1362+
# Severity is computed from the raw role codes, not the rendered
1363+
# ones. approval_group_id is set on the row for any role, not just
1364+
# APPROVER, so a SUPER_ADMIN row with a group column filled would
1365+
# render "SUPER_ADMIN:TECH" and silently fail a membership test
1366+
# against ROLE_SUPER_ADMIN.
1367+
log_security_event(
1368+
EVENT_USER_MODIFY,
1369+
category=CATEGORY_ADMIN,
1370+
severity=SEVERITY_ALERT if ROLE_SUPER_ADMIN in entry["raw_codes"] else SEVERITY_INFO,
1371+
details={
1372+
"target_user_id": user.id,
1373+
"target_email": user.email,
1374+
"granted": sorted(granted),
1375+
"revoked": [],
1376+
"source": "bulk_upload",
1377+
},
1378+
)
13361379

13371380
db.session.commit()
13381381

app/routes/admin/users.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
CONFIG_AUDIT_RESTORE,
2424
)
2525
from app.routes import h
26+
from app.security_audit import (
27+
log_security_event,
28+
EVENT_USER_MODIFY,
29+
CATEGORY_ADMIN,
30+
SEVERITY_ALERT,
31+
SEVERITY_INFO,
32+
)
2633
from .helpers import (
2734
require_super_admin,
2835
render_admin_config_page,
@@ -306,6 +313,24 @@ def restore_user(user_id: str):
306313
return redirect(url_for(".list_users"))
307314

308315

316+
def _role_code(role: UserRole) -> str:
317+
"""One role as a readable code, never a database ID."""
318+
if role.work_type_id and role.work_type:
319+
return f"{role.role_code}:{role.work_type.code}"
320+
if role.approval_group_id and role.approval_group:
321+
return f"{role.role_code}:{role.approval_group.code}"
322+
return role.role_code
323+
324+
325+
def _role_codes(user: User) -> set[str]:
326+
"""Current roles as readable codes, never database IDs.
327+
328+
An ID is meaningless in an audit entry read a year later, and it breaks if
329+
the work type or approval group is renamed or removed.
330+
"""
331+
return {_role_code(r) for r in user.roles}
332+
333+
309334
def _update_user_roles(user: User, form_data) -> None:
310335
"""
311336
Update user roles based on form data.
@@ -314,7 +339,13 @@ def _update_user_roles(user: User, form_data) -> None:
314339
- role_super_admin: "1" if checked
315340
- role_worktype_admin_<work_type_id>: "1" if checked
316341
- role_approver_<approval_group_id>: "1" if checked
342+
343+
Snapshots the role set before clearing it, and logs a security audit
344+
entry naming what was granted and revoked. The snapshot must come first;
345+
clear() plus flush() below leaves nothing to diff against afterward.
317346
"""
347+
before = _role_codes(user)
348+
318349
# Clear existing roles
319350
user.roles.clear()
320351
db.session.flush()
@@ -348,3 +379,34 @@ def _update_user_roles(user: User, form_data) -> None:
348379
approval_group_id=ag.id,
349380
)
350381
db.session.add(role)
382+
383+
# New roles are added via db.session.add(), not user.roles.append(). The
384+
# already-loaded `roles` collection on `user` (cached when `before` ran)
385+
# does not see them. flush() persists the FK rows; it does not refresh a
386+
# collection already loaded in memory. Expire it so the next access
387+
# requeries instead of returning the stale list.
388+
db.session.flush()
389+
db.session.expire(user, ["roles"])
390+
after = _role_codes(user)
391+
392+
granted = sorted(after - before)
393+
revoked = sorted(before - after)
394+
if not granted and not revoked:
395+
return
396+
397+
log_security_event(
398+
EVENT_USER_MODIFY,
399+
category=CATEGORY_ADMIN,
400+
severity=(
401+
SEVERITY_ALERT
402+
if ROLE_SUPER_ADMIN in granted or ROLE_SUPER_ADMIN in revoked
403+
else SEVERITY_INFO
404+
),
405+
details={
406+
"source": "admin_form",
407+
"target_user_id": user.id,
408+
"target_email": user.email,
409+
"granted": granted,
410+
"revoked": revoked,
411+
},
412+
)

app/security_audit.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
from __future__ import annotations
1010

1111
import json
12+
import logging
1213
from datetime import datetime
1314
from flask import request, session, has_request_context
1415
from app import db
1516
from app.models import SecurityAuditLog
17+
from app.services.slack import send_slack_message
18+
19+
logger = logging.getLogger(__name__)
1620

1721
# Event categories
1822
CATEGORY_AUTH = "AUTH"
@@ -38,6 +42,23 @@
3842
SEVERITY_ALERT = "ALERT"
3943

4044

45+
def _format_security_alert(event_type: str, user_id: str | None, details: dict | None) -> str:
46+
"""One line of Slack text for an ALERT-severity security event."""
47+
actor = user_id or "unknown user"
48+
parts = [f"Security alert: {event_type} by {actor}"]
49+
if details:
50+
target = details.get("target_email") or details.get("target_user_id")
51+
if target:
52+
parts.append(f"target {target}")
53+
granted = details.get("granted")
54+
if granted:
55+
parts.append(f"granted {', '.join(granted)}")
56+
revoked = details.get("revoked")
57+
if revoked:
58+
parts.append(f"revoked {', '.join(revoked)}")
59+
return ". ".join(parts)
60+
61+
4162
def log_security_event(
4263
event_type: str,
4364
category: str,
@@ -83,6 +104,30 @@ def log_security_event(
83104
details=json.dumps(details) if details else None,
84105
)
85106
db.session.add(event)
107+
108+
# ALERT posts to Slack synchronously, with a 10 second timeout on the
109+
# request path. Keep ALERT for rare, deliberate events. Raising a
110+
# high-frequency event such as ACCESS_DENIED to ALERT would attempt a post
111+
# on every occurrence. send_slack_message never raises and returns False
112+
# when Slack is disabled, unconfigured, or circuit-broken, so a Slack
113+
# problem cannot lose the audit row or fail the request.
114+
#
115+
# A caller that logs ALERT events inside a per-row loop before a single
116+
# commit multiplies this cost. Twenty rows with Slack unreachable is
117+
# twenty sequential ten-second waits, holding the transaction open.
118+
if severity == SEVERITY_ALERT:
119+
try:
120+
send_slack_message(
121+
text=_format_security_alert(event_type, user_id, details),
122+
template_key=f"security_{event_type}",
123+
work_item_id=None,
124+
)
125+
except Exception:
126+
# send_slack_message documents that it never raises. This guard is
127+
# here so that if that ever stops being true, a Slack problem
128+
# cannot swallow the audit row, which is the actual control.
129+
logger.exception("Slack post failed for %s security alert", event_type)
130+
86131
return event
87132

88133

0 commit comments

Comments
 (0)