chore: rearrange audit logging code into enterprise folder (#3741)

This commit is contained in:
Colin Adler 2022-08-31 16:12:54 -05:00 committed by GitHub
parent 9583e16a05
commit 00da01fdf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 916 additions and 600 deletions

24
coderd/audit/audit.go Normal file
View File

@ -0,0 +1,24 @@
package audit
import (
"context"
"github.com/coder/coder/coderd/database"
)
type Auditor interface {
Export(ctx context.Context, alog database.AuditLog) error
diff(old, new any) Map
}
func NewNop() Auditor {
return nop{}
}
type nop struct{}
func (nop) Export(context.Context, database.AuditLog) error {
return nil
}
func (nop) diff(any, any) Map { return Map{} }

View File

@ -1,16 +1,35 @@
package audit
import (
"database/sql"
"fmt"
"reflect"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
)
// TODO: this might need to be in the database package.
type Map map[string]interface{}
// Auditable is mostly a marker interface. It contains a definitive list of all
// auditable types. If you want to audit a new type, first define it in
// AuditableResources, then add it to this interface.
type Auditable interface {
database.APIKey |
database.Organization |
database.OrganizationMember |
database.Template |
database.TemplateVersion |
database.User |
database.Workspace |
database.GitSSHKey
}
// Map is a map of changed fields in an audited resource. It maps field names to
// the old and new value for that field.
type Map map[string]OldNew
// OldNew is a pair of values representing the old value and the new value.
type OldNew struct {
Old any
New any
Secret bool
}
// Empty returns a default value of type T.
func Empty[T Auditable]() T {
var t T
return t
@ -18,153 +37,16 @@ func Empty[T Auditable]() T {
// Diff compares two auditable resources and produces a Map of the changed
// values.
func Diff[T Auditable](left, right T) Map {
// Values are equal, return an empty diff.
if reflect.DeepEqual(left, right) {
return Map{}
}
func Diff[T Auditable](a Auditor, left, right T) Map { return a.diff(left, right) }
return diffValues(left, right, AuditableResources)
// Differ is used so the enterprise version can implement the diff function in
// the Auditor feature interface. Only types in the same package as the
// interface can implement unexported methods.
type Differ struct {
DiffFn func(old, new any) Map
}
func structName(t reflect.Type) string {
return t.PkgPath() + "." + t.Name()
}
func diffValues[T any](left, right T, table Table) Map {
var (
baseDiff = Map{}
leftV = reflect.ValueOf(left)
rightV = reflect.ValueOf(right)
rightT = reflect.TypeOf(right)
diffKey = table[structName(rightT)]
)
if diffKey == nil {
panic(fmt.Sprintf("dev error: type %q (type %T) attempted audit but not auditable", rightT.Name(), right))
}
for i := 0; i < rightT.NumField(); i++ {
var (
leftF = leftV.Field(i)
rightF = rightV.Field(i)
leftI = leftF.Interface()
rightI = rightF.Interface()
diffName = rightT.Field(i).Tag.Get("json")
)
atype, ok := diffKey[diffName]
if !ok {
panic(fmt.Sprintf("dev error: field %q lacks audit information", diffName))
}
if atype == ActionIgnore {
continue
}
// coerce struct types that would produce bad diffs.
if leftI, rightI, ok = convertDiffType(leftI, rightI); ok {
leftF, rightF = reflect.ValueOf(leftI), reflect.ValueOf(rightI)
}
// If the field is a pointer, dereference it. Nil pointers are coerced
// to the zero value of their underlying type.
if leftF.Kind() == reflect.Ptr && rightF.Kind() == reflect.Ptr {
leftF, rightF = derefPointer(leftF), derefPointer(rightF)
leftI, rightI = leftF.Interface(), rightF.Interface()
}
// Recursively walk up nested structs.
if rightF.Kind() == reflect.Struct {
baseDiff[diffName] = diffValues(leftI, rightI, table)
continue
}
if !reflect.DeepEqual(leftI, rightI) {
switch atype {
case ActionTrack:
baseDiff[diffName] = rightI
case ActionSecret:
baseDiff[diffName] = reflect.Zero(rightF.Type()).Interface()
}
}
}
return baseDiff
}
// convertDiffType converts external struct types to primitive types.
//
//nolint:forcetypeassert
func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
switch typed := left.(type) {
case uuid.UUID:
return typed.String(), right.(uuid.UUID).String(), true
case uuid.NullUUID:
leftStr, _ := typed.MarshalText()
rightStr, _ := right.(uuid.NullUUID).MarshalText()
return string(leftStr), string(rightStr), true
case sql.NullString:
leftStr := typed.String
if !typed.Valid {
leftStr = "null"
}
rightStr := right.(sql.NullString).String
if !right.(sql.NullString).Valid {
rightStr = "null"
}
return leftStr, rightStr, true
case sql.NullInt64:
var leftInt64Ptr *int64
var rightInt64Ptr *int64
if !typed.Valid {
leftInt64Ptr = nil
} else {
leftInt64Ptr = ptr(typed.Int64)
}
rightInt64Ptr = ptr(right.(sql.NullInt64).Int64)
if !right.(sql.NullInt64).Valid {
rightInt64Ptr = nil
}
return leftInt64Ptr, rightInt64Ptr, true
default:
return left, right, false
}
}
// derefPointer deferences a reflect.Value that is a pointer to its underlying
// value. It dereferences recursively until it finds a non-pointer value. If the
// pointer is nil, it will be coerced to the zero value of the underlying type.
func derefPointer(ptr reflect.Value) reflect.Value {
if !ptr.IsNil() {
// Grab the value the pointer references.
ptr = ptr.Elem()
} else {
// Coerce nil ptrs to zero'd values of their underlying type.
ptr = reflect.Zero(ptr.Type().Elem())
}
// Recursively deref nested pointers.
if ptr.Kind() == reflect.Ptr {
return derefPointer(ptr)
}
return ptr
}
func ptr[T any](x T) *T {
return &x
//nolint:unused
func (d Differ) diff(old, new any) Map {
return d.DiffFn(old, new)
}

View File

@ -1,163 +0,0 @@
package audit
import (
"testing"
"github.com/stretchr/testify/assert"
"k8s.io/utils/pointer"
)
func Test_diffValues(t *testing.T) {
t.Parallel()
t.Run("Normal", func(t *testing.T) {
t.Parallel()
type foo struct {
Bar string `json:"bar"`
Baz int64 `json:"baz"`
}
table := auditMap(map[any]map[string]Action{
&foo{}: {
"bar": ActionTrack,
"baz": ActionTrack,
},
})
runDiffTests(t, table, []diffTest{
{
name: "LeftEmpty",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "bar", Baz: 10},
exp: Map{
"bar": "bar",
"baz": int64(10),
},
},
{
name: "RightEmpty",
left: foo{Bar: "Bar", Baz: 10}, right: foo{Bar: "", Baz: 0},
exp: Map{
"bar": "",
"baz": int64(0),
},
},
{
name: "NoChange",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "", Baz: 0},
exp: Map{},
},
{
name: "SingleFieldChange",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "Bar", Baz: 0},
exp: Map{
"bar": "Bar",
},
},
})
})
t.Run("PointerField", func(t *testing.T) {
t.Parallel()
type foo struct {
Bar *string `json:"bar"`
}
table := auditMap(map[any]map[string]Action{
&foo{}: {
"bar": ActionTrack,
},
})
runDiffTests(t, table, []diffTest{
{
name: "LeftNil",
left: foo{Bar: nil}, right: foo{Bar: pointer.StringPtr("baz")},
exp: Map{"bar": "baz"},
},
{
name: "RightNil",
left: foo{Bar: pointer.StringPtr("baz")}, right: foo{Bar: nil},
exp: Map{"bar": ""},
},
})
})
t.Run("NestedStruct", func(t *testing.T) {
t.Parallel()
type bar struct {
Baz string `json:"baz"`
}
type foo struct {
Bar *bar `json:"bar"`
}
table := auditMap(map[any]map[string]Action{
&foo{}: {
"bar": ActionTrack,
},
&bar{}: {
"baz": ActionTrack,
},
})
runDiffTests(t, table, []diffTest{
{
name: "LeftEmpty",
left: foo{Bar: &bar{}}, right: foo{Bar: &bar{Baz: "baz"}},
exp: Map{
"bar": Map{
"baz": "baz",
},
},
},
{
name: "RightEmpty",
left: foo{Bar: &bar{Baz: "baz"}}, right: foo{Bar: &bar{}},
exp: Map{
"bar": Map{
"baz": "",
},
},
},
{
name: "LeftNil",
left: foo{Bar: nil}, right: foo{Bar: &bar{}},
exp: Map{
"bar": Map{},
},
},
{
name: "RightNil",
left: foo{Bar: &bar{Baz: "baz"}}, right: foo{Bar: nil},
exp: Map{
"bar": Map{
"baz": "",
},
},
},
})
})
}
type diffTest struct {
name string
left, right any
exp any
}
func runDiffTests(t *testing.T, table Table, tests []diffTest) {
t.Helper()
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t,
test.exp,
diffValues(test.left, test.right, table),
)
})
}
}

