Compare commits

...

11 Commits

Author SHA1 Message Date
kolaente ab038ec6c4 feat(audit): forward audit entries to stdout, syslog and webhook sinks
Adds the audit.forwarders config list and a Sink interface with three
implementations: stdout, RFC 5424 syslog over UDP/TCP (hand-rolled —
stdlib log/syslog only emits RFC 3164 and does not build on Windows),
and webhook POSTs through the SSRF-safe HTTP client. Forwarders are
best-effort fan-out: the local file stays the source of truth and a
dead sink is logged instead of poison-queueing every event.
2026-06-10 22:22:20 +02:00
kolaente ae908be716 fix: dispatch pending events after user creation commits
The register handler, local/LDAP login and the OIDC callback all queue
the user.created event via DispatchOnCommit but never called
DispatchPending, so the event was silently dropped and its queue entry
leaked. Flush after commit and discard on rollback.
2026-06-10 22:20:59 +02:00
kolaente 126ea78dac refactor(events): pass context to DispatchPending directly
Every DispatchPending caller either has the request context in scope or
is genuinely request-less, so passing it as a parameter replaces the
stored-context mechanism on the pending queue and satisfies
contextcheck. Also fixes lint findings in the audit package.
2026-06-10 22:19:04 +02:00
kolaente 3fc5813888 docs(audit): add package documentation 2026-06-10 22:18:50 +02:00
kolaente 3d8c259242 test(audit): cover listener pipeline, license gating and rotation 2026-06-10 22:18:11 +02:00
kolaente 6ab03d3f87 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.
2026-06-10 22:18:11 +02:00
kolaente de22af0048 feat(events): add auth boundary events
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.

None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
2026-06-10 22:18:11 +02:00
kolaente 4ff8181a47 feat(audit): wire request-meta middleware and writer initialization 2026-06-10 22:18:11 +02:00
kolaente 939daaf1ab feat(audit): add audit logging package
Entry schema with constructor-enforced actor/target types, a generic
RegisterEventForAudit helper that maps opted-in events to entries on
the existing watermill bus (license-gated per event since licenses are
runtime-mutable), and a JSONL writer with size-based rotation,
age-based cleanup of rotated files and batched fsync.
2026-06-10 22:18:11 +02:00
kolaente a4bbd02d6a feat(config): add audit logging config keys 2026-06-10 22:17:42 +02:00
kolaente 5db25ab75c feat(events): carry request metadata onto dispatched event messages
Adds a RequestMeta context bridge so events dispatched during an HTTP
request can be attributed to it: a middleware stashes IP/UA/request-id
on the request context, the generic Do* handlers associate that context
with the transaction key, and DispatchPending/DispatchWithContext copy
the metadata onto the watermill message at publish time. Existing
dispatch call sites are unchanged.
2026-06-10 21:00:41 +02:00
35 changed files with 1717 additions and 34 deletions

View File

