mirror of
https://github.com/coder/coder.git
synced 2025-07-06 15:41:45 +00:00
feat(enterprise/audit): add user object to slog exporter (#9456)
This commit is contained in:
@ -33,12 +33,14 @@ type Request[T Auditable] struct {
|
|||||||
Old T
|
Old T
|
||||||
New T
|
New T
|
||||||
|
|
||||||
// This optional field can be passed in when the userID cannot be determined from the API Key
|
// UserID is an optional field can be passed in when the userID cannot be
|
||||||
// such as in the case of login, when the audit log is created prior the API Key's existence.
|
// determined from the API Key such as in the case of login, when the audit
|
||||||
|
// log is created prior the API Key's existence.
|
||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
|
|
||||||
// This optional field can be passed in if the AuditAction must be overridden
|
// Action is an optional field can be passed in if the AuditAction must be
|
||||||
// such as in the case of new user authentication when the Audit Action is 'register', not 'login'.
|
// overridden such as in the case of new user authentication when the Audit
|
||||||
|
// Action is 'register', not 'login'.
|
||||||
Action database.AuditAction
|
Action database.AuditAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,23 +2,37 @@ package audit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/audit"
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type BackendDetails struct {
|
||||||
|
Actor *Actor
|
||||||
|
}
|
||||||
|
|
||||||
|
type Actor struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
// Backends can store or send audit logs to arbitrary locations.
|
// Backends can store or send audit logs to arbitrary locations.
|
||||||
type Backend interface {
|
type Backend interface {
|
||||||
// Decision determines the FilterDecisions that the backend tolerates.
|
// Decision determines the FilterDecisions that the backend tolerates.
|
||||||
Decision() FilterDecision
|
Decision() FilterDecision
|
||||||
// Export sends an audit log to the backend.
|
// Export sends an audit log to the backend.
|
||||||
Export(ctx context.Context, alog database.AuditLog) error
|
Export(ctx context.Context, alog database.AuditLog, details BackendDetails) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuditor(filter Filter, backends ...Backend) audit.Auditor {
|
func NewAuditor(db database.Store, filter Filter, backends ...Backend) audit.Auditor {
|
||||||
return &auditor{
|
return &auditor{
|
||||||
|
db: db,
|
||||||
filter: filter,
|
filter: filter,
|
||||||
backends: backends,
|
backends: backends,
|
||||||
Differ: audit.Differ{DiffFn: func(old, new any) audit.Map {
|
Differ: audit.Differ{DiffFn: func(old, new any) audit.Map {
|
||||||
@ -29,6 +43,7 @@ func NewAuditor(filter Filter, backends ...Backend) audit.Auditor {
|
|||||||
|
|
||||||
// auditor is the enterprise implementation of the Auditor interface.
|
// auditor is the enterprise implementation of the Auditor interface.
|
||||||
type auditor struct {
|
type auditor struct {
|
||||||
|
db database.Store
|
||||||
filter Filter
|
filter Filter
|
||||||
backends []Backend
|
backends []Backend
|
||||||
|
|
||||||
@ -41,12 +56,21 @@ func (a *auditor) Export(ctx context.Context, alog database.AuditLog) error {
|
|||||||
return xerrors.Errorf("filter check: %w", err)
|
return xerrors.Errorf("filter check: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actor, err := a.db.GetUserByID(dbauthz.AsSystemRestricted(ctx), alog.UserID) //nolint
|
||||||
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, backend := range a.backends {
|
for _, backend := range a.backends {
|
||||||
if decision&backend.Decision() != backend.Decision() {
|
if decision&backend.Decision() != backend.Decision() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = backend.Export(ctx, alog)
|
err = backend.Export(ctx, alog, BackendDetails{Actor: &Actor{
|
||||||
|
ID: actor.ID,
|
||||||
|
Email: actor.Email,
|
||||||
|
Username: actor.Username,
|
||||||
|
}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// naively return the first error. should probably make this smarter
|
// naively return the first error. should probably make this smarter
|
||||||
// by returning multiple errors.
|
// by returning multiple errors.
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
"github.com/coder/coder/v2/enterprise/audit"
|
"github.com/coder/coder/v2/enterprise/audit"
|
||||||
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
||||||
)
|
)
|
||||||
@ -90,6 +91,7 @@ func TestAuditor(t *testing.T) {
|
|||||||
var (
|
var (
|
||||||
backend = &testBackend{decision: test.backendDecision, err: test.backendError}
|
backend = &testBackend{decision: test.backendDecision, err: test.backendError}
|
||||||
exporter = audit.NewAuditor(
|
exporter = audit.NewAuditor(
|
||||||
|
dbfake.New(),
|
||||||
audit.FilterFunc(func(_ context.Context, _ database.AuditLog) (audit.FilterDecision, error) {
|
audit.FilterFunc(func(_ context.Context, _ database.AuditLog) (audit.FilterDecision, error) {
|
||||||
return test.filterDecision, test.filterError
|
return test.filterDecision, test.filterError
|
||||||
}),
|
}),
|
||||||
@ -113,18 +115,26 @@ type testBackend struct {
|
|||||||
decision audit.FilterDecision
|
decision audit.FilterDecision
|
||||||
err error
|
err error
|
||||||
|
|
||||||
alogs []database.AuditLog
|
alogs []testExport
|
||||||
|
}
|
||||||
|
|
||||||
|
type testExport struct {
|
||||||
|
alog database.AuditLog
|
||||||
|
details audit.BackendDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testBackend) Decision() audit.FilterDecision {
|
func (t *testBackend) Decision() audit.FilterDecision {
|
||||||
return t.decision
|
return t.decision
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *testBackend) Export(_ context.Context, alog database.AuditLog) error {
|
func (t *testBackend) Export(_ context.Context, alog database.AuditLog, details audit.BackendDetails) error {
|
||||||
if t.err != nil {
|
if t.err != nil {
|
||||||
return t.err
|
return t.err
|
||||||
}
|
}
|
||||||
|
|
||||||
t.alogs = append(t.alogs, alog)
|
t.alogs = append(t.alogs, testExport{
|
||||||
|
alog: alog,
|
||||||
|
details: details,
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ func (b *postgresBackend) Decision() audit.FilterDecision {
|
|||||||
return audit.FilterDecisionExport
|
return audit.FilterDecisionExport
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *postgresBackend) Export(ctx context.Context, alog database.AuditLog) error {
|
func (b *postgresBackend) Export(ctx context.Context, alog database.AuditLog, _ audit.BackendDetails) error {
|
||||||
_, err := b.db.InsertAuditLog(ctx, database.InsertAuditLogParams(alog))
|
_, err := b.db.InsertAuditLog(ctx, database.InsertAuditLogParams(alog))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("insert audit log: %w", err)
|
return xerrors.Errorf("insert audit log: %w", err)
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
|
"github.com/coder/coder/v2/enterprise/audit"
|
||||||
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
||||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||||
)
|
)
|
||||||
@ -25,7 +26,7 @@ func TestPostgresBackend(t *testing.T) {
|
|||||||
)
|
)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := pgb.Export(ctx, alog)
|
err := pgb.Export(ctx, alog, audit.BackendDetails{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
got, err := db.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
|
got, err := db.GetAuditLogsOffset(ctx, database.GetAuditLogsOffsetParams{
|
||||||
|
@ -24,7 +24,7 @@ func (*slogBackend) Decision() audit.FilterDecision {
|
|||||||
return audit.FilterDecisionExport
|
return audit.FilterDecisionExport
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *slogBackend) Export(ctx context.Context, alog database.AuditLog) error {
|
func (b *slogBackend) Export(ctx context.Context, alog database.AuditLog, details audit.BackendDetails) error {
|
||||||
// We don't use structs.Map because we don't want to recursively convert
|
// We don't use structs.Map because we don't want to recursively convert
|
||||||
// fields into maps. When we keep the type information, slog can more
|
// fields into maps. When we keep the type information, slog can more
|
||||||
// pleasantly format the output. For example, the clean result of
|
// pleasantly format the output. For example, the clean result of
|
||||||
@ -35,6 +35,10 @@ func (b *slogBackend) Export(ctx context.Context, alog database.AuditLog) error
|
|||||||
fields = append(fields, b.fieldToSlog(sf))
|
fields = append(fields, b.fieldToSlog(sf))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if details.Actor != nil {
|
||||||
|
fields = append(fields, slog.F("actor", details.Actor))
|
||||||
|
}
|
||||||
|
|
||||||
b.log.Info(ctx, "audit_log", fields...)
|
b.log.Info(ctx, "audit_log", fields...)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import (
|
|||||||
"cdr.dev/slog"
|
"cdr.dev/slog"
|
||||||
"cdr.dev/slog/sloggers/slogjson"
|
"cdr.dev/slog/sloggers/slogjson"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/enterprise/audit"
|
||||||
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
"github.com/coder/coder/v2/enterprise/audit/audittest"
|
||||||
"github.com/coder/coder/v2/enterprise/audit/backends"
|
"github.com/coder/coder/v2/enterprise/audit/backends"
|
||||||
)
|
)
|
||||||
@ -39,7 +40,7 @@ func TestSlogBackend(t *testing.T) {
|
|||||||
)
|
)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := backend.Export(ctx, alog)
|
err := backend.Export(ctx, alog, audit.BackendDetails{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, sink.entries, 1)
|
require.Len(t, sink.entries, 1)
|
||||||
require.Equal(t, sink.entries[0].Message, "audit_log")
|
require.Equal(t, sink.entries[0].Message, "audit_log")
|
||||||
@ -59,7 +60,7 @@ func TestSlogBackend(t *testing.T) {
|
|||||||
_, inet, _ = net.ParseCIDR("127.0.0.1/32")
|
_, inet, _ = net.ParseCIDR("127.0.0.1/32")
|
||||||
alog = database.AuditLog{
|
alog = database.AuditLog{
|
||||||
ID: uuid.UUID{1},
|
ID: uuid.UUID{1},
|
||||||
Time: time.Unix(1257894000, 0),
|
Time: time.Unix(1257894000, 0).UTC(),
|
||||||
UserID: uuid.UUID{2},
|
UserID: uuid.UUID{2},
|
||||||
OrganizationID: uuid.UUID{3},
|
OrganizationID: uuid.UUID{3},
|
||||||
Ip: pqtype.Inet{
|
Ip: pqtype.Inet{
|
||||||
@ -80,7 +81,11 @@ func TestSlogBackend(t *testing.T) {
|
|||||||
)
|
)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := backend.Export(ctx, alog)
|
err := backend.Export(ctx, alog, audit.BackendDetails{Actor: &audit.Actor{
|
||||||
|
ID: uuid.UUID{2},
|
||||||
|
Username: "coadler",
|
||||||
|
Email: "doug@coder.com",
|
||||||
|
}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
logger.Sync()
|
logger.Sync()
|
||||||
|
|
||||||
@ -90,7 +95,7 @@ func TestSlogBackend(t *testing.T) {
|
|||||||
err = json.Unmarshal(buf.Bytes(), &s)
|
err = json.Unmarshal(buf.Bytes(), &s)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
expected := `{"ID":"01000000-0000-0000-0000-000000000000","Time":"2009-11-10T23:00:00Z","UserID":"02000000-0000-0000-0000-000000000000","OrganizationID":"03000000-0000-0000-0000-000000000000","Ip":"127.0.0.1","UserAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36","ResourceType":"organization","ResourceID":"04000000-0000-0000-0000-000000000000","ResourceTarget":"colin's organization","Action":"delete","Diff":{"1":2},"StatusCode":204,"AdditionalFields":{"name":"doug","species":"cat"},"RequestID":"05000000-0000-0000-0000-000000000000","ResourceIcon":"photo.png"}`
|
expected := `{"ID":"01000000-0000-0000-0000-000000000000","Time":"2009-11-10T23:00:00Z","UserID":"02000000-0000-0000-0000-000000000000","OrganizationID":"03000000-0000-0000-0000-000000000000","Ip":"127.0.0.1","UserAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36","ResourceType":"organization","ResourceID":"04000000-0000-0000-0000-000000000000","ResourceTarget":"colin's organization","Action":"delete","Diff":{"1":2},"StatusCode":204,"AdditionalFields":{"name":"doug","species":"cat"},"RequestID":"05000000-0000-0000-0000-000000000000","ResourceIcon":"photo.png","actor":{"id":"02000000-0000-0000-0000-000000000000","email":"doug@coder.com","username":"coadler"}}`
|
||||||
assert.Equal(t, expected, string(s.Fields))
|
assert.Equal(t, expected, string(s.Fields))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,9 @@ func (r *RootCmd) server() *clibase.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
options.DERPServer.SetMeshKey(meshKey)
|
options.DERPServer.SetMeshKey(meshKey)
|
||||||
options.Auditor = audit.NewAuditor(audit.DefaultFilter,
|
options.Auditor = audit.NewAuditor(
|
||||||
|
options.Database,
|
||||||
|
audit.DefaultFilter,
|
||||||
backends.NewPostgres(options.Database, true),
|
backends.NewPostgres(options.Database, true),
|
||||||
backends.NewSlog(options.Logger),
|
backends.NewSlog(options.Logger),
|
||||||
)
|
)
|
||||||
|
@ -7,10 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
||||||
"github.com/coder/coder/v2/coderd/rbac"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/goleak"
|
"go.uber.org/goleak"
|
||||||
@ -18,6 +14,9 @@ import (
|
|||||||
agplaudit "github.com/coder/coder/v2/coderd/audit"
|
agplaudit "github.com/coder/coder/v2/coderd/audit"
|
||||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||||
|
"github.com/coder/coder/v2/coderd/rbac"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/enterprise/audit"
|
"github.com/coder/coder/v2/enterprise/audit"
|
||||||
"github.com/coder/coder/v2/enterprise/coderd"
|
"github.com/coder/coder/v2/enterprise/coderd"
|
||||||
@ -185,7 +184,7 @@ func TestAuditLogging(t *testing.T) {
|
|||||||
_, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
_, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||||
AuditLogging: true,
|
AuditLogging: true,
|
||||||
Options: &coderdtest.Options{
|
Options: &coderdtest.Options{
|
||||||
Auditor: audit.NewAuditor(audit.DefaultFilter),
|
Auditor: audit.NewAuditor(dbfake.New(), audit.DefaultFilter),
|
||||||
},
|
},
|
||||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||||
Features: license.Features{
|
Features: license.Features{
|
||||||
@ -194,7 +193,7 @@ func TestAuditLogging(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
auditor := *api.AGPL.Auditor.Load()
|
auditor := *api.AGPL.Auditor.Load()
|
||||||
ea := audit.NewAuditor(audit.DefaultFilter)
|
ea := audit.NewAuditor(dbfake.New(), audit.DefaultFilter)
|
||||||
t.Logf("%T = %T", auditor, ea)
|
t.Logf("%T = %T", auditor, ea)
|
||||||
assert.EqualValues(t, reflect.ValueOf(ea).Type(), reflect.ValueOf(auditor).Type())
|
assert.EqualValues(t, reflect.ValueOf(ea).Type(), reflect.ValueOf(auditor).Type())
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user