Skip to content

Commit 4e26e32

Browse files
authored
feat: add auditing to user routes (#3961)
1 parent c026464 commit 4e26e32

File tree

11 files changed

+259
-62
lines changed

11 files changed

+259
-62
lines changed

coderd/audit/audit.go

+15
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,18 @@ func (nop) Export(context.Context, database.AuditLog) error {
2222
}
2323

2424
func (nop) diff(any, any) Map { return Map{} }
25+
26+
func NewMock() *MockAuditor {
27+
return &MockAuditor{}
28+
}
29+
30+
type MockAuditor struct {
31+
AuditLogs []database.AuditLog
32+
}
33+
34+
func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error {
35+
a.AuditLogs = append(a.AuditLogs, alog)
36+
return nil
37+
}
38+
39+
func (*MockAuditor) diff(any, any) Map { return Map{} }

coderd/audit/request.go

+106-25
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package audit
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"net"
78
"net/http"
89

@@ -11,20 +12,17 @@ import (
1112

1213
"cdr.dev/slog"
1314
"github.com/coder/coder/coderd/database"
15+
"github.com/coder/coder/coderd/features"
1416
"github.com/coder/coder/coderd/httpapi"
1517
"github.com/coder/coder/coderd/httpmw"
1618
)
1719

1820
type RequestParams struct {
19-
Audit Auditor
20-
Log slog.Logger
21-
22-
Request *http.Request
23-
ResourceID uuid.UUID
24-
ResourceTarget string
25-
Action database.AuditAction
26-
ResourceType database.ResourceType
27-
Actor uuid.UUID
21+
Features features.Service
22+
Log slog.Logger
23+
24+
Request *http.Request
25+
Action database.AuditAction
2826
}
2927

3028
type Request[T Auditable] struct {
@@ -34,6 +32,63 @@ type Request[T Auditable] struct {
3432
New T
3533
}
3634

35+
func ResourceTarget[T Auditable](tgt T) string {
36+
switch typed := any(tgt).(type) {
37+
case database.Organization:
38+
return typed.Name
39+
case database.Template:
40+
return typed.Name
41+
case database.TemplateVersion:
42+
return typed.Name
43+
case database.User:
44+
return typed.Username
45+
case database.Workspace:
46+
return typed.Name
47+
case database.GitSSHKey:
48+
return typed.PublicKey
49+
default:
50+
panic(fmt.Sprintf("unknown resource %T", tgt))
51+
}
52+
}
53+
54+
func ResourceID[T Auditable](tgt T) uuid.UUID {
55+
switch typed := any(tgt).(type) {
56+
case database.Organization:
57+
return typed.ID
58+
case database.Template:
59+
return typed.ID
60+
case database.TemplateVersion:
61+
return typed.ID
62+
case database.User:
63+
return typed.ID
64+
case database.Workspace:
65+
return typed.ID
66+
case database.GitSSHKey:
67+
return typed.UserID
68+
default:
69+
panic(fmt.Sprintf("unknown resource %T", tgt))
70+
}
71+
}
72+
73+
func ResourceType[T Auditable](tgt T) database.ResourceType {
74+
switch any(tgt).(type) {
75+
case database.Organization:
76+
return database.ResourceTypeOrganization
77+
case database.Template:
78+
return database.ResourceTypeTemplate
79+
case database.TemplateVersion:
80+
return database.ResourceTypeTemplateVersion
81+
case database.User:
82+
return database.ResourceTypeUser
83+
case database.Workspace:
84+
return database.ResourceTypeWorkspace
85+
case database.GitSSHKey:
86+
return database.ResourceTypeGitSshKey
87+
default:
88+
panic(fmt.Sprintf("unknown resource %T", tgt))
89+
}
90+
}
91+
3792
// InitRequest initializes an audit log for a request. It returns a function
3893
// that should be deferred, causing the audit log to be committed when the
3994
// handler returns.
@@ -47,38 +102,64 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request
47102
params: p,
48103
}
49104

105+
feats := struct {
106+
Audit Auditor
107+
}{}
108+
err := p.Features.Get(&feats)
109+
if err != nil {
110+
p.Log.Error(p.Request.Context(), "unable to get auditor interface", slog.Error(err))
111+
return req, func() {}
112+
}
113+
50114
return req, func() {
51115
ctx := context.Background()
116+
logCtx := p.Request.Context()
52117

53-
diff := Diff(p.Audit, req.Old, req.New)
118+
// If no resources were provided, there's nothing we can audit.
119+
if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil {
120+
return
121+
}
122+
123+
diff := Diff(feats.Audit, req.Old, req.New)
54124
diffRaw, _ := json.Marshal(diff)
55125

56126
ip, err := parseIP(p.Request.RemoteAddr)
57127
if err != nil {
58-
p.Log.Warn(ctx, "parse ip", slog.Error(err))
128+
p.Log.Warn(logCtx, "parse ip", slog.Error(err))
59129
}
60130

61-
err = p.Audit.Export(ctx, database.AuditLog{
62-
ID: uuid.New(),
63-
Time: database.Now(),
64-
UserID: p.Actor,
65-
Ip: ip,
66-
UserAgent: p.Request.UserAgent(),
67-
ResourceType: p.ResourceType,
68-
ResourceID: p.ResourceID,
69-
ResourceTarget: p.ResourceTarget,
70-
Action: p.Action,
71-
Diff: diffRaw,
72-
StatusCode: int32(sw.Status),
73-
RequestID: httpmw.RequestID(p.Request),
131+
err = feats.Audit.Export(ctx, database.AuditLog{
132+
ID: uuid.New(),
133+
Time: database.Now(),
134+
UserID: httpmw.APIKey(p.Request).UserID,
135+
Ip: ip,
136+
UserAgent: p.Request.UserAgent(),
137+
ResourceType: either(req.Old, req.New, ResourceType[T]),
138+
ResourceID: either(req.Old, req.New, ResourceID[T]),
139+
ResourceTarget: either(req.Old, req.New, ResourceTarget[T]),
140+
Action: p.Action,
141+
Diff: diffRaw,
142+
StatusCode: int32(sw.Status),
143+
RequestID: httpmw.RequestID(p.Request),
144+
AdditionalFields: json.RawMessage("{}"),
74145
})
75146
if err != nil {
76-
p.Log.Error(ctx, "export audit log", slog.Error(err))
147+
p.Log.Error(logCtx, "export audit log", slog.Error(err))
77148
return
78149
}
79150
}
80151
}
81152

153+
func either[T Auditable, R any](old, new T, fn func(T) R) R {
154+
if ResourceID(new) != uuid.Nil {
155+
return fn(new)
156+
} else if ResourceID(old) != uuid.Nil {
157+
return fn(old)
158+
} else {
159+
panic("both old and new are nil")
160+
}
161+
}
162+
82163
func parseIP(ipStr string) (pqtype.Inet, error) {
83164
var err error
84165

coderd/coderd.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/coder/coder/buildinfo"
2828
"github.com/coder/coder/coderd/awsidentity"
2929
"github.com/coder/coder/coderd/database"
30+
"github.com/coder/coder/coderd/features"
3031
"github.com/coder/coder/coderd/gitsshkey"
3132
"github.com/coder/coder/coderd/httpapi"
3233
"github.com/coder/coder/coderd/httpmw"
@@ -72,7 +73,7 @@ type Options struct {
7273
TracerProvider *sdktrace.TracerProvider
7374
AutoImportTemplates []AutoImportTemplate
7475
LicenseHandler http.Handler
75-
FeaturesService FeaturesService
76+
FeaturesService features.Service
7677

7778
TailscaleEnable bool
7879
TailnetCoordinator *tailnet.Coordinator
@@ -113,7 +114,7 @@ func New(options *Options) *API {
113114
options.LicenseHandler = licenses()
114115
}
115116
if options.FeaturesService == nil {
116-
options.FeaturesService = featuresService{}
117+
options.FeaturesService = &featuresService{}
117118
}
118119

119120
siteCacheDir := options.CacheDir

coderd/coderdtest/coderdtest.go

+8
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import (
4343
"cdr.dev/slog"
4444
"cdr.dev/slog/sloggers/slogtest"
4545
"github.com/coder/coder/coderd"
46+
"github.com/coder/coder/coderd/audit"
4647
"github.com/coder/coder/coderd/autobuild/executor"
4748
"github.com/coder/coder/coderd/awsidentity"
4849
"github.com/coder/coder/coderd/database"
@@ -74,6 +75,7 @@ type Options struct {
7475
AutoImportTemplates []coderd.AutoImportTemplate
7576
AutobuildTicker <-chan time.Time
7677
AutobuildStats chan<- executor.Stats
78+
Auditor audit.Auditor
7779

7880
// IncludeProvisionerDaemon when true means to start an in-memory provisionerD
7981
IncludeProvisionerDaemon bool
@@ -197,6 +199,11 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
197199
_ = turnServer.Close()
198200
})
199201

202+
features := coderd.DisabledImplementations
203+
if options.Auditor != nil {
204+
features.Auditor = options.Auditor
205+
}
206+
200207
// We set the handler after server creation for the access URL.
201208
coderAPI := options.APIBuilder(&coderd.Options{
202209
AgentConnectionUpdateFrequency: 150 * time.Millisecond,
@@ -240,6 +247,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c
240247
AutoImportTemplates: options.AutoImportTemplates,
241248
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
242249
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
250+
FeaturesService: coderd.NewMockFeaturesService(features),
243251
})
244252
t.Cleanup(func() {
245253
_ = coderAPI.Close()

coderd/features.go

+21-17
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,31 @@ import (
77
"golang.org/x/xerrors"
88

99
"github.com/coder/coder/coderd/audit"
10+
"github.com/coder/coder/coderd/features"
1011
"github.com/coder/coder/coderd/httpapi"
1112
"github.com/coder/coder/codersdk"
1213
)
1314

14-
// FeaturesService is the interface for interacting with enterprise features.
15-
type FeaturesService interface {
16-
EntitlementsAPI(w http.ResponseWriter, r *http.Request)
17-
18-
// Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a
19-
// struct type containing feature interfaces as fields. The FeatureService sets all fields to
20-
// the correct implementations depending on whether the features are turned on.
21-
Get(s any) error
15+
func NewMockFeaturesService(feats FeatureInterfaces) features.Service {
16+
return &featuresService{
17+
feats: &feats,
18+
}
2219
}
2320

24-
type featuresService struct{}
21+
type featuresService struct {
22+
feats *FeatureInterfaces
23+
}
2524

26-
func (featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request) {
27-
features := make(map[string]codersdk.Feature)
25+
func (*featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request) {
26+
feats := make(map[string]codersdk.Feature)
2827
for _, f := range codersdk.FeatureNames {
29-
features[f] = codersdk.Feature{
28+
feats[f] = codersdk.Feature{
3029
Entitlement: codersdk.EntitlementNotEntitled,
3130
Enabled: false,
3231
}
3332
}
3433
httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{
35-
Features: features,
34+
Features: feats,
3635
Warnings: []string{},
3736
HasLicense: false,
3837
})
@@ -42,7 +41,7 @@ func (featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request)
4241
// struct type containing feature interfaces as fields. The AGPL featureService always returns the
4342
// "disabled" version of the feature interface because it doesn't include any enterprise features
4443
// by definition.
45-
func (featuresService) Get(ps any) error {
44+
func (f *featuresService) Get(ps any) error {
4645
if reflect.TypeOf(ps).Kind() != reflect.Pointer {
4746
return xerrors.New("input must be pointer to struct")
4847
}
@@ -56,7 +55,7 @@ func (featuresService) Get(ps any) error {
5655
if tf.Kind() != reflect.Interface {
5756
return xerrors.Errorf("fields of input struct must be interfaces: %s", tf.String())
5857
}
59-
err := setImplementation(vf, tf)
58+
err := f.setImplementation(vf, tf)
6059
if err != nil {
6160
return err
6261
}
@@ -66,11 +65,16 @@ func (featuresService) Get(ps any) error {
6665

6766
// setImplementation finds the correct implementation for the field's type, and sets it on the
6867
// struct. It returns an error if unsuccessful
69-
func setImplementation(vf reflect.Value, tf reflect.Type) error {
68+
func (f *featuresService) setImplementation(vf reflect.Value, tf reflect.Type) error {
69+
feats := f.feats
70+
if feats == nil {
71+
feats = &DisabledImplementations
72+
}
73+
7074
// when we get more than a few features it might make sense to have a data structure for finding
7175
// the correct implementation that's faster than just a linear search, but for now just spin
7276
// through the implementations we have.
73-
vd := reflect.ValueOf(DisabledImplementations)
77+
vd := reflect.ValueOf(*feats)
7478
for j := 0; j < vd.NumField(); j++ {
7579
vdf := vd.Field(j)
7680
if vdf.Type() == tf {

coderd/features/features.go

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package features
2+
3+
import "net/http"
4+
5+
// Service is the interface for interacting with enterprise features.
6+
type Service interface {
7+
EntitlementsAPI(w http.ResponseWriter, r *http.Request)
8+
9+
// Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a
10+
// struct type containing feature interfaces as fields. The FeatureService sets all fields to
11+
// the correct implementations depending on whether the features are turned on.
12+
Get(s any) error
13+
}

coderd/features_internal_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestEntitlements(t *testing.T) {
1919
t.Parallel()
2020
r := httptest.NewRequest("GET", "https://example.com/api/v2/entitlements", nil)
2121
rw := httptest.NewRecorder()
22-
featuresService{}.EntitlementsAPI(rw, r)
22+
(&featuresService{}).EntitlementsAPI(rw, r)
2323
resp := rw.Result()
2424
defer resp.Body.Close()
2525
assert.Equal(t, http.StatusOK, resp.StatusCode)

0 commit comments

Comments
 (0)