@ -997,6 +997,41 @@
}
]
},
{
"key": "audit",
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
"children": [
{
"key": "enabled",
"default_value": "false",
"comment": "Whether to enable audit logging."
},
{
"key": "logfile",
"default_value": "",
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
},
{
"key": "rotation",
"children": [
{
"key": "maxsizemb",
"default_value": "100",
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
},
{
"key": "maxage",
"default_value": "30",
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
}
]
},
{
"key": "forwarders",
"comment": "A list of sinks to forward each audit entry to, in addition to the local logfile. Each entry needs a `type` of `stdout`, `syslog` or `webhook`. `syslog` requires `address` (e.g. `udp://logs.example.com:514`) and accepts an optional `facility` (default `local0`). `webhook` requires `url` and accepts an optional `headers` map sent with each request.\nExample:\n\n```yaml\nforwarders:\n- type: stdout\n- type: syslog\n address: udp://logs.example.com:514\n facility: local0\n- type: webhook\n url: https://siem.example.com/ingest\n headers:\n Authorization: Bearer something\n```"
}
]
},
{
"key": "outgoingrequests",
"children": [

254
pkg/audit/audit_test.go Normal file
View File

@ -0,0 +1,254 @@
// 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 <https://www.gnu.org/licenses/>.
package audit_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) {
log.InitLogger()
config.InitDefaultConfig()
keyvalue.InitStorage() // license.SetForTests persists state through keyvalue
os.Exit(m.Run())
}
// One event type per test so each topic has exactly the listeners the test registered.
type pipelineEvent struct {
TaskID int64 `json:"task_id"`
DoerID int64 `json:"doer_id"`
}
func (e *pipelineEvent) Name() string { return "test.audit.pipeline" }
type licenseGateEvent struct {
Marker string `json:"marker"`
}
func (e *licenseGateEvent) Name() string { return "test.audit.licensegate" }
type rotationEvent struct {
Filler string `json:"filler"`
}
func (e *rotationEvent) Name() string { return "test.audit.rotation" }
// otherListener is a second, non-audit listener on the same topic.
type otherListener struct {
called chan struct{}
}
func (l *otherListener) Handle(_ *message.Message) error {
select {
case l.called <- struct{}{}:
default:
}
return nil
}
func (l *otherListener) Name() string { return "other" }
var (
registerTestEventsOnce sync.Once
other = &otherListener{called: make(chan struct{}, 16)}
)
// The listener registry is global and watermill rejects duplicate handler
// names, so register once per process (relevant for -count > 1).
func registerTestEvents() {
registerTestEventsOnce.Do(func() {
audit.RegisterEventForAudit(func(e *pipelineEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.UserActor(e.DoerID),
Target: audit.TaskTarget(e.TaskID),
}
})
events.RegisterListener((&pipelineEvent{}).Name(), other)
audit.RegisterEventForAudit(func(e *licenseGateEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.SystemActor(),
Target: audit.TaskTarget(1),
Metadata: map[string]any{"marker": e.Marker},
}
})
audit.RegisterEventForAudit(func(e *rotationEvent) *audit.Entry {
return &audit.Entry{
Action: "task.created",
Actor: audit.SystemActor(),
Target: audit.TaskTarget(1),
Metadata: map[string]any{"filler": e.Filler},
}
})
})
}
func setupAuditFile(t *testing.T) string {
t.Helper()
logfile := filepath.Join(t.TempDir(), "audit.log")
config.AuditLogfile.Set(logfile)
require.NoError(t, audit.Init())
t.Cleanup(audit.Close)
return logfile
}
func startEventRouter(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ready, err := events.InitEventsForTesting(ctx)
require.NoError(t, err)
<-ready
}
func waitForLines(t *testing.T, logfile string) []string {
t.Helper()
var lines []string
require.Eventually(t, func() bool {
content, err := os.ReadFile(logfile)
if err != nil {
return false
}
lines = strings.Split(strings.TrimSpace(string(content)), "\n")
if len(lines) == 1 && lines[0] == "" {
lines = nil
}
return len(lines) >= 1
}, 5*time.Second, 10*time.Millisecond, "expected at least one audit log line")
return lines
}
func TestAuditPipeline(t *testing.T) {
logfile := setupAuditFile(t)
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
registerTestEvents()
startEventRouter(t)
ctx := events.WithRequestMeta(context.Background(), &events.RequestMeta{
IP: "192.0.2.42",
UserAgent: "test-agent/1.0",
RequestID: "req-123",
})
require.NoError(t, events.DispatchWithContext(ctx, &pipelineEvent{TaskID: 99, DoerID: 7}))
waitForLines(t, logfile)
select {
case <-other.called:
case <-time.After(5 * time.Second):
t.Fatal("other listener on the same topic was not called")
}
// A topic with multiple listeners must produce exactly one audit entry.
events.WaitForPendingHandlers()
lines := waitForLines(t, logfile)
require.Len(t, lines, 1)
var entry audit.Entry
require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry))
assert.NotEmpty(t, entry.EventID)
assert.False(t, entry.Timestamp.IsZero())
assert.Equal(t, "task.created", entry.Action)
assert.Equal(t, audit.UserActor(7), entry.Actor)
assert.Equal(t, audit.TaskTarget(99), entry.Target)
assert.Equal(t, audit.OutcomeSuccess, entry.Outcome)
assert.Equal(t, "192.0.2.42", entry.Source.IP)
assert.Equal(t, "test-agent/1.0", entry.Source.UserAgent)
assert.Equal(t, audit.SourceHTTP, entry.Source.Type)
assert.Equal(t, "req-123", entry.RequestID)
}
func TestAuditLicenseGating(t *testing.T) {
logfile := setupAuditFile(t)
registerTestEvents()
startEventRouter(t)
// Without the licensed feature nothing must be written. The license check
// happens per event at handle time, so give the async handler a settle
// window before flipping the license back on.
license.ResetForTests()
require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "unlicensed"}))
require.Never(t, func() bool {
content, err := os.ReadFile(logfile)
return err == nil && len(content) > 0
}, 500*time.Millisecond, 10*time.Millisecond, "unlicensed event must not be written")
events.WaitForPendingHandlers()
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
require.NoError(t, events.Dispatch(&licenseGateEvent{Marker: "licensed"}))
lines := waitForLines(t, logfile)
require.Len(t, lines, 1)
assert.Contains(t, lines[0], `"marker":"licensed"`)
assert.NotContains(t, lines[0], "unlicensed")
assert.Contains(t, lines[0], `"type":"system"`)
}
func TestAuditRotation(t *testing.T) {
logfile := setupAuditFile(t)
license.SetForTests([]license.Feature{license.FeatureAuditLogs})
t.Cleanup(license.ResetForTests)
registerTestEvents()
startEventRouter(t)
// Default max size is 100MB and config values are MB-granular, so two
// entries of ~600KB cross the limit with maxsizemb set to 1.
config.AuditRotationMaxSizeMB.Set("1")
t.Cleanup(func() { config.AuditRotationMaxSizeMB.Set("100") })
require.NoError(t, audit.Init())
filler := strings.Repeat("x", 600*1024)
require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler}))
waitForLines(t, logfile)
require.NoError(t, events.Dispatch(&rotationEvent{Filler: filler}))
waitForLines(t, logfile)
require.Eventually(t, func() bool {
rotated, err := filepath.Glob(strings.TrimSuffix(logfile, ".log") + "-*.log")
return err == nil && len(rotated) == 1
}, 5*time.Second, 10*time.Millisecond, "expected one rotated audit log file")
}
func TestWriteAuditEventNotInitialized(t *testing.T) {
audit.Close()
err := audit.WriteAuditEvent(&audit.Entry{Action: "task.created"})
require.Error(t, err)
}

46
pkg/audit/doc.go Normal file
View File

@ -0,0 +1,46 @@
// 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 <https://www.gnu.org/licenses/>.
// Package audit persists an audit trail of authentication, authorization and
// data lifecycle events as JSONL, with optional forwarding to stdout, syslog
// or webhook sinks.
//
// 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.
//
// The local file is the source of truth: a failed file write is returned to
// the router for retry, while forwarder failures are only logged so a dead
// sink cannot poison-queue every event. Tamper evidence comes from filesystem
// permissions (the file is created 0600) plus shipping entries to an external
// sink, 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

127
pkg/audit/entry.go Normal file
View File

@ -0,0 +1,127 @@
// 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 <https://www.gnu.org/licenses/>.
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} }

88
pkg/audit/listener.go Normal file
View File

@ -0,0 +1,88 @@
// 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 <https://www.gnu.org/licenses/>.
package audit
import (
"encoding/json"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/license"
"github.com/ThreeDotsLabs/watermill/message"
)
type auditListener struct {
handle func(msg *message.Message) error
}
func (l *auditListener) Handle(msg *message.Message) error {
return l.handle(msg)
}
func (l *auditListener) Name() string {
return "audit"
}
// RegisterEventForAudit opts an event into audit logging. The event→Entry
// mapping is passed at registration, so opting in and defining the mapping
// are one unit and can't drift apart. Returning a nil Entry skips the event.
func RegisterEventForAudit[T any, PT interface {
*T
events.Event
}](toEntry func(PT) *Entry) {
name := PT(new(T)).Name()
RegisterEventNameForAudit(name, func(payload []byte) (*Entry, error) {
e := PT(new(T)) // fresh instance per message — handlers run concurrently
if err := json.Unmarshal(payload, e); err != nil {
return nil, err
}
return toEntry(e), nil
})
}
// RegisterEventNameForAudit is the untyped variant for events which cannot be
// unmarshaled into their Go struct directly (e.g. interface-typed Doer
// fields); the mapping decodes the raw payload itself.
func RegisterEventNameForAudit(name string, toEntry func(payload []byte) (*Entry, error)) {
events.RegisterListener(name, &auditListener{handle: func(msg *message.Message) error {
if !license.IsFeatureEnabled(license.FeatureAuditLogs) {
return nil // license is runtime-mutable — checked per event, not at registration
}
entry, err := toEntry(msg.Payload)
if err != nil {
return err
}
if entry == nil {
return nil
}
enrichFromMetadata(entry, msg.Metadata)
return WriteAuditEvent(entry)
}})
}
func enrichFromMetadata(entry *Entry, meta message.Metadata) {
entry.Source.IP = meta.Get(events.MetadataKeyIP)
entry.Source.UserAgent = meta.Get(events.MetadataKeyUserAgent)
entry.RequestID = meta.Get(events.MetadataKeyRequestID)
if entry.Source.Type == "" {
if entry.Source.IP != "" || entry.Source.UserAgent != "" {
entry.Source.Type = SourceHTTP
} else {
entry.Source.Type = SourceSystem
}
}
}

24
pkg/audit/sinks/sink.go Normal file
View File

@ -0,0 +1,24 @@
// 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 <https://www.gnu.org/licenses/>.
// Package sinks contains the forwarding targets for audit log entries.
package sinks
// Sink forwards a single audit entry, passed as its serialized JSON line
// without a trailing newline. Implementations must be safe for concurrent use.
type Sink interface {
Write(line []byte) error
}

44
pkg/audit/sinks/stdout.go Normal file
View File

@ -0,0 +1,44 @@
// 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 <https://www.gnu.org/licenses/>.
package sinks
import (
"io"
"os"
"sync"
)
// Stdout writes each entry as one line to standard output.
type Stdout struct {
mu sync.Mutex
// out exists so tests can capture the output.
out io.Writer
}
func NewStdout() *Stdout {
return &Stdout{out: os.Stdout}
}
func (s *Stdout) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, err := s.out.Write(line); err != nil {
return err
}
_, err := s.out.Write([]byte{'\n'})
return err
}

116
pkg/audit/sinks/syslog.go Normal file
View File

@ -0,0 +1,116 @@
// 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 <https://www.gnu.org/licenses/>.
package sinks
import (
"context"
"fmt"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
)
// Hand-rolled RFC 5424 instead of log/syslog: the stdlib package only emits
// the older RFC 3164 format and does not build on Windows.
type Syslog struct {
network string
address string
facility int
hostname string
procid string
mu sync.Mutex
conn net.Conn
}
var syslogFacilities = map[string]int{
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5,
"lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
}
// NewSyslog creates a syslog sink. The address has the form
// udp://host:port or tcp://host:port; the scheme defaults to udp.
func NewSyslog(address, facility string) (*Syslog, error) {
if address == "" {
return nil, fmt.Errorf("syslog forwarder requires an address")
}
if !strings.Contains(address, "://") {
address = "udp://" + address
}
u, err := url.Parse(address)
if err != nil {
return nil, fmt.Errorf("invalid syslog address %q: %w", address, err)
}
if u.Scheme != "udp" && u.Scheme != "tcp" {
return nil, fmt.Errorf("unsupported syslog scheme %q, must be udp or tcp", u.Scheme)
}
if facility == "" {
facility = "local0"
}
facilityCode, ok := syslogFacilities[strings.ToLower(facility)]
if !ok {
return nil, fmt.Errorf("unknown syslog facility %q", facility)
}
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "-"
}
return &Syslog{
network: u.Scheme,
address: u.Host,
facility: facilityCode,
hostname: hostname,
procid: fmt.Sprintf("%d", os.Getpid()),
}, nil
}
func (s *Syslog) Write(line []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(context.Background(), s.network, s.address)
if err != nil {
return fmt.Errorf("could not connect to syslog at %s://%s: %w", s.network, s.address, err)
}
s.conn = conn
}
pri := s.facility*8 + 6 // severity: informational
frame := fmt.Sprintf("<%d>1 %s %s vikunja %s audit - %s",
pri, time.Now().UTC().Format(time.RFC3339Nano), s.hostname, s.procid, line)
if s.network == "tcp" {
frame += "\n" // RFC 6587 non-transparent framing
}
if _, err := s.conn.Write([]byte(frame)); err != nil {
// Drop the connection so the next write redials.
_ = s.conn.Close()
s.conn = nil
return err
}
return nil
}

View File