View File

@ -1,240 +0,0 @@
package audit_test
import (
"database/sql"
"reflect"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
)
func TestDiff(t *testing.T) {
t.Parallel()
runDiffTests(t, []diffTest[database.GitSSHKey]{
{
name: "Create",
left: audit.Empty[database.GitSSHKey](),
right: database.GitSSHKey{
UserID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
PrivateKey: "a very secret private key",
PublicKey: "a very public public key",
},
exp: audit.Map{
"user_id": uuid.UUID{1}.String(),
"private_key": "",
"public_key": "a very public public key",
},
},
})
runDiffTests(t, []diffTest[database.OrganizationMember]{
{
name: "Create",
left: audit.Empty[database.OrganizationMember](),
right: database.OrganizationMember{
UserID: uuid.UUID{1},
OrganizationID: uuid.UUID{2},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Roles: []string{"auditor"},
},
exp: audit.Map{
"user_id": uuid.UUID{1}.String(),
"organization_id": uuid.UUID{2}.String(),
"roles": []string{"auditor"},
},
},
})
runDiffTests(t, []diffTest[database.Organization]{
{
name: "Create",
left: audit.Empty[database.Organization](),
right: database.Organization{
ID: uuid.UUID{1},
Name: "rust developers",
Description: "an organization for rust developers",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"name": "rust developers",
"description": "an organization for rust developers",
},
},
})
runDiffTests(t, []diffTest[database.Template]{
{
name: "Create",
left: audit.Empty[database.Template](),
right: database.Template{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{2},
Deleted: false,
Name: "rust",
Provisioner: database.ProvisionerTypeTerraform,
ActiveVersionID: uuid.UUID{3},
MaxTtl: int64(time.Hour),
MinAutostartInterval: int64(time.Minute),
CreatedBy: uuid.UUID{4},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"organization_id": uuid.UUID{2}.String(),
"name": "rust",
"provisioner": database.ProvisionerTypeTerraform,
"active_version_id": uuid.UUID{3}.String(),
"max_ttl": int64(3600000000000),
"min_autostart_interval": int64(60000000000),
"created_by": uuid.UUID{4}.String(),
},
},
})
runDiffTests(t, []diffTest[database.TemplateVersion]{
{
name: "Create",
left: audit.Empty[database.TemplateVersion](),
right: database.TemplateVersion{
ID: uuid.UUID{1},
TemplateID: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"template_id": uuid.UUID{2}.String(),
"organization_id": uuid.UUID{3}.String(),
"name": "rust",
"created_by": uuid.UUID{4}.String(),
},
},
{
name: "CreateNullTemplateID",
left: audit.Empty[database.TemplateVersion](),
right: database.TemplateVersion{
ID: uuid.UUID{1},
TemplateID: uuid.NullUUID{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"organization_id": uuid.UUID{3}.String(),
"name": "rust",
"created_by": uuid.UUID{4}.String(),
},
},
})
runDiffTests(t, []diffTest[database.User]{
{
name: "Create",
left: audit.Empty[database.User](),
right: database.User{
ID: uuid.UUID{1},
Email: "colin@coder.com",
Username: "colin",
HashedPassword: []byte("hunter2ButHashed"),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Status: database.UserStatusActive,
RBACRoles: []string{"omega admin"},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"email": "colin@coder.com",
"username": "colin",
"hashed_password": ([]byte)(nil),
"status": database.UserStatusActive,
"rbac_roles": []string{"omega admin"},
},
},
})
runDiffTests(t, []diffTest[database.Workspace]{
{
name: "Create",
left: audit.Empty[database.Workspace](),
right: database.Workspace{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OwnerID: uuid.UUID{2},
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{String: "0 12 * * 1-5", Valid: true},
Ttl: sql.NullInt64{Int64: int64(8 * time.Hour), Valid: true},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"owner_id": uuid.UUID{2}.String(),
"template_id": uuid.UUID{3}.String(),
"name": "rust workspace",
"autostart_schedule": "0 12 * * 1-5",
"ttl": int64(28800000000000), // XXX: pq still does not support time.Duration
},
},
{
name: "NullSchedules",
left: audit.Empty[database.Workspace](),
right: database.Workspace{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OwnerID: uuid.UUID{2},
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{},
Ttl: sql.NullInt64{},
},
exp: audit.Map{
"id": uuid.UUID{1}.String(),
"owner_id": uuid.UUID{2}.String(),
"template_id": uuid.UUID{3}.String(),
"name": "rust workspace",
},
},
})
}
type diffTest[T audit.Auditable] struct {
name string
left, right T
exp audit.Map
}
func runDiffTests[T audit.Auditable](t *testing.T, tests []diffTest[T]) {
t.Helper()
var typ T
typName := reflect.TypeOf(typ).Name()
for _, test := range tests {
t.Run(typName+"/"+test.name, func(t *testing.T) {
t.Parallel()
require.Equal(t,
test.exp,
audit.Diff(test.left, test.right),
)
})
}
}

52
coderd/audit/request.go Normal file
View File

@ -0,0 +1,52 @@
package audit
import (
"context"
"net/http"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
)
type RequestParams struct {
Audit Auditor
Log slog.Logger
Action database.AuditAction
ResourceType database.ResourceType
Actor uuid.UUID
}
type Request[T Auditable] struct {
params *RequestParams
Old T
New T
}
// InitRequest initializes an audit log for a request. It returns a function
// that should be deferred, causing the audit log to be committed when the
// handler returns.
func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func()) {
sw, ok := w.(chimw.WrapResponseWriter)
if !ok {
panic("dev error: http.ResponseWriter is not chimw.WrapResponseWriter")
}
req := &Request[T]{
params: p,
}
return req, func() {
ctx := context.Background()
code := sw.Status()
err := p.Audit.Export(ctx, database.AuditLog{StatusCode: int32(code)})
if err != nil {
p.Log.Error(ctx, "export audit log", slog.Error(err))
}
}
}

