mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: rearrange audit logging code into enterprise folder (#3741)
This commit is contained in:
@@ -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{} }
|
||||
+35
-153
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"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 (
|
||||
// ActionIgnore ignores diffing for the field.
|
||||
ActionIgnore = "ignore"
|
||||
// ActionTrack includes the value in the diff if the value changed.
|
||||
ActionTrack = "track"
|
||||
// ActionSecret includes a zero value of the same type if the value changed.
|
||||
// It lets you indicate that a value changed, but without leaking its
|
||||
// contents.
|
||||
ActionSecret = "secret"
|
||||
)
|
||||
|
||||
// Table is a map of struct names to a map of field names that indicate that
|
||||
// field's AuditType.
|
||||
type Table map[string]map[string]Action
|
||||
|
||||
// AuditableResources contains a definitive list of all auditable resources and
|
||||
// which fields are auditable.
|
||||
var AuditableResources = auditMap(map[any]map[string]Action{
|
||||
&database.GitSSHKey{}: {
|
||||
"user_id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"private_key": ActionSecret, // We don't want to expose private keys in diffs.
|
||||
"public_key": ActionTrack, // Public keys are ok to expose in a diff.
|
||||
},
|
||||
&database.OrganizationMember{}: {
|
||||
"user_id": ActionTrack,
|
||||
"organization_id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"roles": ActionTrack,
|
||||
},
|
||||
&database.Organization{}: {
|
||||
"id": ActionTrack,
|
||||
"name": ActionTrack,
|
||||
"description": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
},
|
||||
&database.Template{}: {
|
||||
"id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"organization_id": ActionTrack,
|
||||
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
|
||||
"name": ActionTrack,
|
||||
"provisioner": ActionTrack,
|
||||
"active_version_id": ActionTrack,
|
||||
"description": ActionTrack,
|
||||
"icon": ActionTrack,
|
||||
"max_ttl": ActionTrack,
|
||||
"min_autostart_interval": ActionTrack,
|
||||
"created_by": ActionTrack,
|
||||
},
|
||||
&database.TemplateVersion{}: {
|
||||
"id": ActionTrack,
|
||||
"template_id": ActionTrack,
|
||||
"organization_id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"name": ActionTrack,
|
||||
"readme": ActionTrack,
|
||||
"job_id": ActionIgnore, // Not helpful in a diff because jobs aren't tracked in audit logs.
|
||||
"created_by": ActionTrack,
|
||||
},
|
||||
&database.User{}: {
|
||||
"id": ActionTrack,
|
||||
"email": ActionTrack,
|
||||
"username": ActionTrack,
|
||||
"hashed_password": ActionSecret, // Do not expose a users hashed password.
|
||||
"created_at": ActionIgnore, // Never changes.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"status": ActionTrack,
|
||||
"rbac_roles": ActionTrack,
|
||||
"login_type": ActionIgnore,
|
||||
},
|
||||
&database.Workspace{}: {
|
||||
"id": ActionTrack,
|
||||
"created_at": ActionIgnore, // Never changes.
|
||||
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
|
||||
"owner_id": ActionTrack,
|
||||
"organization_id": ActionTrack,
|
||||
"template_id": ActionTrack,
|
||||
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
|
||||
"name": ActionTrack,
|
||||
"autostart_schedule": ActionTrack,
|
||||
"ttl": ActionTrack,
|
||||
},
|
||||
})
|
||||
|
||||
// auditMap converts a map of struct pointers to a map of struct names as
|
||||
// strings. It's a convenience wrapper so that structs can be passed in by value
|
||||
// instead of manually typing struct names as strings.
|
||||
func auditMap(m map[any]map[string]Action) Table {
|
||||
out := make(Table, len(m))
|
||||
|
||||
for k, v := range m {
|
||||
out[structName(reflect.TypeOf(k).Elem())] = v
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func (t Action) String() string {
|
||||
return string(t)
|
||||
}
|
||||
Generated
+4
-1
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE audit_logs
|
||||
DROP COLUMN additional_fields,
|
||||
DROP COLUMN request_id,
|
||||
DROP COLUMN resource_icon;
|
||||
@@ -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;
|
||||
+15
-12
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 *;
|
||||
|
||||
Reference in New Issue
Block a user