@ -0,0 +1,69 @@
// 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 <https://www.gnu.org/licenses/>.
package sinks
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"code.vikunja.io/api/pkg/utils"
)
// Webhook POSTs each entry as a JSON body to a fixed URL.
type Webhook struct {
url string
headers map[string]string
client *http.Client
}
func NewWebhook(url string, headers map[string]string) (*Webhook, error) {
if url == "" {
return nil, fmt.Errorf("webhook forwarder requires a url")
}
return &Webhook{
url: url,
headers: headers,
client: utils.NewSSRFSafeHTTPClient(),
}, nil
}
func (w *Webhook) Write(line []byte) error {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, w.url, bytes.NewReader(line))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Vikunja/audit")
for key, value := range w.headers {
req.Header.Set(key, value)
}
resp, err := w.client.Do(req) // #nosec G704 -- URL is the operator-configured sink target; the SSRF-safe client enforces IP restrictions
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("audit webhook %s returned status %d", w.url, resp.StatusCode)
}
return nil
}

281
pkg/audit/writer.go Normal file
View File

@ -0,0 +1,281 @@
// 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 <https://www.gnu.org/licenses/>.
package audit
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"code.vikunja.io/api/pkg/audit/sinks"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"github.com/google/uuid"
)
var (
mu sync.Mutex
initialized bool
logFile *os.File
logfilePath string
currentSize int64
maxSizeBytes int64
maxAge time.Duration
lastSync time.Time
forwarders []sinks.Sink
)
// Init opens the audit log file and sets up the configured forwarders.
// Safe to call again to re-read the config (used by tests).
func Init() error {
mu.Lock()
defer mu.Unlock()
closeLocked()
logfilePath = config.AuditLogfile.GetString()
if logfilePath == "" {
logfilePath = filepath.Join(config.LogPath.GetString(), "audit.log")
}
maxSizeBytes = config.AuditRotationMaxSizeMB.GetInt64() * 1024 * 1024
maxAge = time.Duration(config.AuditRotationMaxAge.GetInt64()) * 24 * time.Hour
if err := os.MkdirAll(filepath.Dir(logfilePath), 0750); err != nil {
return fmt.Errorf("could not create audit log directory: %w", err)
}
if err := openLogFileLocked(); err != nil {
return err
}
var err error
forwarders, err = buildForwarders(config.AuditForwarders.Get())
if err != nil {
closeLocked()
return err
}
initialized = true
return nil
}
// Close closes the audit log file. Used by tests.
func Close() {
mu.Lock()
defer mu.Unlock()
closeLocked()
}
func closeLocked() {
if logFile != nil {
_ = logFile.Sync()
_ = logFile.Close()
logFile = nil
}
forwarders = nil
initialized = false
}
func openLogFileLocked() error {
f, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("could not open audit log file %s: %w", logfilePath, err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return fmt.Errorf("could not stat audit log file %s: %w", logfilePath, err)
}
logFile = f
currentSize = info.Size()
return nil
}
func buildForwarders(raw any) (built []sinks.Sink, err error) {
if raw == nil {
return nil, nil
}
rawList, ok := raw.([]any)
if !ok {
return nil, fmt.Errorf("audit.forwarders must be a list, got %T", raw)
}
for i, rawEntry := range rawList {
entry, ok := toStringMap(rawEntry)
if !ok {
return nil, fmt.Errorf("audit.forwarders[%d] must be a map", i)
}
var sink sinks.Sink
typ, _ := entry["type"].(string)
switch typ {
case "stdout":
sink = sinks.NewStdout()
case "syslog":
address, _ := entry["address"].(string)
facility, _ := entry["facility"].(string)
sink, err = sinks.NewSyslog(address, facility)
case "webhook":
targetURL, _ := entry["url"].(string)
headers := map[string]string{}
if rawHeaders, ok := toStringMap(entry["headers"]); ok {
for key, value := range rawHeaders {
headers[key], _ = value.(string)
}
}
sink, err = sinks.NewWebhook(targetURL, headers)
default:
return nil, fmt.Errorf("audit.forwarders[%d] has unknown type %q", i, typ)
}
if err != nil {
return nil, fmt.Errorf("audit.forwarders[%d]: %w", i, err)
}
built = append(built, sink)
}
return built, nil
}
// toStringMap normalizes the two map shapes viper produces depending on the
// config source (file vs. programmatic Set).
func toStringMap(raw any) (map[string]any, bool) {
switch m := raw.(type) {
case map[string]any:
return m, true
case map[any]any:
out := make(map[string]any, len(m))
for key, value := range m {
keyStr, ok := key.(string)
if !ok {
return nil, false
}
out[keyStr] = value
}
return out, true
}
return nil, false
}
// WriteAuditEvent writes one entry to the local audit log and forwards it to
// all configured sinks. The local write is the source of truth — its failure
// is returned so the event router retries; forwarder failures are only
// logged, since a dead sink must not poison-queue every event.
func WriteAuditEvent(entry *Entry) error {
if entry.EventID == "" {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("could not generate audit event id: %w", err)
}
entry.EventID = id.String()
}
if entry.Timestamp.IsZero() {
entry.Timestamp = time.Now().UTC()
}
if entry.Outcome == "" {
entry.Outcome = OutcomeSuccess
}
line, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("could not marshal audit entry: %w", err)
}
mu.Lock()
if !initialized {
mu.Unlock()
return fmt.Errorf("audit log not initialized")
}
if err := rotateIfNeededLocked(int64(len(line)) + 1); err != nil {
mu.Unlock()
return err
}
written, err := logFile.Write(append(line, '\n'))
currentSize += int64(written)
if err == nil && time.Since(lastSync) > time.Second {
err = logFile.Sync()
lastSync = time.Now()
}
currentForwarders := forwarders
mu.Unlock()
if err != nil {
return fmt.Errorf("could not write audit entry: %w", err)
}
for _, forwarder := range currentForwarders {
if ferr := forwarder.Write(line); ferr != nil {
log.Errorf("Could not forward audit entry %s: %s", entry.EventID, ferr)
}
}
return nil
}
func rotateIfNeededLocked(addition int64) error {
if maxSizeBytes <= 0 || currentSize+addition <= maxSizeBytes {
return nil
}
_ = logFile.Sync()
_ = logFile.Close()
logFile = nil
rotatedPath := rotatedFileName(logfilePath, time.Now().UTC())
if err := os.Rename(logfilePath, rotatedPath); err != nil {
// Reopen the original so logging continues even if rotation failed.
_ = openLogFileLocked()
return fmt.Errorf("could not rotate audit log: %w", err)
}
cleanupRotatedFiles()
return openLogFileLocked()
}
func rotatedFileName(path string, now time.Time) string {
ext := filepath.Ext(path)
return strings.TrimSuffix(path, ext) + "-" + now.Format("20060102T150405.000") + ext
}
func cleanupRotatedFiles() {
if maxAge <= 0 {
return
}
ext := filepath.Ext(logfilePath)
pattern := strings.TrimSuffix(logfilePath, ext) + "-*" + ext
matches, err := filepath.Glob(pattern)
if err != nil {
log.Errorf("Could not list rotated audit log files: %s", err)
return
}
cutoff := time.Now().Add(-maxAge)
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.ModTime().After(cutoff) {
continue
}
if err := os.Remove(match); err != nil {
log.Errorf("Could not remove old audit log file %s: %s", match, err)
}
}
}