View File

@ -112,7 +112,10 @@ CREATE TABLE audit_logs (
resource_target text NOT NULL,
action audit_action NOT NULL,
diff jsonb NOT NULL,
status_code integer NOT NULL
status_code integer NOT NULL,
additional_fields jsonb NOT NULL,
request_id uuid NOT NULL,
resource_icon text NOT NULL
);
CREATE TABLE files (

View File

@ -0,0 +1,4 @@
ALTER TABLE audit_logs
DROP COLUMN additional_fields,
DROP COLUMN request_id,
DROP COLUMN resource_icon;

View File

@ -0,0 +1,9 @@
ALTER TABLE audit_logs
ADD COLUMN additional_fields jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN request_id uuid NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000'::uuid,
ADD COLUMN resource_icon text NOT NULL DEFAULT '';
ALTER TABLE audit_logs
ALTER COLUMN additional_fields DROP DEFAULT,
ALTER COLUMN request_id DROP DEFAULT,
ALTER COLUMN resource_icon DROP DEFAULT;

View File

@ -326,18 +326,21 @@ type APIKey struct {
}
type AuditLog struct {
ID uuid.UUID `db:"id" json:"id"`
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.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"`
ResourceTarget string `db:"resource_target" json:"resource_target"`
Action AuditAction `db:"action" json:"action"`
Diff json.RawMessage `db:"diff" json:"diff"`
StatusCode int32 `db:"status_code" json:"status_code"`
ID uuid.UUID `db:"id" json:"id"`
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.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"`
ResourceTarget string `db:"resource_target" json:"resource_target"`
Action AuditAction `db:"action" json:"action"`
Diff json.RawMessage `db:"diff" json:"diff"`
StatusCode int32 `db:"status_code" json:"status_code"`
AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"`
RequestID uuid.UUID `db:"request_id" json:"request_id"`
ResourceIcon string `db:"resource_icon" json:"resource_icon"`
}
type File struct {

View File

@ -192,7 +192,7 @@ func (q *sqlQuerier) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDP
const getAuditLogsBefore = `-- name: GetAuditLogsBefore :many
SELECT
id, time, user_id, organization_id, ip, user_agent, resource_type, resource_id, resource_target, action, diff, status_code
id, time, user_id, organization_id, ip, user_agent, resource_type, resource_id, resource_target, action, diff, status_code, additional_fields, request_id, resource_icon
FROM
audit_logs
WHERE
@ -233,6 +233,9 @@ func (q *sqlQuerier) GetAuditLogsBefore(ctx context.Context, arg GetAuditLogsBef
&i.Action,
&i.Diff,
&i.StatusCode,
&i.AdditionalFields,
&i.RequestID,
&i.ResourceIcon,
); err != nil {
return nil, err
}
@ -261,25 +264,31 @@ INSERT INTO
resource_target,
action,
diff,
status_code
status_code,
additional_fields,
request_id,
resource_icon
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, time, user_id, organization_id, ip, user_agent, resource_type, resource_id, resource_target, action, diff, status_code
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, time, user_id, organization_id, ip, user_agent, resource_type, resource_id, resource_target, action, diff, status_code, additional_fields, request_id, resource_icon
`
type InsertAuditLogParams struct {
ID uuid.UUID `db:"id" json:"id"`
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.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"`
ResourceTarget string `db:"resource_target" json:"resource_target"`
Action AuditAction `db:"action" json:"action"`
Diff json.RawMessage `db:"diff" json:"diff"`
StatusCode int32 `db:"status_code" json:"status_code"`
ID uuid.UUID `db:"id" json:"id"`
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.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"`
ResourceTarget string `db:"resource_target" json:"resource_target"`
Action AuditAction `db:"action" json:"action"`
Diff json.RawMessage `db:"diff" json:"diff"`
StatusCode int32 `db:"status_code" json:"status_code"`
AdditionalFields json.RawMessage `db:"additional_fields" json:"additional_fields"`
RequestID uuid.UUID `db:"request_id" json:"request_id"`
ResourceIcon string `db:"resource_icon" json:"resource_icon"`
}
func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) {
@ -296,6 +305,9 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam
arg.Action,
arg.Diff,
arg.StatusCode,
arg.AdditionalFields,
arg.RequestID,
arg.ResourceIcon,
)
var i AuditLog
err := row.Scan(
@ -311,6 +323,9 @@ func (q *sqlQuerier) InsertAuditLog(ctx context.Context, arg InsertAuditLogParam
&i.Action,
&i.Diff,
&i.StatusCode,
&i.AdditionalFields,
&i.RequestID,
&i.ResourceIcon,
)
return i, err
}

View File

@ -26,7 +26,10 @@ INSERT INTO
resource_target,
action,
diff,
status_code
status_code,
additional_fields,
request_id,
resource_icon
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *;

56
codersdk/audit.go Normal file
View File

@ -0,0 +1,56 @@
package codersdk
import (
"encoding/json"
"net/netip"
"time"
"github.com/google/uuid"
)
type ResourceType string
const (
ResourceTypeOrganization ResourceType = "organization"
ResourceTypeTemplate ResourceType = "template"
ResourceTypeTemplateVersion ResourceType = "template_version"
ResourceTypeUser ResourceType = "user"
ResourceTypeWorkspace ResourceType = "workspace"
)
type AuditAction string
const (
AuditActionCreate AuditAction = "create"
AuditActionWrite AuditAction = "write"
AuditActionDelete AuditAction = "delete"
)
type AuditDiff map[string]AuditDiffField
type AuditDiffField struct {
Old any
New any
Secret bool
}
type AuditLog struct {
ID uuid.UUID `json:"id"`
RequestID uuid.UUID `json:"request_id"`
Time time.Time `json:"time"`
OrganizationID uuid.UUID `json:"organization_id"`
IP netip.Addr `json:"ip"`
UserAgent string `json:"user_agent"`
ResourceType ResourceType `json:"resource_type"`
ResourceID uuid.UUID `json:"resource_id"`
// ResourceTarget is the name of the resource.
ResourceTarget string `json:"resource_target"`
ResourceIcon string `json:"resource_icon"`
Action AuditAction `json:"action"`
Diff AuditDiff `json:"diff"`
StatusCode int32 `json:"status_code"`
AdditionalFields json.RawMessage `json:"additional_fields"`
Description string `json:"description"`
User *User `json:"user"`
}

39
enterprise/audit/audit.go Normal file
View File

@ -0,0 +1,39 @@
package audit
import (
"context"
"github.com/coder/coder/coderd/audit"
"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
}
func NewAuditor() audit.Auditor {
return &auditor{
Differ: audit.Differ{DiffFn: func(old, new any) audit.Map {
return diffValues(old, new, AuditableResources)
}},
}
}
// auditor is the enterprise implementation of the Auditor interface.
type auditor struct {
//nolint:unused
filter Filter
//nolint:unused
backends []Backend
audit.Differ
}
//nolint:unused
func (*auditor) Export(context.Context, database.AuditLog) error {
panic("not implemented") // TODO: Implement
}

164
enterprise/audit/diff.go Normal file
View File

@ -0,0 +1,164 @@
package audit
import (
"database/sql"
"fmt"
"reflect"
"github.com/google/uuid"
"github.com/coder/coder/coderd/audit"
)
func structName(t reflect.Type) string {
return t.PkgPath() + "." + t.Name()
}
func diffValues(left, right any, table Table) audit.Map {
var (
baseDiff = audit.Map{}
leftV = reflect.ValueOf(left)
rightV = reflect.ValueOf(right)
rightT = reflect.TypeOf(right)
diffKey = table[structName(rightT)]
)
if diffKey == nil {
panic(fmt.Sprintf("dev error: type %q (type %T) attempted audit but not auditable", rightT.Name(), right))
}
for i := 0; i < rightT.NumField(); i++ {
var (
leftF = leftV.Field(i)
rightF = rightV.Field(i)
leftI = leftF.Interface()
rightI = rightF.Interface()
diffName = rightT.Field(i).Tag.Get("json")
)
atype, ok := diffKey[diffName]
if !ok {
panic(fmt.Sprintf("dev error: field %q lacks audit information", diffName))
}
if atype == ActionIgnore {
continue
}
// coerce struct types that would produce bad diffs.
if leftI, rightI, ok = convertDiffType(leftI, rightI); ok {
leftF, rightF = reflect.ValueOf(leftI), reflect.ValueOf(rightI)
}
// If the field is a pointer, dereference it. Nil pointers are coerced
// to the zero value of their underlying type.
if leftF.Kind() == reflect.Ptr && rightF.Kind() == reflect.Ptr {
leftF, rightF = derefPointer(leftF), derefPointer(rightF)
leftI, rightI = leftF.Interface(), rightF.Interface()
}
if !reflect.DeepEqual(leftI, rightI) {
switch atype {
case ActionTrack:
baseDiff[diffName] = audit.OldNew{Old: leftI, New: rightI}
case ActionSecret:
baseDiff[diffName] = audit.OldNew{
Old: reflect.Zero(rightF.Type()).Interface(),
New: reflect.Zero(rightF.Type()).Interface(),
Secret: true,
}
}
}
}
return baseDiff
}
// convertDiffType converts external struct types to primitive types.
//
//nolint:forcetypeassert
func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
switch typedLeft := left.(type) {
case uuid.UUID:
typedRight := right.(uuid.UUID)
// Automatically coerce Nil UUIDs to empty strings.
outLeft := typedLeft.String()
if typedLeft == uuid.Nil {
outLeft = ""
}
outRight := typedRight.String()
if typedRight == uuid.Nil {
outRight = ""
}
return outLeft, outRight, true
case uuid.NullUUID:
leftStr, _ := typedLeft.MarshalText()
rightStr, _ := right.(uuid.NullUUID).MarshalText()
return string(leftStr), string(rightStr), true
case sql.NullString:
leftStr := typedLeft.String
if !typedLeft.Valid {
leftStr = "null"
}
rightStr := right.(sql.NullString).String
if !right.(sql.NullString).Valid {
rightStr = "null"
}
return leftStr, rightStr, true
case sql.NullInt64:
var leftInt64Ptr *int64
var rightInt64Ptr *int64
if !typedLeft.Valid {
leftInt64Ptr = nil
} else {
leftInt64Ptr = ptr(typedLeft.Int64)
}
rightInt64Ptr = ptr(right.(sql.NullInt64).Int64)
if !right.(sql.NullInt64).Valid {
rightInt64Ptr = nil
}
return leftInt64Ptr, rightInt64Ptr, true
default:
return left, right, false
}
}
// derefPointer deferences a reflect.Value that is a pointer to its underlying
// value. It dereferences recursively until it finds a non-pointer value. If the
// pointer is nil, it will be coerced to the zero value of the underlying type.
func derefPointer(ptr reflect.Value) reflect.Value {
if !ptr.IsNil() {
// Grab the value the pointer references.
ptr = ptr.Elem()
} else {
// Coerce nil ptrs to zero'd values of their underlying type.
ptr = reflect.Zero(ptr.Type().Elem())
}
// Recursively deref nested pointers.
if ptr.Kind() == reflect.Ptr {
return derefPointer(ptr)
}
return ptr
}
func ptr[T any](x T) *T {
return &x
}

