Compare commits
11 Commits
main
...
feat-audit
| Author | SHA1 | Date |
|---|---|---|
|
|
ab038ec6c4 | |
|
|
ae908be716 | |
|
|
126ea78dac | |
|
|
3fc5813888 | |
|
|
3d8c259242 | |
|
|
6ab03d3f87 | |
|
|
de22af0048 | |
|
|
4ff8181a47 | |
|
|
939daaf1ab | |
|
|
a4bbd02d6a | |
|
|
5db25ab75c |
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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} }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//////
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue