Skip to content

Commit 475dc70

Browse files
committed
Label service-account audit events as System.
Staff writes keep their Auth and profile identity even if the profile doc is being deleted.
1 parent 0523d1e commit 475dc70

3 files changed

Lines changed: 145 additions & 44 deletions

File tree

app/admin/activity/page.tsx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ const ROLE_COLORS: Record<string, string> = {
6767
admin: "bg-purple-500/15 text-purple-300 border-purple-500/30",
6868
author: "bg-blue-500/15 text-blue-300 border-blue-500/30",
6969
moderator: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
70+
system: "bg-white/10 text-white/70 border-white/20",
71+
}
72+
73+
function isSystemActorUid(uid: string) {
74+
return /gserviceaccount\.com$/i.test(uid || "")
75+
}
76+
77+
function resolveActorDisplay(log: AuditLogEntry) {
78+
if (isSystemActorUid(log.actorUid) || log.actorRole === "system") {
79+
return { name: "System", handle: "", role: "system" }
80+
}
81+
return {
82+
name: log.actorName || "Unknown user",
83+
handle: log.actorHandle || "",
84+
role: log.actorRole || "user",
85+
}
7086
}
7187

7288
export default function ActivityLogPage() {
@@ -139,11 +155,12 @@ export default function ActivityLogPage() {
139155
const actorMap = new Map<string, { uid: string; name: string; handle: string; role: string }>()
140156
logs.forEach((log) => {
141157
if (log.actorUid && !actorMap.has(log.actorUid)) {
158+
const display = resolveActorDisplay(log)
142159
actorMap.set(log.actorUid, {
143160
uid: log.actorUid,
144-
name: log.actorName,
145-
handle: log.actorHandle,
146-
role: log.actorRole,
161+
name: display.name,
162+
handle: display.handle,
163+
role: display.role,
147164
})
148165
}
149166
})
@@ -182,17 +199,20 @@ export default function ActivityLogPage() {
182199
// Search query filter
183200
if (searchQuery.trim()) {
184201
const queryLower = searchQuery.toLowerCase().trim()
202+
const display = resolveActorDisplay(log)
185203
const matchDetails = log.details.toLowerCase().includes(queryLower)
186-
const matchActorName = log.actorName.toLowerCase().includes(queryLower)
187-
const matchActorHandle = log.actorHandle.toLowerCase().includes(queryLower)
204+
const matchActorName = display.name.toLowerCase().includes(queryLower)
205+
const matchActorHandle = display.handle.toLowerCase().includes(queryLower)
188206
const matchActorEmail = log.actorEmail.toLowerCase().includes(queryLower)
207+
const matchActorUid = log.actorUid.toLowerCase().includes(queryLower)
189208
const matchTarget = (log.targetTitle || "").toLowerCase().includes(queryLower)
190209
const matchAction = log.action.toLowerCase().includes(queryLower)
191210
return (
192211
matchDetails ||
193212
matchActorName ||
194213
matchActorHandle ||
195214
matchActorEmail ||
215+
matchActorUid ||
196216
matchTarget ||
197217
matchAction
198218
)
@@ -214,7 +234,8 @@ export default function ActivityLogPage() {
214234

215235
const actorActionCounts = new Map<string, { name: string; count: number }>()
216236
logs.forEach((log) => {
217-
const curr = actorActionCounts.get(log.actorUid) || { name: log.actorName, count: 0 }
237+
const display = resolveActorDisplay(log)
238+
const curr = actorActionCounts.get(log.actorUid) || { name: display.name, count: 0 }
218239
curr.count++
219240
actorActionCounts.set(log.actorUid, curr)
220241
})
@@ -248,19 +269,23 @@ export default function ActivityLogPage() {
248269
}
249270

250271
const handleExportCSV = () => {
251-
const headers = ["Timestamp", "Actor Name", "Actor Handle", "Actor Role", "Actor Email", "Category", "Action", "Details", "Target Title", "Target ID"]
252-
const rows = filteredLogs.map((log) => [
272+
const headers = ["Timestamp", "Actor Name", "Actor Handle", "Actor Role", "Actor Email", "Category", "Action", "Details", "Target Title", "Target ID", "Actor UID"]
273+
const rows = filteredLogs.map((log) => {
274+
const actor = resolveActorDisplay(log)
275+
return [
253276
log.timestamp?.toDate ? log.timestamp.toDate().toISOString() : "",
254-
`"${(log.actorName || "").replace(/"/g, '""')}"`,
255-
`"${(log.actorHandle || "").replace(/"/g, '""')}"`,
256-
log.actorRole,
277+
`"${(actor.name || "").replace(/"/g, '""')}"`,
278+
`"${(actor.handle || "").replace(/"/g, '""')}"`,
279+
actor.role,
257280
log.actorEmail,
258281
log.category,
259282
log.action,
260283
`"${(log.details || "").replace(/"/g, '""')}"`,
261284
`"${(log.targetTitle || "").replace(/"/g, '""')}"`,
262285
log.targetId,
263-
])
286+
log.actorUid,
287+
]
288+
})
264289

265290
const csvContent = "data:text/csv;charset=utf-8," + [headers.join(","), ...rows.map((e) => e.join(","))].join("\n")
266291
const encodedUri = encodeURI(csvContent)
@@ -409,7 +434,9 @@ export default function ActivityLogPage() {
409434
<SelectItem value="all">All Staff Members</SelectItem>
410435
{distinctActors.map((actor) => (
411436
<SelectItem key={actor.uid} value={actor.uid}>
412-
{actor.name} (@{actor.handle}) &bull; {actor.role}
437+
{actor.handle
438+
? `${actor.name} (@${actor.handle}) • ${actor.role}`
439+
: `${actor.name}${actor.role}`}
413440
</SelectItem>
414441
))}
415442
</SelectContent>
@@ -492,8 +519,9 @@ export default function ActivityLogPage() {
492519
})
493520
: "Just now"
494521

522+
const actor = resolveActorDisplay(log)
495523
const categoryBadge = CATEGORY_COLORS[log.category] || "bg-white/10 text-white/70 border-white/15"
496-
const roleBadge = ROLE_COLORS[log.actorRole] || "bg-white/10 text-white/70 border-white/15"
524+
const roleBadge = ROLE_COLORS[actor.role] || "bg-white/10 text-white/70 border-white/15"
497525
const hasMetadata = log.metadata && Object.keys(log.metadata).length > 0
498526

499527
return (
@@ -517,23 +545,23 @@ export default function ActivityLogPage() {
517545
/>
518546
) : (
519547
<div className="h-10 w-10 rounded-full bg-white/10 border border-white/15 flex items-center justify-center text-white/70 text-xs shrink-0 font-bold mt-0.5">
520-
{(log.actorName || "S").slice(0, 2).toUpperCase()}
548+
{(actor.name || "S").slice(0, 2).toUpperCase()}
521549
</div>
522550
)}
523551

524552
<div className="min-w-0 flex-1">
525553
{/* Actor Name, Handle, Role, Category */}
526554
<div className="flex flex-wrap items-center gap-2">
527555
<span className="font-semibold text-white text-sm">
528-
{log.actorName}
556+
{actor.name}
529557
</span>
530-
{log.actorHandle && (
558+
{actor.handle && (
531559
<span className="text-xs text-white/45 font-mono">
532-
@{log.actorHandle}
560+
@{actor.handle}
533561
</span>
534562
)}
535563
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.2 rounded border ${roleBadge}`}>
536-
{log.actorRole}
564+
{actor.role}
537565
</span>
538566
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.2 rounded border flex items-center gap-1 ${categoryBadge}`}>
539567
{CATEGORY_ICONS[log.category]}

functions/src/index.ts

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,23 @@ function stripUndefinedDeep(value: unknown): unknown {
10631063
return value;
10641064
}
10651065

1066+
function isSystemAuditActor(actorUid: string, authType?: string) {
1067+
return (
1068+
authType === "service_account" ||
1069+
authType === "system" ||
1070+
authType === "api_key" ||
1071+
/gserviceaccount\.com$/i.test(actorUid)
1072+
);
1073+
}
1074+
1075+
function pickAuditName(value: unknown) {
1076+
return typeof value === "string" ? value.trim() : "";
1077+
}
1078+
1079+
function pickAuditHandle(value: unknown) {
1080+
return typeof value === "string" ? value.trim().replace(/^@/, "") : "";
1081+
}
1082+
10661083
async function writeServerAuditLog(params: {
10671084
actorUid: string;
10681085
action: string;
@@ -1072,42 +1089,78 @@ async function writeServerAuditLog(params: {
10721089
targetTitle?: string;
10731090
metadata?: Record<string, unknown>;
10741091
idempotencyKey?: string;
1092+
authType?: string;
1093+
actorHint?: {
1094+
name?: string;
1095+
handle?: string;
1096+
role?: string;
1097+
email?: string;
1098+
photoURL?: string;
1099+
};
10751100
}) {
10761101
const firestore = getDb();
1077-
let actorName = "Staff";
1102+
const hint = params.actorHint || {};
1103+
let actorName = "";
10781104
let actorHandle = "";
1079-
let actorRole = "staff";
1105+
let actorRole = "";
10801106
let actorEmail = "";
10811107
let actorPhotoURL = "";
10821108

1083-
try {
1084-
const [authorDoc, authUser] = await Promise.all([
1085-
firestore.collection("authors").doc(params.actorUid).get(),
1086-
admin.auth().getUser(params.actorUid).catch(() => null),
1087-
]);
1088-
if (authorDoc.exists) {
1089-
const authorData = authorDoc.data();
1090-
actorName = authorData?.name || authUser?.displayName || actorName;
1091-
actorHandle = authorData?.handle || "";
1092-
actorRole = authorData?.role || "staff";
1093-
actorPhotoURL = authorData?.avatar || authUser?.photoURL || "";
1094-
}
1095-
actorEmail = authUser?.email || "";
1096-
if (!actorHandle) {
1097-
const userDoc = await firestore.collection("users").doc(params.actorUid).get();
1098-
if (userDoc.exists) {
1099-
actorHandle = userDoc.data()?.handle || "";
1100-
if (!actorPhotoURL) actorPhotoURL = userDoc.data()?.photoURL || "";
1101-
}
1109+
if (isSystemAuditActor(params.actorUid, params.authType)) {
1110+
actorName = "System";
1111+
actorRole = "system";
1112+
actorEmail = params.actorUid.includes("@") ? params.actorUid : "";
1113+
} else {
1114+
try {
1115+
const [authorDoc, userDoc, authUser] = await Promise.all([
1116+
firestore.collection("authors").doc(params.actorUid).get(),
1117+
firestore.collection("users").doc(params.actorUid).get(),
1118+
admin.auth().getUser(params.actorUid).catch(() => null),
1119+
]);
1120+
const authorData = authorDoc.exists ? authorDoc.data() : undefined;
1121+
const userData = userDoc.exists ? userDoc.data() : undefined;
1122+
actorName =
1123+
pickAuditName(authorData?.name) ||
1124+
pickAuditName(userData?.staffName) ||
1125+
pickAuditName(userData?.displayName) ||
1126+
pickAuditName(authUser?.displayName) ||
1127+
pickAuditName(hint.name) ||
1128+
pickAuditName(authUser?.email?.split("@")[0]) ||
1129+
params.actorUid;
1130+
actorHandle =
1131+
pickAuditHandle(authorData?.handle) ||
1132+
pickAuditHandle(userData?.handle) ||
1133+
pickAuditHandle(hint.handle) ||
1134+
pickAuditHandle(authUser?.email?.split("@")[0]) ||
1135+
"";
1136+
actorRole =
1137+
pickAuditHandle(authorData?.role) ||
1138+
pickAuditHandle(hint.role) ||
1139+
(authorDoc.exists ? "staff" : "user");
1140+
actorEmail =
1141+
authUser?.email ||
1142+
pickAuditName(userData?.email) ||
1143+
pickAuditName(hint.email) ||
1144+
"";
1145+
actorPhotoURL =
1146+
pickAuditName(authorData?.avatar) ||
1147+
pickAuditName(userData?.photoURL) ||
1148+
authUser?.photoURL ||
1149+
pickAuditName(hint.photoURL) ||
1150+
"";
1151+
} catch (e) {
1152+
logger.warn("Could not enrich server audit log actor details:", e);
1153+
actorName = pickAuditName(hint.name) || params.actorUid;
1154+
actorHandle = pickAuditHandle(hint.handle) || "";
1155+
actorRole = pickAuditHandle(hint.role) || "user";
1156+
actorEmail = pickAuditName(hint.email) || "";
11021157
}
1103-
} catch (e) {
1104-
logger.warn("Could not enrich server audit log actor details:", e);
11051158
}
11061159

11071160
const auditData = stripUndefinedDeep({
11081161
actorUid: params.actorUid,
11091162
actorName,
1110-
actorHandle: actorHandle || actorEmail.split("@")[0] || "staff",
1163+
actorHandle,
11111164
actorEmail,
11121165
actorRole,
11131166
actorPhotoURL,
@@ -1142,6 +1195,7 @@ function changedTopLevelKeys(
11421195
async function auditAuthenticatedClientWrite(params: {
11431196
eventId: string;
11441197
actorUid?: string;
1198+
authType?: string;
11451199
collection: string;
11461200
documentId: string;
11471201
category: "articles" | "comments" | "team" | "profile";
@@ -1150,8 +1204,21 @@ async function auditAuthenticatedClientWrite(params: {
11501204
}) {
11511205
if (!params.actorUid) return;
11521206
const operation = !params.before ? "create" : !params.after ? "delete" : "update";
1207+
const snapshot = params.after || params.before;
1208+
const actorHint =
1209+
params.documentId === params.actorUid
1210+
? {
1211+
name: pickAuditName(snapshot?.name) || pickAuditName(snapshot?.displayName) || pickAuditName(snapshot?.staffName),
1212+
handle: pickAuditHandle(snapshot?.handle),
1213+
role: pickAuditHandle(snapshot?.role),
1214+
email: pickAuditName(snapshot?.email),
1215+
photoURL: pickAuditName(snapshot?.avatar) || pickAuditName(snapshot?.photoURL),
1216+
}
1217+
: undefined;
11531218
await writeServerAuditLog({
11541219
actorUid: params.actorUid,
1220+
authType: params.authType,
1221+
actorHint,
11551222
action: `${params.collection}.${operation}`,
11561223
category: params.category,
11571224
details: `${operation} ${params.collection} record ${params.documentId}`,
@@ -1172,6 +1239,7 @@ export const auditClientArticleWrite = onDocumentWrittenWithAuthContext(
11721239
async (event) => auditAuthenticatedClientWrite({
11731240
eventId: event.id,
11741241
actorUid: event.authId,
1242+
authType: event.authType,
11751243
collection: "article",
11761244
documentId: event.params.documentId,
11771245
category: "articles",
@@ -1185,6 +1253,7 @@ export const auditClientArticleTrashWrite = onDocumentWrittenWithAuthContext(
11851253
async (event) => auditAuthenticatedClientWrite({
11861254
eventId: event.id,
11871255
actorUid: event.authId,
1256+
authType: event.authType,
11881257
collection: "article_trash",
11891258
documentId: event.params.documentId,
11901259
category: "articles",
@@ -1198,6 +1267,7 @@ export const auditClientAuthorWrite = onDocumentWrittenWithAuthContext(
11981267
async (event) => auditAuthenticatedClientWrite({
11991268
eventId: event.id,
12001269
actorUid: event.authId,
1270+
authType: event.authType,
12011271
collection: "author_profile",
12021272
documentId: event.params.documentId,
12031273
category: "profile",
@@ -1211,6 +1281,7 @@ export const auditClientCommentWrite = onDocumentWrittenWithAuthContext(
12111281
async (event) => auditAuthenticatedClientWrite({
12121282
eventId: event.id,
12131283
actorUid: event.authId,
1284+
authType: event.authType,
12141285
collection: "comment",
12151286
documentId: event.params.documentId,
12161287
category: "comments",
@@ -1224,6 +1295,7 @@ export const auditClientReplyWrite = onDocumentWrittenWithAuthContext(
12241295
async (event) => auditAuthenticatedClientWrite({
12251296
eventId: event.id,
12261297
actorUid: event.authId,
1298+
authType: event.authType,
12271299
collection: "comment_reply",
12281300
documentId: event.params.documentId,
12291301
category: "comments",
@@ -1237,6 +1309,7 @@ export const auditClientReaderProfileWrite = onDocumentWrittenWithAuthContext(
12371309
async (event) => auditAuthenticatedClientWrite({
12381310
eventId: event.id,
12391311
actorUid: event.authId,
1312+
authType: event.authType,
12401313
collection: "reader_profile",
12411314
documentId: event.params.documentId,
12421315
category: "profile",

lib/audit-logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface AuditLogEntry {
2525
actorName: string
2626
actorHandle: string
2727
actorEmail: string
28-
actorRole: "super" | "admin" | "author" | "moderator"
28+
actorRole: "super" | "admin" | "author" | "moderator" | "staff" | "system" | "user"
2929
actorPhotoURL?: string
3030
action: string
3131
category: AuditCategory

0 commit comments

Comments
 (0)