feat(audit): register the audited event surface

One config-gated block in RegisterListeners maps every opted-in event
to its audit entry. Events with interface-typed doers are decoded via
a small doer ref that distinguishes link shares by their hash field.
This commit is contained in:
kolaente 2026-06-10 21:10:13 +02:00
parent de22af0048
commit 6ab03d3f87
1 changed files with 305 additions and 0 deletions

View File

@ -22,6 +22,7 @@ import (
"strconv"
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
@ -82,6 +83,310 @@ func RegisterListeners() {
// Internal delivery listener — one message per webhook with its own retry lifecycle
events.RegisterListener((&WebhookDeliveryEvent{}).Name(), &WebhookDeliveryListener{})
}
if config.AuditEnabled.GetBool() {
registerEventsForAuditLogging()
}
}
// auditDoerRef decodes the doer of events whose Doer field is an interface
// and thus can't be unmarshaled into the event struct directly.
type auditDoerRef struct {
ID int64 `json:"id"`
Hash string `json:"hash"` // only set when the doer is a link share
}
func auditActorFromDoerRef(d *auditDoerRef) audit.Actor {
if d == nil {
return audit.SystemActor()
}
if d.Hash != "" {
return audit.LinkShareActor(d.ID)
}
return audit.ActorFromDoerID(d.ID)
}
func auditActorFromUser(u *user.User) audit.Actor {
if u == nil {
return audit.SystemActor()
}
return audit.ActorFromDoerID(u.ID)
}
// registerEventsForAuditLogging opts events into audit logging. This block is
// the catalog of the entire audited surface — an event without a registration
// here is not audited.
func registerEventsForAuditLogging() {
// Auth boundary
audit.RegisterEventForAudit(func(e *user.LoginSucceededEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLoginSucceeded,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
}
})
audit.RegisterEventForAudit(func(e *user.LoginFailedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLoginFailed,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
Outcome: audit.OutcomeFailure,
Reason: "wrong password",
}
})
audit.RegisterEventForAudit(func(e *user.LogoutEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionLogout,
Actor: audit.UserActor(e.UserID),
Target: audit.UserTarget(e.UserID),
}
})
audit.RegisterEventForAudit(func(e *APITokenIssuedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenIssued,
Actor: audit.UserActor(e.DoerID),
Target: audit.APITokenTarget(e.TokenID),
Metadata: map[string]any{"owner_id": e.OwnerID},
}
})
audit.RegisterEventForAudit(func(e *APITokenRevokedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenRevoked,
Actor: audit.UserActor(e.DoerID),
Target: audit.APITokenTarget(e.TokenID),
}
})
audit.RegisterEventForAudit(func(e *APITokenUsedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionAPITokenUsed,
Actor: audit.UserActor(e.OwnerID),
Target: audit.APITokenTarget(e.TokenID),
}
})
// Users
audit.RegisterEventForAudit(func(e *user.CreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionUserCreated,
Actor: audit.UserActor(e.User.ID),
Target: audit.UserTarget(e.User.ID),
}
})
// Tasks
audit.RegisterEventForAudit(func(e *TaskCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskUpdatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskUpdated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
}
})
audit.RegisterEventForAudit(func(e *TaskAssigneeCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAssigneeAdded,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"assignee_id": e.Assignee.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAssigneeDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAssigneeRemoved,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"assignee_id": e.Assignee.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentUpdatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentUpdated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskCommentDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskCommentDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"comment_id": e.Comment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAttachmentCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAttachmentCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"attachment_id": e.Attachment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskAttachmentDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskAttachmentDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{"attachment_id": e.Attachment.ID},
}
})
audit.RegisterEventForAudit(func(e *TaskRelationCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskRelationCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{
"other_task_id": e.Relation.OtherTaskID,
"relation_kind": e.Relation.RelationKind,
},
}
})
audit.RegisterEventForAudit(func(e *TaskRelationDeletedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTaskRelationDeleted,
Actor: auditActorFromUser(e.Doer),
Target: audit.TaskTarget(e.Task.ID),
Metadata: map[string]any{
"other_task_id": e.Relation.OtherTaskID,
"relation_kind": e.Relation.RelationKind,
},
}
})
// Projects
audit.RegisterEventForAudit(func(e *ProjectCreatedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionProjectCreated,
Actor: auditActorFromUser(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}
})
audit.RegisterEventNameForAudit((&ProjectUpdatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Project *Project `json:"project"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionProjectUpdated,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}, nil
})
audit.RegisterEventNameForAudit((&ProjectDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Project *Project `json:"project"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionProjectDeleted,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
}, nil
})
audit.RegisterEventNameForAudit((&ProjectSharedWithUserEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Project *Project `json:"project"`
User *user.User `json:"user"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionProjectSharedWithUser,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
Metadata: map[string]any{"user_id": e.User.ID},
}, nil
})
audit.RegisterEventNameForAudit((&ProjectSharedWithTeamEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Project *Project `json:"project"`
Team *Team `json:"team"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionProjectSharedWithTeam,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.ProjectTarget(e.Project.ID),
Metadata: map[string]any{"team_id": e.Team.ID},
}, nil
})
// Teams
audit.RegisterEventNameForAudit((&TeamCreatedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Team *Team `json:"team"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionTeamCreated,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
}, nil
})
audit.RegisterEventNameForAudit((&TeamDeletedEvent{}).Name(), func(payload []byte) (*audit.Entry, error) {
e := &struct {
Team *Team `json:"team"`
Doer *auditDoerRef `json:"doer"`
}{}
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return &audit.Entry{
Action: audit.ActionTeamDeleted,
Actor: auditActorFromDoerRef(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
}, nil
})
audit.RegisterEventForAudit(func(e *TeamMemberAddedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamMemberAdded,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
Metadata: map[string]any{"member_id": e.Member.ID},
}
})
audit.RegisterEventForAudit(func(e *TeamMemberRemovedEvent) *audit.Entry {
return &audit.Entry{
Action: audit.ActionTeamMemberRemoved,
Actor: auditActorFromUser(e.Doer),
Target: audit.TeamTarget(e.Team.ID),
Metadata: map[string]any{"member_id": e.Member.ID},
}
})
}
//////