View File

@ -220,6 +220,12 @@ const (
WebhooksProxyPassword Key = `webhooks.proxypassword`
WebhooksAllowNonRoutableIPs Key = `webhooks.allownonroutableips`
AuditEnabled Key = `audit.enabled`
AuditLogfile Key = `audit.logfile`
AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb`
AuditRotationMaxAge Key = `audit.rotation.maxage`
AuditForwarders Key = `audit.forwarders`
OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips`
OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl`
OutgoingRequestsProxyPassword Key = `outgoingrequests.proxypassword`
@ -483,6 +489,11 @@ func InitDefaultConfig() {
WebhooksEnabled.setDefault(true)
WebhooksTimeoutSeconds.setDefault(30)
WebhooksAllowNonRoutableIPs.setDefault(false)
// Audit
AuditEnabled.setDefault(false)
AuditLogfile.setDefault("") // empty means <log.path>/audit.log, resolved at init
AuditRotationMaxSizeMB.setDefault(100)
AuditRotationMaxAge.setDefault(30)
// Outgoing Requests
OutgoingRequestsAllowNonRoutableIPs.setDefault(false)
OutgoingRequestsTimeoutSeconds.setDefault(30)

View File

@ -201,6 +201,13 @@ func InitEventsForTesting(ctx context.Context) (<-chan struct{}, error) {
// Dispatch dispatches an event
func Dispatch(event Event) error {
return DispatchWithContext(context.Background(), event)
}
// DispatchWithContext dispatches an event and copies request metadata from the
// context (see WithRequestMeta) onto the message metadata, so listeners can
// attribute the event to the originating HTTP request.
func DispatchWithContext(ctx context.Context, event Event) error {
if isUnderTest {
dispatchedTestEvents = append(dispatchedTestEvents, event)
return nil
@ -216,6 +223,17 @@ func Dispatch(event Event) error {
}
msg := message.NewMessage(watermill.NewUUID(), content)
if meta := RequestMetaFromContext(ctx); meta != nil {
if meta.IP != "" {
msg.Metadata.Set(MetadataKeyIP, meta.IP)
}
if meta.UserAgent != "" {
msg.Metadata.Set(MetadataKeyUserAgent, meta.UserAgent)
}
if meta.RequestID != "" {
msg.Metadata.Set(MetadataKeyRequestID, meta.RequestID)
}
}
return pubsub.Publish(event.Name(), msg)
}
@ -241,8 +259,9 @@ func DispatchOnCommit(key any, event Event) {
// DispatchPending dispatches all events accumulated for the given key and removes them.
// Call this after s.Commit(). Safe to call even if no events were registered.
// Request metadata on the context (see WithRequestMeta) is copied onto each message.
// If any event fails to dispatch, the error is logged but remaining events are still dispatched.
func DispatchPending(key any) {
func DispatchPending(ctx context.Context, key any) {
val, ok := pendingEvents.LoadAndDelete(key)
if !ok {
return
@ -251,7 +270,7 @@ func DispatchPending(key any) {
// No need to lock here since we've already removed it from the map
// and this key won't receive new events
for _, event := range queue.events {
if err := Dispatch(event); err != nil {
if err := DispatchWithContext(ctx, event); err != nil {
log.Errorf("Failed to dispatch event %s: %v", event.Name(), err)
}
}

View File

@ -17,6 +17,7 @@
package events
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
@ -40,7 +41,7 @@ func TestDispatchOnCommit(t *testing.T) {
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
// Simulate post-commit dispatch
DispatchPending(key)
DispatchPending(context.Background(), key)
// Now it should be dispatched
assert.Equal(t, 1, CountDispatchedEvents("test.event"))
@ -57,7 +58,7 @@ func TestDispatchOnCommitMultipleEvents(t *testing.T) {
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
DispatchPending(key)
DispatchPending(context.Background(), key)
assert.Equal(t, 3, CountDispatchedEvents("test.event"))
}
@ -74,7 +75,7 @@ func TestCleanupPending(t *testing.T) {
CleanupPending(key)
// Dispatching after cleanup should be a no-op
DispatchPending(key)
DispatchPending(context.Background(), key)
assert.Equal(t, 0, CountDispatchedEvents("test.event"))
}
@ -85,7 +86,7 @@ func TestDispatchPendingNoEvents(t *testing.T) {
key := new(int)
// Should be a no-op
DispatchPending(key)
DispatchPending(context.Background(), key)
// Verify no events were dispatched
assert.Equal(t, 0, CountDispatchedEvents("test.event"))

View File

@ -0,0 +1,55 @@
// 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 <https://www.gnu.org/licenses/>.
package events
import "context"
// RequestMeta carries information about the originating HTTP request. It is
// stashed on the request context by a middleware and copied onto message
// metadata at publish time, so listeners (e.g. audit) can attribute an event
// to a request without every dispatch site changing its signature.
type RequestMeta struct {
IP string
UserAgent string
RequestID string
}
// Message metadata keys holding request information.
const (
MetadataKeyIP = "request_ip"
MetadataKeyUserAgent = "request_user_agent"
MetadataKeyRequestID = "request_id"
)
type requestMetaKeyType struct{}
var requestMetaKey requestMetaKeyType
// WithRequestMeta returns a context carrying the given request metadata.
func WithRequestMeta(ctx context.Context, meta *RequestMeta) context.Context {
return context.WithValue(ctx, requestMetaKey, meta)
}
// RequestMetaFromContext returns the request metadata stored on the context,
// or nil if there is none.
func RequestMetaFromContext(ctx context.Context) *RequestMeta {
if ctx == nil {
return nil
}
meta, _ := ctx.Value(requestMetaKey).(*RequestMeta)
return meta
}

View File

@ -19,6 +19,7 @@ package initialize
import (
"time"
"code.vikunja.io/api/pkg/audit"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/db"
@ -98,6 +99,12 @@ func FullInitWithoutAsync() {
// See the package comment in pkg/license/license.go before removing.
license.Init()
if config.AuditEnabled.GetBool() {
if err := audit.Init(); err != nil {
log.Fatalf("Could not initialize audit logging: %s", err)
}
}
// Start the mail daemon
mail.StartMailDaemon()

View File

@ -24,6 +24,7 @@ import (
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web"
@ -121,7 +122,17 @@ func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) {
}
_, err = s.Insert(t)
return err
if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenIssuedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
OwnerID: t.OwnerID,
})
return nil
}
func HashToken(token, salt string) string {
@ -192,10 +203,19 @@ func (t *APIToken) ReadAll(s *xorm.Session, a web.Auth, search string, page int,
// @Failure 404 {object} web.HTTPError "The token does not exist."
// @Failure 500 {object} models.Message "Internal error"
// @Router /tokens/{tokenID} [delete]
func (t *APIToken) Delete(s *xorm.Session, _ web.Auth) (err error) {
func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) {
// Ownership is verified in CanDelete; delete by ID only.
_, err = s.Where("id = ?", t.ID).Delete(&APIToken{})
return err
if err != nil {
return err
}
events.DispatchOnCommit(s, &APITokenRevokedEvent{
TokenID: t.ID,
DoerID: a.GetID(),
})
return nil
}
// HasCaldavAccess checks whether the token has the caldav access permission.

View File

@ -395,3 +395,44 @@ type TimeEntryDeletedEvent struct {
func (e *TimeEntryDeletedEvent) Name() string {
return "time-entry.deleted"
}
////////////////////
// API Token Events
// API token events carry IDs only: the freshly created token struct holds the
// raw token string, which must never end up in a message payload (the poison
// queue logs payloads on handler failure).
// APITokenIssuedEvent represents an API token being created
type APITokenIssuedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenIssuedEvent
func (e *APITokenIssuedEvent) Name() string {
return "api-token.issued"
}
// APITokenRevokedEvent represents an API token being deleted
type APITokenRevokedEvent struct {
TokenID int64 `json:"token_id"`
DoerID int64 `json:"doer_id"`
}
// Name defines the name for APITokenRevokedEvent
func (e *APITokenRevokedEvent) Name() string {
return "api-token.revoked"
}
// APITokenUsedEvent represents an API token authenticating a request
type APITokenUsedEvent struct {
TokenID int64 `json:"token_id"`
OwnerID int64 `json:"owner_id"`
}
// Name defines the name for APITokenUsedEvent
func (e *APITokenUsedEvent) Name() string {
return "api-token.used"
}

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},
}
})
}
//////

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"fmt"
"testing"
@ -45,7 +46,7 @@ func TestTaskComment_Create(t *testing.T) {
assert.Equal(t, int64(1), tc.Author.ID)
err = s.Commit()
require.NoError(t, err)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TaskCommentCreatedEvent{})
db.AssertExists(t, "task_comments", map[string]interface{}{

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"testing"
"time"
@ -70,7 +71,7 @@ func TestTask_Create(t *testing.T) {
"bucket_id": 1,
}, false)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TaskCreatedEvent{})
})
t.Run("with reminders", func(t *testing.T) {
@ -280,7 +281,7 @@ func TestTask_Update(t *testing.T) {
err = s.Commit()
require.NoError(t, err)
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
// Verify exactly ONE task.updated event was dispatched
count := events.CountDispatchedEvents("task.updated")
assert.Equal(t, 1, count, "Expected exactly 1 task.updated event, got %d", count)

View File

@ -17,6 +17,7 @@
package models
import (
"context"
"encoding/json"
"testing"
"time"
@ -596,7 +597,7 @@ func TestTimeEntry_Events(t *testing.T) {
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
require.NoError(t, te.Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
})
@ -612,7 +613,7 @@ func TestTimeEntry_Events(t *testing.T) {
require.True(t, can)
require.NoError(t, te.Update(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
@ -624,7 +625,7 @@ func TestTimeEntry_Events(t *testing.T) {
require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryDeletedEvent{})
})
@ -637,7 +638,7 @@ func TestTimeEntry_Events(t *testing.T) {
// entry 4 is user1's running timer; a new running timer auto-stops it
require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
@ -651,7 +652,7 @@ func TestTimeEntry_Events(t *testing.T) {
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
require.NoError(t, te.Create(s, u))
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name()))
assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop")
})
@ -665,7 +666,7 @@ func TestTimeEntry_Events(t *testing.T) {
_, err := StopRunningTimer(s, u)
require.NoError(t, err)
require.NoError(t, s.Commit())
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
})
}

View File

@ -26,6 +26,8 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/humaecho5"
"code.vikunja.io/api/pkg/user"
@ -123,6 +125,10 @@ func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
return err
}
if err := events.DispatchWithContext(c.Request().Context(), &user.LoginSucceededEvent{User: u}); err != nil {
log.Errorf("Could not dispatch login succeeded event: %s", err)
}
// Set the refresh token as an HttpOnly cookie. The cookie is path-scoped
// to the refresh endpoint, so the browser only sends it there. JavaScript
// never sees the refresh token — this protects it from XSS.

View File

@ -27,6 +27,7 @@ import (
"strings"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -187,6 +188,9 @@ func HandleCallback(c *echo.Context) error {
s := db.NewSession()
defer s.Close()
// Discards events queued during a rolled-back transaction (e.g. user
// creation); a no-op once DispatchPending has run.
defer events.CleanupPending(s)
// Check if we have seen this user before
u, err := getOrCreateUser(s, cl, provider, idToken)
@ -212,6 +216,9 @@ func HandleCallback(c *echo.Context) error {
if err := enforceTOTPIfRequired(s, u, cb.TOTPPasscode); err != nil {
if commitErr := s.Commit(); commitErr != nil {
log.Errorf("Error committing session after failed OIDC TOTP attempt for user %d: %v", u.ID, commitErr)
} else {
// The user creation above was committed, so its events are real.
events.DispatchPending(c.Request().Context(), s)
}
if user.IsErrInvalidTOTPPasscode(err) {
user.HandleFailedTOTPAuth(u)
@ -233,6 +240,8 @@ func HandleCallback(c *echo.Context) error {
return err
}
events.DispatchPending(c.Request().Context(), s)
// Create token
return auth.NewUserAuthTokenResponse(u, c, false)
}

View File

@ -18,6 +18,7 @@ package migration
import (
"bytes"
"context"
"xorm.io/xorm"
@ -50,7 +51,7 @@ func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.Us
return err
}
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
return nil
}

View File

@ -21,6 +21,8 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/auth/ldap"
@ -51,6 +53,9 @@ func Login(c *echo.Context) (err error) {
s := db.NewSession()
defer s.Close()
// Discards events queued during a rolled-back transaction (e.g. LDAP user
// creation); a no-op once DispatchPending has run.
defer events.CleanupPending(s)
var user *user2.User
if config.AuthLdapEnabled.GetBool() {
@ -125,6 +130,8 @@ func Login(c *echo.Context) (err error) {
return err
}
events.DispatchPending(c.Request().Context(), s)
// Create token
return auth.NewUserAuthTokenResponse(user, c, u.LongToken)
}
@ -231,10 +238,14 @@ func Logout(c *echo.Context) (err error) {
auth.ClearRefreshTokenCookie(c)
var sid string
var userID int64
if raw := c.Get("user"); raw != nil {
if jwtinf, ok := raw.(*jwt.Token); ok {
if claims, ok := jwtinf.Claims.(jwt.MapClaims); ok {
sid, _ = claims["sid"].(string)
if id, ok := claims["id"].(float64); ok {
userID = int64(id)
}
}
}
}
@ -257,5 +268,11 @@ func Logout(c *echo.Context) (err error) {
return err
}
if userID != 0 {
if err := events.DispatchWithContext(c.Request().Context(), &user2.LogoutEvent{UserID: userID}); err != nil {
log.Errorf("Could not dispatch logout event: %s", err)
}
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
}

View File

@ -97,7 +97,7 @@ func RequestUserDataExport(c *echo.Context) error {
return err
}
events.DispatchPending(s)
events.DispatchPending(c.Request().Context(), s)
return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested data export. We will send you an email when it's ready."})
}

View File

@ -22,6 +22,7 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/metrics"
"code.vikunja.io/api/pkg/models"
@ -79,14 +80,18 @@ func RegisterUser(c *echo.Context) error {
})
if err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
events.CleanupPending(s)
return err
}
events.DispatchPending(c.Request().Context(), s)
// Bust the cached user count so the new registration shows up in metrics
// immediately instead of after the regular cache expiry.
if config.MetricsEnabled.GetBool() {

View File

@ -155,7 +155,7 @@ func timeEntriesTimerStop(ctx context.Context, _ *struct{}) (*singleBody[models.
events.CleanupPending(s)
return nil, translateDomainError(err)
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return &singleBody[models.TimeEntry]{Body: entry}, nil
}

View File

@ -21,6 +21,7 @@ import (
"strings"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
@ -89,5 +90,17 @@ func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context, sk
c.Set("api_token", token)
c.Set("api_user", u)
// Guarded by config: this fires on every token-authenticated request and
// only the audit listener consumes it.
if config.AuditEnabled.GetBool() {
err = events.DispatchWithContext(c.Request().Context(), &models.APITokenUsedEvent{
TokenID: token.ID,
OwnerID: token.OwnerID,
})
if err != nil {
log.Errorf("Could not dispatch api token used event: %s", err)
}
}
return nil
}

View File

@ -17,6 +17,7 @@
package caldav
import (
"context"
"slices"
"strconv"
"strings"
@ -396,7 +397,7 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
return nil, err
}
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
// Build up the proper response
rr := VikunjaProjectResourceAdapter{
@ -473,7 +474,7 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
return nil, err
}
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
// Build up the proper response
rr := VikunjaProjectResourceAdapter{
@ -516,7 +517,7 @@ func (vcls *VikunjaCaldavProjectStorage) DeleteResource(_ string) error {
return err
}
events.DispatchPending(s)
events.DispatchPending(context.Background(), s)
}
return nil

View File

@ -0,0 +1,45 @@
// 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 <https://www.gnu.org/licenses/>.
package middleware
import (
"code.vikunja.io/api/pkg/events"
"github.com/labstack/echo/v5"
)
// RequestMeta stashes IP, User-Agent and X-Request-ID on the request context
// so events dispatched while handling the request carry them as message
// metadata (consumed by the audit listeners).
func RequestMeta() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
req := c.Request()
requestID := req.Header.Get(echo.HeaderXRequestID)
if requestID == "" {
requestID = c.Response().Header().Get(echo.HeaderXRequestID)
}
ctx := events.WithRequestMeta(req.Context(), &events.RequestMeta{
IP: c.RealIP(),
UserAgent: req.UserAgent(),
RequestID: requestID,
})
c.SetRequest(req.WithContext(ctx))
return next(c)
}
}
}

View File

@ -199,6 +199,10 @@ func NewEcho() *echo.Echo {
// handler binds them. Runs globally so both /api/v1 and /api/v2 benefit.
e.Use(vmiddleware.NormalizeArrayParams())
if config.AuditEnabled.GetBool() {
e.Use(vmiddleware.RequestMeta())
}
setupSentry(e)
// Validation

View File

@ -25,3 +25,34 @@ type CreatedEvent struct {
func (t *CreatedEvent) Name() string {
return "user.created"
}
// LoginSucceededEvent is fired after a user successfully authenticated,
// regardless of the auth provider (local, LDAP, OpenID).
type LoginSucceededEvent struct {
User *User `json:"user"`
}
// Name defines the name for LoginSucceededEvent
func (t *LoginSucceededEvent) Name() string {
return "user.login.succeeded"
}
// LoginFailedEvent is fired for every failed password check of a known user.
type LoginFailedEvent struct {
User *User `json:"user"`
}
// Name defines the name for LoginFailedEvent
func (t *LoginFailedEvent) Name() string {
return "user.login.failed"
}
// LogoutEvent is fired when a user destroys their session.
type LogoutEvent struct {
UserID int64 `json:"user_id"`
}
// Name defines the name for LogoutEvent
func (t *LogoutEvent) Name() string {
return "user.logout"
}

View File

@ -27,6 +27,7 @@ import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/notifications"
@ -411,6 +412,10 @@ func (u *User) IsLocalUser() bool {
}
func handleFailedPassword(user *User) {
if err := events.Dispatch(&LoginFailedEvent{User: user}); err != nil {
log.Errorf("Could not dispatch login failed event: %s", err)
}
key := user.GetFailedPasswordAttemptsKey()
err := keyvalue.IncrBy(key, 1)
if err != nil {

View File

@ -28,7 +28,7 @@ import (
// DoCreate runs the permission check + model Create + commit pipeline for a
// CObject. Framework-agnostic: callable from both Echo (CreateWeb) and Huma.
// Caller is responsible for body/path binding and validation before calling.
func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
func DoCreate(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@ -60,7 +60,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
return err
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return nil
}
@ -68,7 +68,7 @@ func DoCreate(_ context.Context, obj CObject, a web.Auth) error {
// CObject. obj should have its identifying fields set before call. On success,
// obj is fully populated. maxPermission is exposed via the x-max-permission
// header in the Echo wrapper; Huma wrapper may ignore it.
func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, err error) {
func DoReadOne(ctx context.Context, obj CObject, a web.Auth) (maxPermission int, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
@ -100,7 +100,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e
return 0, err
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return maxPermission, nil
}
@ -108,7 +108,7 @@ func DoReadOne(_ context.Context, obj CObject, a web.Auth) (maxPermission int, e
// scoping context (e.g., TaskID on LabelTask). Returns the result slice/
// interface, the result count, and total count. Pagination header math and
// nil-slice normalization remain the caller's responsibility.
func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) {
func DoReadAll(ctx context.Context, obj CObject, a web.Auth, search string, page, perPage int) (result any, resultCount int, total int64, err error) {
s := db.NewSession()
defer func() {
if cerr := s.Close(); cerr != nil {
@ -128,14 +128,14 @@ func DoReadAll(_ context.Context, obj CObject, a web.Auth, search string, page,
return nil, 0, 0, err
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return result, resultCount, total, nil
}
// DoUpdate runs the permission check + model Update + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for body/path binding
// and validation before calling.
func DoUpdate(_ context.Context, obj CObject, a web.Auth) error {
func DoUpdate(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@ -167,14 +167,14 @@ func DoUpdate(_ context.Context, obj CObject, a web.Auth) error {
return err
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return nil
}
// DoDelete runs the permission check + model Delete + commit pipeline for a
// CObject. Framework-agnostic. Caller is responsible for path binding before
// calling.
func DoDelete(_ context.Context, obj CObject, a web.Auth) error {
func DoDelete(ctx context.Context, obj CObject, a web.Auth) error {
s := db.NewSession()
defer func() {
if err := s.Close(); err != nil {
@ -206,6 +206,6 @@ func DoDelete(_ context.Context, obj CObject, a web.Auth) error {
return err
}
events.DispatchPending(s)
events.DispatchPending(ctx, s)
return nil
}