View File

@ -0,0 +1,396 @@
package audit
import (
"database/sql"
"reflect"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/utils/pointer"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
)
func Test_diffValues(t *testing.T) {
t.Parallel()
t.Run("Normal", func(t *testing.T) {
t.Parallel()
type foo struct {
Bar string `json:"bar"`
Baz int `json:"baz"`
}
table := auditMap(map[any]map[string]Action{
&foo{}: {
"bar": ActionTrack,
"baz": ActionTrack,
},
})
runDiffValuesTests(t, table, []diffTest{
{
name: "LeftEmpty",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "bar", Baz: 10},
exp: audit.Map{
"bar": audit.OldNew{Old: "", New: "bar"},
"baz": audit.OldNew{Old: 0, New: 10},
},
},
{
name: "RightEmpty",
left: foo{Bar: "Bar", Baz: 10}, right: foo{Bar: "", Baz: 0},
exp: audit.Map{
"bar": audit.OldNew{Old: "Bar", New: ""},
"baz": audit.OldNew{Old: 10, New: 0},
},
},
{
name: "NoChange",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "", Baz: 0},
exp: audit.Map{},
},
{
name: "SingleFieldChange",
left: foo{Bar: "", Baz: 0}, right: foo{Bar: "Bar", Baz: 0},
exp: audit.Map{
"bar": audit.OldNew{Old: "", New: "Bar"},
},
},
})
})
//nolint:revive
t.Run("PointerField", func(t *testing.T) {
t.Parallel()
type foo struct {
Bar *string `json:"bar"`
}
table := auditMap(map[any]map[string]Action{
&foo{}: {
"bar": ActionTrack,
},
})
runDiffValuesTests(t, table, []diffTest{
{
name: "LeftNil",
left: foo{Bar: nil}, right: foo{Bar: pointer.StringPtr("baz")},
exp: audit.Map{
"bar": audit.OldNew{Old: "", New: "baz"},
},
},
{
name: "RightNil",
left: foo{Bar: pointer.StringPtr("baz")}, right: foo{Bar: nil},
exp: audit.Map{
"bar": audit.OldNew{Old: "baz", New: ""},
},
},
})
})
// We currently don't support nested structs.
// t.Run("NestedStruct", func(t *testing.T) {
// t.Parallel()
// type bar struct {
// Baz string `json:"baz"`
// }
// type foo struct {
// Bar *bar `json:"bar"`
// }
// table := auditMap(map[any]map[string]Action{
// &foo{}: {
// "bar": ActionTrack,
// },
// &bar{}: {
// "baz": ActionTrack,
// },
// })
// runDiffValuesTests(t, table, []diffTest{
// {
// name: "LeftEmpty",
// left: foo{Bar: &bar{}}, right: foo{Bar: &bar{Baz: "baz"}},
// exp: audit.Map{
// "bar": audit.Map{
// "baz": audit.OldNew{Old: "", New: "baz"},
// },
// },
// },
// {
// name: "RightEmpty",
// left: foo{Bar: &bar{Baz: "baz"}}, right: foo{Bar: &bar{}},
// exp: audit.Map{
// "bar": audit.Map{
// "baz": audit.OldNew{Old: "baz", New: ""},
// },
// },
// },
// {
// name: "LeftNil",
// left: foo{Bar: nil}, right: foo{Bar: &bar{}},
// exp: audit.Map{
// "bar": audit.Map{},
// },
// },
// {
// name: "RightNil",
// left: foo{Bar: &bar{Baz: "baz"}}, right: foo{Bar: nil},
// exp: audit.Map{
// "bar": audit.Map{
// "baz": audit.OldNew{Old: "baz", New: ""},
// },
// },
// },
// })
// })
}
type diffTest struct {
name string
left, right any
exp any
}
func runDiffValuesTests(t *testing.T, table Table, tests []diffTest) {
t.Helper()
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t,
test.exp,
diffValues(test.left, test.right, table),
)
})
}
}
func Test_diff(t *testing.T) {
t.Parallel()
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.GitSSHKey](),
right: database.GitSSHKey{
UserID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
PrivateKey: "a very secret private key",
PublicKey: "a very public public key",
},
exp: audit.Map{
"user_id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"private_key": audit.OldNew{Old: "", New: "", Secret: true},
"public_key": audit.OldNew{Old: "", New: "a very public public key"},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.OrganizationMember](),
right: database.OrganizationMember{
UserID: uuid.UUID{1},
OrganizationID: uuid.UUID{2},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Roles: []string{"auditor"},
},
exp: audit.Map{
"user_id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"organization_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"roles": audit.OldNew{Old: ([]string)(nil), New: []string{"auditor"}},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.Organization](),
right: database.Organization{
ID: uuid.UUID{1},
Name: "rust developers",
Description: "an organization for rust developers",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"name": audit.OldNew{Old: "", New: "rust developers"},
"description": audit.OldNew{Old: "", New: "an organization for rust developers"},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.Template](),
right: database.Template{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{2},
Deleted: false,
Name: "rust",
Provisioner: database.ProvisionerTypeTerraform,
ActiveVersionID: uuid.UUID{3},
MaxTtl: int64(time.Hour),
MinAutostartInterval: int64(time.Minute),
CreatedBy: uuid.UUID{4},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"organization_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"name": audit.OldNew{Old: "", New: "rust"},
"provisioner": audit.OldNew{Old: database.ProvisionerType(""), New: database.ProvisionerTypeTerraform},
"active_version_id": audit.OldNew{Old: "", New: uuid.UUID{3}.String()},
"max_ttl": audit.OldNew{Old: int64(0), New: int64(time.Hour)},
"min_autostart_interval": audit.OldNew{Old: int64(0), New: int64(time.Minute)},
"created_by": audit.OldNew{Old: "", New: uuid.UUID{4}.String()},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.TemplateVersion](),
right: database.TemplateVersion{
ID: uuid.UUID{1},
TemplateID: uuid.NullUUID{UUID: uuid.UUID{2}, Valid: true},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"template_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"organization_id": audit.OldNew{Old: "", New: uuid.UUID{3}.String()},
"created_by": audit.OldNew{Old: "", New: uuid.UUID{4}.String()},
"name": audit.OldNew{Old: "", New: "rust"},
},
},
{
name: "CreateNullTemplateID",
left: audit.Empty[database.TemplateVersion](),
right: database.TemplateVersion{
ID: uuid.UUID{1},
TemplateID: uuid.NullUUID{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"organization_id": audit.OldNew{Old: "", New: uuid.UUID{3}.String()},
"created_by": audit.OldNew{Old: "null", New: uuid.UUID{4}.String()},
"name": audit.OldNew{Old: "", New: "rust"},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.User](),
right: database.User{
ID: uuid.UUID{1},
Email: "colin@coder.com",
Username: "colin",
HashedPassword: []byte("hunter2ButHashed"),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Status: database.UserStatusActive,
RBACRoles: []string{"omega admin"},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"email": audit.OldNew{Old: "", New: "colin@coder.com"},
"username": audit.OldNew{Old: "", New: "colin"},
"hashed_password": audit.OldNew{Old: ([]byte)(nil), New: ([]byte)(nil), Secret: true},
"status": audit.OldNew{Old: database.UserStatus(""), New: database.UserStatusActive},
"rbac_roles": audit.OldNew{Old: ([]string)(nil), New: []string{"omega admin"}},
},
},
})
runDiffTests(t, []diffTest{
{
name: "Create",
left: audit.Empty[database.Workspace](),
right: database.Workspace{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OwnerID: uuid.UUID{2},
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{String: "0 12 * * 1-5", Valid: true},
Ttl: sql.NullInt64{Int64: int64(8 * time.Hour), Valid: true},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"owner_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"template_id": audit.OldNew{Old: "", New: uuid.UUID{3}.String()},
"name": audit.OldNew{Old: "", New: "rust workspace"},
"autostart_schedule": audit.OldNew{Old: "", New: "0 12 * * 1-5"},
"ttl": audit.OldNew{Old: int64(0), New: int64(8 * time.Hour)}, // XXX: pq still does not support time.Duration
},
},
{
name: "NullSchedules",
left: audit.Empty[database.Workspace](),
right: database.Workspace{
ID: uuid.UUID{1},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
OwnerID: uuid.UUID{2},
TemplateID: uuid.UUID{3},
Name: "rust workspace",
AutostartSchedule: sql.NullString{},
Ttl: sql.NullInt64{},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"owner_id": audit.OldNew{Old: "", New: uuid.UUID{2}.String()},
"template_id": audit.OldNew{Old: "", New: uuid.UUID{3}.String()},
"name": audit.OldNew{Old: "", New: "rust workspace"},
},
},
})
}
func runDiffTests(t *testing.T, tests []diffTest) {
t.Helper()
for _, test := range tests {
typName := reflect.TypeOf(test.left).Name()
t.Run(typName+"/"+test.name, func(t *testing.T) {
t.Parallel()
require.Equal(t,
test.exp,
diffValues(test.left, test.right, AuditableResources),
)
})
}
}

