Skip to content

Commit e7d0201

Browse files
committed
feat: add audit package
1 parent cf8a20d commit e7d0201

File tree

5 files changed

+274
-2
lines changed

5 files changed

+274
-2
lines changed

coderd/audit/diff.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package audit
2+
3+
import (
4+
"fmt"
5+
"reflect"
6+
)
7+
8+
// TODO: this might need to be in the database package.
9+
type DiffMap map[string]interface{}
10+
11+
func Empty[T Auditable]() T {
12+
var t T
13+
return t
14+
}
15+
16+
func Diff[T Auditable](old, new T) DiffMap {
17+
// Values are equal, return an empty diff.
18+
if reflect.DeepEqual(old, new) {
19+
return DiffMap{}
20+
}
21+
22+
return diffValues(old, new)
23+
}
24+
25+
func diffValues[T any](old, new T) DiffMap {
26+
var (
27+
baseDiff = DiffMap{}
28+
29+
oldV = reflect.ValueOf(old)
30+
31+
newV = reflect.ValueOf(new)
32+
newT = reflect.TypeOf(new)
33+
34+
diffKey = AuditableResources[newT.Name()]
35+
)
36+
37+
if diffKey == nil {
38+
panic(fmt.Sprintf("dev error: type %T attempted audit but not auditable", new))
39+
}
40+
41+
for i := 0; i < newT.NumField(); i++ {
42+
var (
43+
oldF = oldV.Field(i)
44+
newF = newV.Field(i)
45+
46+
oldI = oldF.Interface()
47+
newI = newF.Interface()
48+
49+
diffName = newT.Field(i).Tag.Get("json")
50+
)
51+
52+
atype, ok := diffKey[diffName]
53+
if !ok {
54+
panic(fmt.Sprintf("dev error: field %q lacks audit information", diffName))
55+
}
56+
57+
if atype == ActionIgnore {
58+
continue
59+
}
60+
61+
// If the field is a pointer, dereference it. Nil pointers are coerced
62+
// to the zero value of their underlying type.
63+
if oldF.Kind() == reflect.Ptr && newF.Kind() == reflect.Ptr {
64+
oldF, newF = derefPointer(oldF), derefPointer(newF)
65+
oldI, newI = oldF.Interface(), newF.Interface()
66+
}
67+
68+
// Recursively walk up nested structs.
69+
if newF.Kind() == reflect.Struct {
70+
baseDiff[diffName] = diffValues(oldI, newI)
71+
continue
72+
}
73+
74+
if !reflect.DeepEqual(oldI, newI) {
75+
switch atype {
76+
case ActionAuditable:
77+
baseDiff[diffName] = newI
78+
case ActionSecret:
79+
baseDiff[diffName] = reflect.Zero(newF.Type()).Interface()
80+
}
81+
}
82+
}
83+
84+
return baseDiff
85+
}
86+
87+
// derefPointer deferences a reflect.Value that is a pointer to its underlying
88+
// value. It dereferences recursively until it finds a non-pointer value. If the
89+
// pointer is nil, it will be coerced to the zero value of the underlying type.
90+
func derefPointer(ptr reflect.Value) reflect.Value {
91+
if !ptr.IsNil() {
92+
// Grab the value the pointer references.
93+
ptr = ptr.Elem()
94+
} else {
95+
// Coerce nil ptrs to zero'd values of their underlying type.
96+
ptr = reflect.Zero(ptr.Type().Elem())
97+
}
98+
99+
// Recursively deref nested pointers.
100+
if ptr.Kind() == reflect.Ptr {
101+
return derefPointer(ptr)
102+
}
103+
104+
return ptr
105+
}

coderd/audit/diff_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package audit_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/coder/coder/coderd/audit"
9+
"github.com/coder/coder/coderd/database"
10+
)
11+
12+
func TestDiff(t *testing.T) {
13+
t.Parallel()
14+
15+
t.Run("Normal", func(t *testing.T) {
16+
t.Parallel()
17+
18+
runDiffTests(t, []diffTest[database.User]{
19+
{
20+
name: "LeftEmpty",
21+
left: audit.Empty[database.User](), right: database.User{Name: "colin", Email: "colin@coder.com"},
22+
exp: audit.DiffMap{
23+
"name": "colin",
24+
"email": "colin@coder.com",
25+
},
26+
},
27+
{
28+
name: "RightEmpty",
29+
left: database.User{Name: "colin", Email: "colin@coder.com"}, right: audit.Empty[database.User](),
30+
exp: audit.DiffMap{
31+
"name": "",
32+
"email": "",
33+
},
34+
},
35+
{
36+
name: "NoChange",
37+
left: audit.Empty[database.User](), right: audit.Empty[database.User](),
38+
exp: audit.DiffMap{},
39+
},
40+
})
41+
})
42+
}
43+
44+
type diffTest[T audit.Auditable] struct {
45+
name string
46+
left, right T
47+
exp audit.DiffMap
48+
}
49+
50+
func runDiffTests[T audit.Auditable](t *testing.T, tests []diffTest[T]) {
51+
t.Helper()
52+
53+
for _, test := range tests {
54+
t.Run(test.name, func(t *testing.T) {
55+
require.Equal(t,
56+
test.exp,
57+
audit.Diff(test.left, test.right),
58+
)
59+
})
60+
}
61+
}

