// Vikunja is a to-do list application to facilitate your life. // Copyright 2018-present Vikunja and contributors. All rights reserved. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package audit persists an audit trail of authentication, authorization and // data lifecycle events as JSONL. // // Events opt in via RegisterEventForAudit, which subscribes one audit // listener per event on the existing watermill bus; the event→Entry mapping // is a closure passed at registration. The catalog of audited events lives in // registerEventsForAuditLogging in pkg/models/listeners.go. // // Entries reference actors and targets by opaque ID only — deleting a user // row orphans their audit references, which satisfies GDPR erasure without // log redaction. // // Audit logging is gated twice: registration on the audit.enabled config key, // and each write on the licensed audit_logs feature. The license is checked // per event because it can change at runtime; enabled-but-unlicensed means // listeners run and write nothing. // // Request attribution (IP, user agent, request id) flows from an Echo // middleware through the request context onto message metadata — see // pkg/events.RequestMeta. Events dispatched outside a request get // source type "system" instead. // // A failed file write is returned to the router for retry. Tamper evidence // comes from filesystem permissions (the file is created 0600) plus shipping // the file to an external system, not from hash chains or signatures. // Rotation is size-based with age-based cleanup of rotated files; retention // is the operator's concern. package audit import "time" // Entry is one audit log record. It only references actors and targets by // opaque ID — no names, emails or content — so GDPR erasure is satisfied by // deleting the referenced row. type Entry struct { EventID string `json:"event_id"` // UUIDv7 Timestamp time.Time `json:"timestamp"` Actor Actor `json:"actor"` Source Source `json:"source"` Action string `json:"action"` Target Target `json:"target"` Outcome string `json:"outcome"` Reason string `json:"reason,omitempty"` RequestID string `json:"request_id,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } type actorType string type targetType string // Actor is the principal which performed the audited action. type Actor struct { Type actorType `json:"type"` ID int64 `json:"id,omitempty"` } // Source describes where the action originated from. type Source struct { Type string `json:"type"` IP string `json:"ip,omitempty"` UserAgent string `json:"user_agent,omitempty"` } // Target is the resource the audited action was performed on. type Target struct { Type targetType `json:"type"` ID int64 `json:"id,omitempty"` } // Outcome values for an Entry. const ( OutcomeSuccess = "success" OutcomeFailure = "failure" ) // Source types for an Entry. const ( SourceHTTP = "http" SourceSystem = "system" ) // The action catalog. Every audited action is listed here. const ( ActionLoginSucceeded = "auth.login.succeeded" ActionLoginFailed = "auth.login.failed" ActionLogout = "auth.logout" ActionAPITokenIssued = "auth.api_token.issued" // #nosec G101 -- action identifier, not a credential ActionAPITokenRevoked = "auth.api_token.revoked" // #nosec G101 ActionAPITokenUsed = "auth.api_token.used" // #nosec G101 ActionUserCreated = "user.created" ActionTaskCreated = "task.created" ActionTaskUpdated = "task.updated" ActionTaskDeleted = "task.deleted" ActionTaskAssigneeAdded = "task.assignee.added" ActionTaskAssigneeRemoved = "task.assignee.removed" ActionTaskCommentCreated = "task.comment.created" ActionTaskCommentUpdated = "task.comment.updated" ActionTaskCommentDeleted = "task.comment.deleted" ActionTaskAttachmentCreated = "task.attachment.created" ActionTaskAttachmentDeleted = "task.attachment.deleted" ActionTaskRelationCreated = "task.relation.created" ActionTaskRelationDeleted = "task.relation.deleted" ActionProjectCreated = "project.created" ActionProjectUpdated = "project.updated" ActionProjectDeleted = "project.deleted" ActionProjectSharedWithUser = "project.shared.user" ActionProjectSharedWithTeam = "project.shared.team" ActionTeamCreated = "team.created" ActionTeamDeleted = "team.deleted" ActionTeamMemberAdded = "team.member.added" ActionTeamMemberRemoved = "team.member.removed" ) // The type strings are unexported; these constructors are the only way to // build an Actor or Target, so a mismatched type/ID pair can't be expressed. func UserActor(id int64) Actor { return Actor{Type: "user", ID: id} } func LinkShareActor(id int64) Actor { return Actor{Type: "link_share", ID: id} } func SystemActor() Actor { return Actor{Type: "system"} } // ActorFromDoerID maps a doer ID to an actor. Link shares are disguised as // users with negative IDs throughout the event payloads. func ActorFromDoerID(id int64) Actor { if id < 0 { return LinkShareActor(-id) } return UserActor(id) } func TaskTarget(id int64) Target { return Target{Type: "task", ID: id} } func ProjectTarget(id int64) Target { return Target{Type: "project", ID: id} } func UserTarget(id int64) Target { return Target{Type: "user", ID: id} } func TeamTarget(id int64) Target { return Target{Type: "team", ID: id} } func APITokenTarget(id int64) Target { return Target{Type: "api_token", ID: id} }