Audit Logging
Why audit logging matters for authorization
The Securing Your API course introduced structured logging for security events (failed logins, rate limits). Authorization adds a new category: who tried to access what, and was it allowed?
Audit logs answer questions like: “Who deleted the Q4 Plan note?” “When did Bob’s role change from viewer to editor?” “Which API key accessed the billing data?” These questions come up during incident investigations, compliance audits, and debugging.
What to log
Every call to the policy function is an authorization decision. Log it:
// src/policy.ts — update the can function
import { log } from "./logger.js";
export function can(ctx: PolicyContext, action: string): boolean {
const membership = getMembership(ctx.user.id, ctx.orgId);
if (!membership) {
log("authz_denied", {
userId: ctx.user.id,
orgId: ctx.orgId,
action,
reason: "not_a_member",
});
return false;
}
const permissions = getPermissions(membership.role, ctx.orgId);
let allowed = permissions.has(action);
// Special cases...
if (!allowed && action === "notes:edit" && ctx.resourceOwnerId === ctx.user.id) {
allowed = permissions.has("notes:read");
}
// Check API key scopes
if (allowed && ctx.user.scopes !== null && !ctx.user.scopes.includes(action)) {
allowed = false;
}
log(allowed ? "authz_allowed" : "authz_denied", {
userId: ctx.user.id,
orgId: ctx.orgId,
action,
role: membership.role,
...(ctx.resourceOwnerId ? { resourceOwnerId: ctx.resourceOwnerId } : {}),
...(ctx.user.scopes !== null ? { apiKeyScoped: true } : {}),
});
return allowed;
} Every authorization decision produces a log entry with the user, org, action, role, and result.
Additional events to log
Beyond the policy function, log these authorization-specific events:
// Membership changes
log("membership_created", { userId, orgId, role, invitedBy });
log("membership_role_changed", { userId, orgId, oldRole, newRole, changedBy });
log("membership_removed", { userId, orgId, removedBy });
// Invite lifecycle
log("invite_created", { email, orgId, role, invitedBy });
log("invite_accepted", { email, orgId, role });
// API key lifecycle
log("api_key_created", { userId, orgId, name, scopes });
log("api_key_revoked", { keyId, orgId, revokedBy });
// Custom role changes
log("custom_role_created", { orgId, name, permissions, createdBy });
log("custom_role_updated", { orgId, name, permissions, updatedBy }); What NOT to log
Same rules as the Securing Your API course:
- Never log API keys (the actual key value)
- Never log session IDs
- Never log passwords
- Log the key prefix or ID, not the key itself
Querying the audit log
For now, audit logs go to stdout as JSON. In production, they would go to a log aggregation service. Common queries:
“What happened to note X?” Filter by action containing “notes:” and the note ID.
“What did Bob do yesterday?” Filter by userId and timestamp.
“Which API keys were used?” Filter by apiKeyScoped: true.
Exercises
Exercise 1: Add logging to the can function. Perform several operations as different users and review the log output. Can you reconstruct who did what?
Exercise 2: Add logging to the invite acceptance flow. Accept an invite and verify the log includes the email, org, and role.
Exercise 3: Simulate an incident: “Someone deleted the Q4 Plan note.” Search the logs for notes:delete actions in Acme. Who did it?
Why do we log both allowed and denied authorization decisions?