View File

@ -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)
}

View File

@ -6,19 +6,6 @@ import (
"github.com/coder/coder/coderd/database"
)
// Auditable is mostly a marker interface. It contains a definitive list of all
// auditable types. If you want to audit a new type, first define it in
// AuditableResources, then add it to this interface.
type Auditable interface {
database.GitSSHKey |
database.OrganizationMember |
database.Organization |
database.Template |
database.TemplateVersion |
database.User |
database.Workspace
}
type Action string
const (

View File

@ -18,7 +18,6 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"

View File

@ -34,6 +34,41 @@ export interface AssignableRoles extends Role {
readonly assignable: boolean
}
// From codersdk/audit.go
export type AuditDiff = Record<string, AuditDiffField>
// From codersdk/audit.go
export interface AuditDiffField {
// eslint-disable-next-line
readonly Old: any
// eslint-disable-next-line
readonly New: any
readonly Secret: boolean
}
// From codersdk/audit.go
export interface AuditLog {
readonly id: string
readonly request_id: string
readonly time: string
readonly organization_id: string
// Named type "net/netip.Addr" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly ip: any
readonly user_agent: string
readonly resource_type: ResourceType
readonly resource_id: string
readonly resource_target: string
readonly resource_icon: string
readonly action: AuditAction
readonly diff: AuditDiff
readonly status_code: number
// This is likely an enum in an external package ("encoding/json.RawMessage")
readonly additional_fields: string
readonly description: string
readonly user?: User
}
// From codersdk/users.go
export interface AuthMethods {
readonly password: boolean
@ -576,6 +611,9 @@ export interface WorkspaceResourceMetadata {
readonly sensitive: boolean
}
// From codersdk/audit.go
export type AuditAction = "create" | "delete" | "write"
// From codersdk/workspacebuilds.go
export type BuildReason = "autostart" | "autostop" | "initiator"
@ -618,6 +656,9 @@ export type ProvisionerStorageMethod = "file"
// From codersdk/organizations.go
export type ProvisionerType = "echo" | "terraform"
// From codersdk/audit.go
export type ResourceType = "organization" | "template" | "template_version" | "user" | "workspace"
// From codersdk/users.go
export type UserStatus = "active" | "suspended"