Recipes · 4 of 6

Audit log — actor + action + diff

A reusable audit pattern: every state-changing procedure also writes an audit_log entry in the same transaction.

Schema

audit_log: {actor, action, collection, doc_id, before, after, ts, ip}

Wrapper procedure

proc audited_update(collection, query, modification, actor, action) {
    let before = find_one(collection, query)
    if before == null { abort "target not found" }
    update(collection, query, modification)
    let after = find_one(collection, query)
    insert("audit_log", {
        actor: actor, action: action,
        collection: collection, doc_id: before._id,
        before: before, after: after
    })
    return {ok: true}
}

Domain procs use it

proc change_email(user_id, new_email, actor) {
    audited_update({
        collection: "users", query: {_id: user_id},
        modification: {$set: {email: new_email}},
        actor: actor, action: "email_change"
    })
    return {ok: true}
}

proc change_role(user_id, new_role, actor) {
    audited_update({
        collection: "users", query: {_id: user_id},
        modification: {$set: {role: new_role}},
        actor: actor, action: "role_change"
    })
    return {ok: true}
}

Audited delete

proc audited_delete(collection, id, actor) {
    let original = find_one(collection, {_id: id})
    if original == null { abort "not found" }
    delete_one(collection, {_id: id})
    insert("audit_log", {
        actor: actor, action: "delete",
        collection: collection, doc_id: id, before: original
    })
    return {ok: true}
}

Query the log

proc actor_history(actor) {
    return find("audit_log", {actor: actor})
}

proc doc_history(collection, doc_id) {
    return find("audit_log", {collection: collection, doc_id: doc_id})
}

proc recent_actions(action, n) {
    return aggregate("audit_log", [
        {$match: {action: action}},
        {$sort: {ts: -1}},
        {$limit: n}
    ])
}

Compliance summary

proc compliance_summary(year, month) {
    return aggregate("audit_log", [
        {$match: {year: year, month: month}},
        {$group: {_id: "$action", n: {$sum: 1}, actors: {$addToSet: "$actor"}}},
        {$sort: {n: -1}}
    ])
}
Why this works: the audit insert is in the same transaction as the change. There is no path where the data changes but the audit entry is missing.
Report Issue