Skip to content

Audit trail

wardn has two hooks. OnChange sees every change to who can do what; OnDecision sees every check.

wardn.Config{
OnChange: func(ctx context.Context, e wardn.Event) error {
return auditLog.Write(ctx, AuditRecord{
Type: string(e.Type), // "assignment.created", "role.updated", ...
Actor: e.Actor, // the signed-in user who made the change
System: e.System, // true for System() calls, expiry sweeps, and links made with no signed-in user
User: e.User,
Role: e.Role,
Scope: e.Scope.String(),
At: e.At,
Failed: e.Err != nil,
})
},
}
Event When
assignment.created A role is assigned (or its time window replaced)
assignment.revoked A role is revoked
assignment.expired The sweeper records that a time-limited assignment ended
role.created, role.updated, role.deleted A custom role changes (Before and After hold the role)
scope.linked A scope is given its first parent (Parent)
scope.moved A scope is moved with Move (Parent is the new parent, PreviousParent the old one)

Assignment events also carry StartsAt and ExpiresAt, the time window.

To store the event as it is, marshal it: json.Marshal(e) gives stable snake_case names (type, actor, previous_parent, starts_at…), leaves out fields that don’t apply, and writes Err as its message under error. Repeating a change that changes nothing (assigning exactly what’s already there, linking to the same parent) records nothing, so seeding the first administrator on every start keeps the trail clean.

OnChange runs before the change is written, and can stop it:

  • If it returns an error, the change is not made (ErrAuditRejected). If your audit log is down, role changes wait until it’s back. That’s the right trade-off for regulated products.
  • If the write then fails, OnChange is called again with Event.Err set, so your trail shows the change didn’t (fully) happen. Its return value is ignored on that call.

OnChange is never called for a change that is abandoned before it’s written.

wardn.Config{
OnDecision: func(ctx context.Context, d wardn.Decision, err error) {
if !d.Allowed {
log.Info("access denied", "user", d.User, "permission", d.Permission,
"scope", d.Scope.String(), "reason", d.Reason, "err", err)
}
},
}

It’s called for every Can, Check, CanAny, CanAll, CanEach and CheckEach, and in the Require and RequireAny middleware. CanEach and CheckEach call it once per scope. It must be fast and must not panic. Logging only denials is a common choice.

Changes made through System(), expiry sweeps, and Link or Move calls with no signed-in user in ctx have Event.System set and an empty Actor, so they can never be confused with a real user, even one whose ID happens to be "system".

Questions about this page?

Ask below, point out something wrong or unclear, or suggest an improvement. Reading needs no account; to post, sign in with GitHub.