diff --git a/coderd/audit/backends/postgres.go b/coderd/audit/backends/postgres.go new file mode 100644 index 0000000000000..db02580c8e1d0 --- /dev/null +++ b/coderd/audit/backends/postgres.go @@ -0,0 +1,53 @@ +package backends + +import ( + "context" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database" +) + +type postgresBackend struct { + // internal indicates if the exporter is exporting to the Postgres database + // that the rest of Coderd uses. Since this is a generic Postgres exporter, + // we make different decisions to store the audit log based on if it's + // pointing to the Coderd database. + internal bool + db database.Store +} + +func NewPostgres(db database.Store, internal bool) audit.Backend { + return &postgresBackend{db: db, internal: internal} +} + +func (b *postgresBackend) Decision() audit.FilterDecision { + if b.internal { + return audit.FilterDecisionStore + } + + return audit.FilterDecisionExport +} + +func (b *postgresBackend) Export(ctx context.Context, alog database.AuditLog) error { + _, err := b.db.InsertAuditLog(ctx, database.InsertAuditLogParams{ + ID: alog.ID, + Time: alog.Time, + UserID: alog.UserID, + OrganizationID: alog.OrganizationID, + Ip: alog.Ip, + UserAgent: alog.UserAgent, + ResourceType: alog.ResourceType, + ResourceID: alog.ResourceID, + ResourceTarget: alog.ResourceTarget, + Action: alog.Action, + Diff: alog.Diff, + StatusCode: alog.StatusCode, + }) + if err != nil { + return xerrors.Errorf("insert audit log: %w", err) + } + + return nil +} diff --git a/coderd/audit/backends/postgres_test.go b/coderd/audit/backends/postgres_test.go new file mode 100644 index 0000000000000..952443cd67cae --- /dev/null +++ b/coderd/audit/backends/postgres_test.go @@ -0,0 +1,65 @@ +package backends_test + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/tabbed/pqtype" + + "github.com/coder/coder/coderd/audit/backends" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" +) + +func TestPostgresBackend(t *testing.T) { + t.Parallel() + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + ctx, cancel = context.WithCancel(context.Background()) + db = databasefake.New() + pgb = backends.NewPostgres(db, true) + alog = randomAuditLog() + ) + defer cancel() + + err := pgb.Export(ctx, alog) + require.NoError(t, err) + + got, err := db.GetAuditLogsBefore(ctx, database.GetAuditLogsBeforeParams{ + ID: uuid.Nil, + StartTime: time.Now().Add(time.Second), + RowLimit: 1, + }) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, alog, got[0]) + }) +} + +func randomAuditLog() database.AuditLog { + _, inet, _ := net.ParseCIDR("127.0.0.1/32") + return database.AuditLog{ + ID: uuid.New(), + Time: time.Now(), + UserID: uuid.New(), + OrganizationID: uuid.New(), + Ip: pqtype.Inet{ + IPNet: *inet, + Valid: true, + }, + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", + ResourceType: database.ResourceTypeOrganization, + ResourceID: uuid.New(), + ResourceTarget: "colin's organization", + Action: database.AuditActionDelete, + Diff: []byte{}, + StatusCode: http.StatusNoContent, + } +} diff --git a/coderd/audit/backends/slog.go b/coderd/audit/backends/slog.go new file mode 100644 index 0000000000000..ad9191a0c6c92 --- /dev/null +++ b/coderd/audit/backends/slog.go @@ -0,0 +1,34 @@ +package backends + +import ( + "context" + + "github.com/fatih/structs" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database" +) + +type slogBackend struct { + log slog.Logger +} + +func NewSlog(logger slog.Logger) audit.Backend { + return slogBackend{log: logger} +} + +func (slogBackend) Decision() audit.FilterDecision { + return audit.FilterDecisionExport +} + +func (b slogBackend) Export(ctx context.Context, alog database.AuditLog) error { + m := structs.Map(alog) + fields := make([]slog.Field, 0, len(m)) + for k, v := range m { + fields = append(fields, slog.F(k, v)) + } + + b.log.Info(ctx, "audit_log", fields...) + return nil +} diff --git a/coderd/audit/backends/slog_test.go b/coderd/audit/backends/slog_test.go new file mode 100644 index 0000000000000..428637dd4b5a0 --- /dev/null +++ b/coderd/audit/backends/slog_test.go @@ -0,0 +1,46 @@ +package backends_test + +import ( + "context" + "testing" + + "github.com/fatih/structs" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/audit/backends" +) + +func TestSlogBackend(t *testing.T) { + t.Parallel() + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + ctx, cancel = context.WithCancel(context.Background()) + + sink = &fakeSink{} + logger = slog.Make(sink) + backend = backends.NewSlog(logger) + + alog = randomAuditLog() + ) + defer cancel() + + err := backend.Export(ctx, alog) + require.NoError(t, err) + require.Len(t, sink.entries, 1) + require.Equal(t, sink.entries[0].Message, "audit_log") + require.Len(t, sink.entries[0].Fields, len(structs.Fields(alog))) + }) +} + +type fakeSink struct { + entries []slog.SinkEntry +} + +func (s *fakeSink) LogEntry(_ context.Context, e slog.SinkEntry) { + s.entries = append(s.entries, e) +} + +func (*fakeSink) Sync() {} diff --git a/coderd/audit/exporter.go b/coderd/audit/exporter.go new file mode 100644 index 0000000000000..c9b37aec5fb62 --- /dev/null +++ b/coderd/audit/exporter.go @@ -0,0 +1,55 @@ +package audit + +import ( + "context" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" +) + +// Backends can store or send audit logs to arbitrary locations. +type Backend interface { + // Decision determines the FilterDecisions that the backend tolerates. + Decision() FilterDecision + // Export sends an audit log to the backend. + Export(ctx context.Context, alog database.AuditLog) error +} + +// Exporter exports audit logs to an arbitrary list of backends. +type Exporter struct { + filter Filter + backends []Backend +} + +// NewExporter creates an exporter from the given filter and backends. +func NewExporter(filter Filter, backends ...Backend) *Exporter { + return &Exporter{ + filter: filter, + backends: backends, + } +} + +// Export exports and audit log. Before exporting to a backend, it uses the +// filter to determine if the backend tolerates the audit log. If not, it is +// dropped. +func (e *Exporter) Export(ctx context.Context, alog database.AuditLog) error { + decision, err := e.filter.Check(ctx, alog) + if err != nil { + return xerrors.Errorf("filter check: %w", err) + } + + for _, backend := range e.backends { + if decision&backend.Decision() != backend.Decision() { + continue + } + + err = backend.Export(ctx, alog) + if err != nil { + // naively return the first error. should probably make this smarter + // by returning multiple errors. + return xerrors.Errorf("export audit log to backend: %w", err) + } + } + return nil +} diff --git a/coderd/audit/exporter_test.go b/coderd/audit/exporter_test.go new file mode 100644 index 0000000000000..39376b4775558 --- /dev/null +++ b/coderd/audit/exporter_test.go @@ -0,0 +1,131 @@ +package audit_test + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/tabbed/pqtype" + + "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database" +) + +func TestExporter(t *testing.T) { + t.Parallel() + + var tests = []struct { + name string + filterDecision audit.FilterDecision + backendDecision audit.FilterDecision + shouldExport bool + }{ + { + name: "ShouldDrop", + filterDecision: audit.FilterDecisionDrop, + backendDecision: audit.FilterDecisionStore, + shouldExport: false, + }, + { + name: "ShouldStore", + filterDecision: audit.FilterDecisionStore, + backendDecision: audit.FilterDecisionStore, + shouldExport: true, + }, + { + name: "ShouldNotStore", + filterDecision: audit.FilterDecisionExport, + backendDecision: audit.FilterDecisionStore, + shouldExport: false, + }, + { + name: "ShouldExport", + filterDecision: audit.FilterDecisionExport, + backendDecision: audit.FilterDecisionExport, + shouldExport: true, + }, + { + name: "ShouldNotExport", + filterDecision: audit.FilterDecisionStore, + backendDecision: audit.FilterDecisionExport, + shouldExport: false, + }, + { + name: "ShouldStoreOrExport", + filterDecision: audit.FilterDecisionStore | audit.FilterDecisionExport, + backendDecision: audit.FilterDecisionExport, + shouldExport: true, + }, + // When more filters are written they should have their own tests. + { + name: "DefaultFilter", + filterDecision: func() audit.FilterDecision { + decision, _ := audit.DefaultFilter.Check(context.Background(), randomAuditLog()) + return decision + }(), + backendDecision: audit.FilterDecisionExport, + shouldExport: true, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var ( + backend = &testBackend{decision: test.backendDecision} + exporter = audit.NewExporter( + audit.FilterFunc(func(_ context.Context, _ database.AuditLog) (audit.FilterDecision, error) { + return test.filterDecision, nil + }), + backend, + ) + ) + + err := exporter.Export(context.Background(), randomAuditLog()) + require.NoError(t, err) + require.Equal(t, len(backend.alogs) > 0, test.shouldExport) + }) + } +} + +func randomAuditLog() database.AuditLog { + _, inet, _ := net.ParseCIDR("127.0.0.1/32") + return database.AuditLog{ + ID: uuid.New(), + Time: time.Now(), + UserID: uuid.New(), + OrganizationID: uuid.New(), + Ip: pqtype.Inet{ + IPNet: *inet, + Valid: true, + }, + UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36", + ResourceType: database.ResourceTypeOrganization, + ResourceID: uuid.New(), + ResourceTarget: "colin's organization", + Action: database.AuditActionDelete, + Diff: []byte{}, + StatusCode: http.StatusNoContent, + } +} + +type testBackend struct { + decision audit.FilterDecision + + alogs []database.AuditLog +} + +func (t *testBackend) Decision() audit.FilterDecision { + return t.decision +} + +func (t *testBackend) Export(_ context.Context, alog database.AuditLog) error { + t.alogs = append(t.alogs, alog) + return nil +} diff --git a/coderd/audit/filter.go b/coderd/audit/filter.go new file mode 100644 index 0000000000000..868d5bb7d77db --- /dev/null +++ b/coderd/audit/filter.go @@ -0,0 +1,42 @@ +package audit + +import ( + "context" + + "github.com/coder/coder/coderd/database" +) + +// FilterDecision is a bitwise flag describing the actions a given filter allows +// for a given audit log. +type FilterDecision uint8 + +const ( + // FilterDecisionDrop indicates that the audit log should be dropped. It + // should not be stored or exported anywhere. + FilterDecisionDrop FilterDecision = 0 + // FilterDecisionStore indicates that the audit log should be allowed to be + // stored in the Coder database. + FilterDecisionStore FilterDecision = 1 << iota + // FilterDecisionExport indicates that the audit log should be exported + // externally of Coder. + FilterDecisionExport +) + +// Filters produce a FilterDecision for a given audit log. +type Filter interface { + Check(ctx context.Context, alog database.AuditLog) (FilterDecision, error) +} + +// DefaultFilter is the default filter used when exporting audit logs. It allows +// storage and exporting for all audit logs. +var DefaultFilter Filter = FilterFunc(func(ctx context.Context, alog database.AuditLog) (FilterDecision, error) { + // Store and export all audit logs for now. + return FilterDecisionStore | FilterDecisionExport, nil +}) + +// FilterFunc constructs a Filter from a simple function. +type FilterFunc func(ctx context.Context, alog database.AuditLog) (FilterDecision, error) + +func (f FilterFunc) Check(ctx context.Context, alog database.AuditLog) (FilterDecision, error) { + return f(ctx, alog) +} diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index a9c7dea5fdbd5..295062a0a7096 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1644,11 +1644,16 @@ func (q *fakeQuerier) GetAuditLogsBefore(_ context.Context, arg database.GetAudi logs := make([]database.AuditLog, 0) start := database.AuditLog{} - for _, alog := range q.auditLogs { - if alog.ID == arg.ID { - start = alog - break + if arg.ID != uuid.Nil { + for _, alog := range q.auditLogs { + if alog.ID == arg.ID { + start = alog + break + } } + } else { + start.ID = uuid.New() + start.Time = arg.StartTime } if start.ID == uuid.Nil { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index dd18186ea761b..46cfc646e8158 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -101,7 +101,7 @@ CREATE TABLE audit_logs ( "time" timestamp with time zone NOT NULL, user_id uuid NOT NULL, organization_id uuid NOT NULL, - ip cidr NOT NULL, + ip inet NOT NULL, user_agent character varying(256) NOT NULL, resource_type resource_type NOT NULL, resource_id uuid NOT NULL, diff --git a/coderd/database/migrations/000010_audit_logs.up.sql b/coderd/database/migrations/000010_audit_logs.up.sql index 7149679a44947..6c5e8dff9f075 100644 --- a/coderd/database/migrations/000010_audit_logs.up.sql +++ b/coderd/database/migrations/000010_audit_logs.up.sql @@ -18,7 +18,7 @@ CREATE TABLE audit_logs ( "time" timestamp with time zone NOT NULL, user_id uuid NOT NULL, organization_id uuid NOT NULL, - ip cidr NOT NULL, + ip inet NOT NULL, user_agent varchar(256) NOT NULL, resource_type resource_type NOT NULL, resource_id uuid NOT NULL, diff --git a/coderd/database/models.go b/coderd/database/models.go index 0fd6e4ca28270..ad7277e32cbbe 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -311,7 +311,7 @@ type AuditLog struct { Time time.Time `db:"time" json:"time"` UserID uuid.UUID `db:"user_id" json:"user_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Ip pqtype.CIDR `db:"ip" json:"ip"` + Ip pqtype.Inet `db:"ip" json:"ip"` UserAgent string `db:"user_agent" json:"user_agent"` ResourceType ResourceType `db:"resource_type" json:"resource_type"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 450d540a192ac..c79f9690e7f3a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -154,22 +154,23 @@ SELECT FROM audit_logs WHERE - "time" < (SELECT "time" FROM audit_logs a WHERE a.id = $1) + audit_logs."time" < COALESCE((SELECT "time" FROM audit_logs a WHERE a.id = $1), $2) ORDER BY "time" DESC LIMIT - $2 + $3 ` type GetAuditLogsBeforeParams struct { - ID uuid.UUID `db:"id" json:"id"` - RowLimit int32 `db:"row_limit" json:"row_limit"` + ID uuid.UUID `db:"id" json:"id"` + StartTime time.Time `db:"start_time" json:"start_time"` + RowLimit int32 `db:"row_limit" json:"row_limit"` } // GetAuditLogsBefore retrieves `limit` number of audit logs before the provided // ID. func (q *sqlQuerier) GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBeforeParams) ([]AuditLog, error) { - rows, err := q.db.QueryContext(ctx, getAuditLogsBefore, arg.ID, arg.RowLimit) + rows, err := q.db.QueryContext(ctx, getAuditLogsBefore, arg.ID, arg.StartTime, arg.RowLimit) if err != nil { return nil, err } @@ -229,7 +230,7 @@ type InsertAuditLogParams struct { Time time.Time `db:"time" json:"time"` UserID uuid.UUID `db:"user_id" json:"user_id"` OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - Ip pqtype.CIDR `db:"ip" json:"ip"` + Ip pqtype.Inet `db:"ip" json:"ip"` UserAgent string `db:"user_agent" json:"user_agent"` ResourceType ResourceType `db:"resource_type" json:"resource_type"` ResourceID uuid.UUID `db:"resource_id" json:"resource_id"` diff --git a/coderd/database/queries/auditlogs.sql b/coderd/database/queries/auditlogs.sql index 90ec14e526a7f..e827ea1cd113f 100644 --- a/coderd/database/queries/auditlogs.sql +++ b/coderd/database/queries/auditlogs.sql @@ -6,7 +6,7 @@ SELECT FROM audit_logs WHERE - "time" < (SELECT "time" FROM audit_logs a WHERE a.id = sqlc.arg(id)) + audit_logs."time" < COALESCE((SELECT "time" FROM audit_logs a WHERE a.id = sqlc.arg(id)), sqlc.arg(start_time)) ORDER BY "time" DESC LIMIT diff --git a/go.mod b/go.mod index 128a705b78d79..b7c27ea1d1797 100644 --- a/go.mod +++ b/go.mod @@ -155,6 +155,7 @@ require ( github.com/dustin/go-humanize v1.0.0 // indirect github.com/fatedier/beego v0.0.0-20171024143340-6c6a4f5bd5eb // indirect github.com/fatedier/kcp-go v2.0.4-0.20190803094908-fe8645b0a904+incompatible // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect diff --git a/go.sum b/go.sum index 5340761d66be1..17a312897dcc3 100644 --- a/go.sum +++ b/go.sum @@ -579,6 +579,7 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=