feat: add audit package (#1046)

This commit is contained in:
Colin Adler
2022-04-25 13:57:59 -05:00
committed by GitHub
parent a2dd618849
commit 2a57ea757a
8 changed files with 411 additions and 2 deletions

111
coderd/audit/diff.go Normal file
View File

@ -0,0 +1,111 @@
package audit
import (
"fmt"
"reflect"
)
// TODO: this might need to be in the database package.
type Map map[string]interface{}
func Empty[T Auditable]() T {
var t T
return 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{}
}
return diffValues(left, right, AuditableResources)
}
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
}
// 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
}
// 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
}

View File

@ -0,0 +1,163 @@
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),
)
})
}
}

59
coderd/audit/diff_test.go Normal file
View File

@ -0,0 +1,59 @@
package audit_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
)
func TestDiff(t *testing.T) {
t.Parallel()
t.Run("Normal", func(t *testing.T) {
t.Parallel()
runDiffTests(t, []diffTest[database.User]{
{
name: "LeftEmpty",
left: audit.Empty[database.User](), right: database.User{Username: "colin", Email: "colin@coder.com"},
exp: audit.Map{
"email": "colin@coder.com",
},
},
{
name: "RightEmpty",
left: database.User{Username: "colin", Email: "colin@coder.com"}, right: audit.Empty[database.User](),
exp: audit.Map{
"email": "",
},
},
{
name: "NoChange",
left: audit.Empty[database.User](), right: audit.Empty[database.User](),
exp: audit.Map{},
},
})
})
}
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()
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.Equal(t,
test.exp,
audit.Diff(test.left, test.right),
)
})
}
}

73
coderd/audit/table.go Normal file
View File

@ -0,0 +1,73 @@
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.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.User{}: {
"id": ActionIgnore, // Never changes.
"email": ActionTrack, // A user can edit their email.
"username": ActionIgnore, // A user cannot change their username.
"hashed_password": ActionSecret, // A user can change their own password.
"created_at": ActionIgnore, // Never changes.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
},
&database.Workspace{}: {
"id": ActionIgnore, // Never changes.
"created_at": ActionIgnore, // Never changes.
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
"owner_id": ActionIgnore, // We don't allow workspaces to change ownership.
"template_id": ActionIgnore, // We don't allow workspaces to change templates.
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
"name": ActionIgnore, // We don't allow workspaces to change names.
"autostart_schedule": ActionTrack, // Autostart schedules are directly editable by users.
"autostop_schedule": ActionTrack, // Autostart schedules are directly editable by users.
},
})
// 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)
}

View File

@ -167,7 +167,7 @@ func New(options *Options) (http.Handler, func()) {
})
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Post("/", api.postUsers)
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))

View File

@ -152,7 +152,7 @@ func (api *api) users(rw http.ResponseWriter, r *http.Request) {
}
// Creates a new user.
func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) {
func (api *api) postUser(rw http.ResponseWriter, r *http.Request) {
apiKey := httpmw.APIKey(r)
var createUser codersdk.CreateUserRequest