coderd/audit/table.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package audit
2+
3+
import (
4+
"reflect"
5+
6+
"golang.org/x/xerrors"
7+
8+
"github.com/coder/coder/coderd/database"
9+
)
10+
11+
// Auditable is mostly a marker interface. It contains a definitive list of all
12+
// auditable types. If you want to audit a new type, first define it in
13+
// AuditableResources, then add it to this interface.
14+
type Auditable interface {
15+
database.User |
16+
database.Workspace
17+
}
18+
19+
type Action int
20+
21+
const (
22+
// ActionIgnore ignores diffing for the field.
23+
ActionIgnore = iota
24+
// ActionAuditable includes the value in the diff if the value changed.
25+
ActionAuditable
26+
// ActionSecret includes a zero value of the same type if the value changed.
27+
// It lets you indicate that a value changed, but without leaking its
28+
// contents.
29+
ActionSecret
30+
)
31+
32+
// Map is a map of struct names to a map of field names that indicate that
33+
// field's AuditType.
34+
type Map map[string]map[string]Action
35+
36+
// AuditableResources contains a definitive list of all auditable resources and
37+
// which fields are auditable.
38+
var AuditableResources = auditMap(map[any]map[string]Action{
39+
&database.User{}: {
40+
"id": ActionIgnore, // Never changes.
41+
"email": ActionAuditable, // A user can edit their email.
42+
"name": ActionAuditable, // A user can edit their name.
43+
"revoked": ActionAuditable, // An admin can revoke a user. This is different from deletion, which is implicit.
44+
"login_type": ActionAuditable, // An admin can update the login type of a user.
45+
"hashed_password": ActionSecret, // A user can change their own password.
46+
"created_at": ActionIgnore, // Never changes.
47+
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
48+
"username": ActionIgnore, // A user cannot change their username.
49+
},
50+
&database.Workspace{}: {
51+
"id": ActionIgnore, // Never changes.
52+
"created_at": ActionIgnore, // Never changes.
53+
"updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff.
54+
"owner_id": ActionIgnore, // We don't allow workspaces to change ownership.
55+
"template_id": ActionIgnore, // We don't allow workspaces to change templates.
56+
"deleted": ActionIgnore, // Changes, but is implicit when a delete event is fired.
57+
"name": ActionIgnore, // We don't allow workspaces to change names.
58+
"autostart_schedule": ActionAuditable, // Autostart schedules are directly editable by users.
59+
"autostop_schedule": ActionAuditable, // Autostart schedules are directly editable by users.
60+
},
61+
})
62+
63+
// auditMap converts a typed AuditMap into
64+
func auditMap(m map[any]map[string]Action) Map {
65+
out := make(Map, len(m))
66+
67+
for k, v := range m {
68+
out[reflect.TypeOf(k).Elem().Name()] = v
69+
}
70+
71+
return out
72+
}
73+
74+
func (t Action) String() string {
75+
switch t {
76+
case ActionIgnore:
77+
return "ignore"
78+
case ActionAuditable:
79+
return "auditable"
80+
case ActionSecret:
81+
return "secret"
82+
default:
83+
return "unknown"
84+
}
85+
}
86+
87+
func (t Action) MarshalJSON() ([]byte, error) {
88+
return []byte(t.String()), nil
89+
}
90+
91+
func (t *Action) UnmarshalJSON(b []byte) error {
92+
str := string(b)
93+
94+
switch str {
95+
case "ignore":
96+
*t = ActionIgnore
97+
case "auditable":
98+
*t = ActionAuditable
99+
case "secret":
100+
*t = ActionSecret
101+
default:
102+
return xerrors.Errorf("unknown AuditType %q", str)
103+
}
104+
105+
return nil
106+
}

coderd/coderd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func New(options *Options) (http.Handler, func()) {
144144
r.Post("/logout", api.postLogout)
145145
r.Group(func(r chi.Router) {
146146
r.Use(httpmw.ExtractAPIKey(options.Database, nil))
147-
r.Post("/", api.postUsers)
147+
r.Post("/", api.postUser)
148148
r.Route("/{user}", func(r chi.Router) {
149149
r.Use(httpmw.ExtractUserParam(options.Database))
150150
r.Get("/", api.userByName)

coderd/users.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func (api *api) postFirstUser(rw http.ResponseWriter, r *http.Request) {
144144
}
145145

146146
// Creates a new user.
147-
func (api *api) postUsers(rw http.ResponseWriter, r *http.Request) {
147+
func (api *api) postUser(rw http.ResponseWriter, r *http.Request) {
148148
apiKey := httpmw.APIKey(r)
149149

150150
var createUser codersdk.CreateUserRequest

0 commit comments

Comments
 (0)