diff --git a/coderd/audit/audit.go b/coderd/audit/audit.go index f1801987bbebe..92f447130503c 100644 --- a/coderd/audit/audit.go +++ b/coderd/audit/audit.go @@ -22,3 +22,18 @@ func (nop) Export(context.Context, database.AuditLog) error { } func (nop) diff(any, any) Map { return Map{} } + +func NewMock() *MockAuditor { + return &MockAuditor{} +} + +type MockAuditor struct { + AuditLogs []database.AuditLog +} + +func (a *MockAuditor) Export(_ context.Context, alog database.AuditLog) error { + a.AuditLogs = append(a.AuditLogs, alog) + return nil +} + +func (*MockAuditor) diff(any, any) Map { return Map{} } diff --git a/coderd/audit/request.go b/coderd/audit/request.go index d4f1b05feb986..77fb1580de3ad 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -3,6 +3,7 @@ package audit import ( "context" "encoding/json" + "fmt" "net" "net/http" @@ -11,20 +12,17 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/features" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" ) type RequestParams struct { - Audit Auditor - Log slog.Logger - - Request *http.Request - ResourceID uuid.UUID - ResourceTarget string - Action database.AuditAction - ResourceType database.ResourceType - Actor uuid.UUID + Features features.Service + Log slog.Logger + + Request *http.Request + Action database.AuditAction } type Request[T Auditable] struct { @@ -34,6 +32,63 @@ type Request[T Auditable] struct { New T } +func ResourceTarget[T Auditable](tgt T) string { + switch typed := any(tgt).(type) { + case database.Organization: + return typed.Name + case database.Template: + return typed.Name + case database.TemplateVersion: + return typed.Name + case database.User: + return typed.Username + case database.Workspace: + return typed.Name + case database.GitSSHKey: + return typed.PublicKey + default: + panic(fmt.Sprintf("unknown resource %T", tgt)) + } +} + +func ResourceID[T Auditable](tgt T) uuid.UUID { + switch typed := any(tgt).(type) { + case database.Organization: + return typed.ID + case database.Template: + return typed.ID + case database.TemplateVersion: + return typed.ID + case database.User: + return typed.ID + case database.Workspace: + return typed.ID + case database.GitSSHKey: + return typed.UserID + default: + panic(fmt.Sprintf("unknown resource %T", tgt)) + } +} + +func ResourceType[T Auditable](tgt T) database.ResourceType { + switch any(tgt).(type) { + case database.Organization: + return database.ResourceTypeOrganization + case database.Template: + return database.ResourceTypeTemplate + case database.TemplateVersion: + return database.ResourceTypeTemplateVersion + case database.User: + return database.ResourceTypeUser + case database.Workspace: + return database.ResourceTypeWorkspace + case database.GitSSHKey: + return database.ResourceTypeGitSshKey + default: + panic(fmt.Sprintf("unknown resource %T", tgt)) + } +} + // 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. @@ -47,38 +102,64 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request params: p, } + feats := struct { + Audit Auditor + }{} + err := p.Features.Get(&feats) + if err != nil { + p.Log.Error(p.Request.Context(), "unable to get auditor interface", slog.Error(err)) + return req, func() {} + } + return req, func() { ctx := context.Background() + logCtx := p.Request.Context() - diff := Diff(p.Audit, req.Old, req.New) + // If no resources were provided, there's nothing we can audit. + if ResourceID(req.Old) == uuid.Nil && ResourceID(req.New) == uuid.Nil { + return + } + + diff := Diff(feats.Audit, req.Old, req.New) diffRaw, _ := json.Marshal(diff) ip, err := parseIP(p.Request.RemoteAddr) if err != nil { - p.Log.Warn(ctx, "parse ip", slog.Error(err)) + p.Log.Warn(logCtx, "parse ip", slog.Error(err)) } - err = p.Audit.Export(ctx, database.AuditLog{ - ID: uuid.New(), - Time: database.Now(), - UserID: p.Actor, - Ip: ip, - UserAgent: p.Request.UserAgent(), - ResourceType: p.ResourceType, - ResourceID: p.ResourceID, - ResourceTarget: p.ResourceTarget, - Action: p.Action, - Diff: diffRaw, - StatusCode: int32(sw.Status), - RequestID: httpmw.RequestID(p.Request), + err = feats.Audit.Export(ctx, database.AuditLog{ + ID: uuid.New(), + Time: database.Now(), + UserID: httpmw.APIKey(p.Request).UserID, + Ip: ip, + UserAgent: p.Request.UserAgent(), + ResourceType: either(req.Old, req.New, ResourceType[T]), + ResourceID: either(req.Old, req.New, ResourceID[T]), + ResourceTarget: either(req.Old, req.New, ResourceTarget[T]), + Action: p.Action, + Diff: diffRaw, + StatusCode: int32(sw.Status), + RequestID: httpmw.RequestID(p.Request), + AdditionalFields: json.RawMessage("{}"), }) if err != nil { - p.Log.Error(ctx, "export audit log", slog.Error(err)) + p.Log.Error(logCtx, "export audit log", slog.Error(err)) return } } } +func either[T Auditable, R any](old, new T, fn func(T) R) R { + if ResourceID(new) != uuid.Nil { + return fn(new) + } else if ResourceID(old) != uuid.Nil { + return fn(old) + } else { + panic("both old and new are nil") + } +} + func parseIP(ipStr string) (pqtype.Inet, error) { var err error diff --git a/coderd/coderd.go b/coderd/coderd.go index b97692758052f..14a18e7986aae 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/buildinfo" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/features" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -72,7 +73,7 @@ type Options struct { TracerProvider *sdktrace.TracerProvider AutoImportTemplates []AutoImportTemplate LicenseHandler http.Handler - FeaturesService FeaturesService + FeaturesService features.Service TailscaleEnable bool TailnetCoordinator *tailnet.Coordinator @@ -113,7 +114,7 @@ func New(options *Options) *API { options.LicenseHandler = licenses() } if options.FeaturesService == nil { - options.FeaturesService = featuresService{} + options.FeaturesService = &featuresService{} } siteCacheDir := options.CacheDir diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index f4f26dc05d642..490dce5a125a6 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -43,6 +43,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" @@ -74,6 +75,7 @@ type Options struct { AutoImportTemplates []coderd.AutoImportTemplate AutobuildTicker <-chan time.Time AutobuildStats chan<- executor.Stats + Auditor audit.Auditor // IncludeProvisionerDaemon when true means to start an in-memory provisionerD IncludeProvisionerDaemon bool @@ -197,6 +199,11 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c _ = turnServer.Close() }) + features := coderd.DisabledImplementations + if options.Auditor != nil { + features.Auditor = options.Auditor + } + // We set the handler after server creation for the access URL. coderAPI := options.APIBuilder(&coderd.Options{ AgentConnectionUpdateFrequency: 150 * time.Millisecond, @@ -240,6 +247,7 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c AutoImportTemplates: options.AutoImportTemplates, MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, + FeaturesService: coderd.NewMockFeaturesService(features), }) t.Cleanup(func() { _ = coderAPI.Close() diff --git a/coderd/features.go b/coderd/features.go index ecc720e4db4e2..594fad2e38423 100644 --- a/coderd/features.go +++ b/coderd/features.go @@ -7,32 +7,31 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/features" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" ) -// FeaturesService is the interface for interacting with enterprise features. -type FeaturesService interface { - EntitlementsAPI(w http.ResponseWriter, r *http.Request) - - // Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a - // struct type containing feature interfaces as fields. The FeatureService sets all fields to - // the correct implementations depending on whether the features are turned on. - Get(s any) error +func NewMockFeaturesService(feats FeatureInterfaces) features.Service { + return &featuresService{ + feats: &feats, + } } -type featuresService struct{} +type featuresService struct { + feats *FeatureInterfaces +} -func (featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request) { - features := make(map[string]codersdk.Feature) +func (*featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request) { + feats := make(map[string]codersdk.Feature) for _, f := range codersdk.FeatureNames { - features[f] = codersdk.Feature{ + feats[f] = codersdk.Feature{ Entitlement: codersdk.EntitlementNotEntitled, Enabled: false, } } httpapi.Write(rw, http.StatusOK, codersdk.Entitlements{ - Features: features, + Features: feats, Warnings: []string{}, HasLicense: false, }) @@ -42,7 +41,7 @@ func (featuresService) EntitlementsAPI(rw http.ResponseWriter, _ *http.Request) // struct type containing feature interfaces as fields. The AGPL featureService always returns the // "disabled" version of the feature interface because it doesn't include any enterprise features // by definition. -func (featuresService) Get(ps any) error { +func (f *featuresService) Get(ps any) error { if reflect.TypeOf(ps).Kind() != reflect.Pointer { return xerrors.New("input must be pointer to struct") } @@ -56,7 +55,7 @@ func (featuresService) Get(ps any) error { if tf.Kind() != reflect.Interface { return xerrors.Errorf("fields of input struct must be interfaces: %s", tf.String()) } - err := setImplementation(vf, tf) + err := f.setImplementation(vf, tf) if err != nil { return err } @@ -66,11 +65,16 @@ func (featuresService) Get(ps any) error { // setImplementation finds the correct implementation for the field's type, and sets it on the // struct. It returns an error if unsuccessful -func setImplementation(vf reflect.Value, tf reflect.Type) error { +func (f *featuresService) setImplementation(vf reflect.Value, tf reflect.Type) error { + feats := f.feats + if feats == nil { + feats = &DisabledImplementations + } + // when we get more than a few features it might make sense to have a data structure for finding // the correct implementation that's faster than just a linear search, but for now just spin // through the implementations we have. - vd := reflect.ValueOf(DisabledImplementations) + vd := reflect.ValueOf(*feats) for j := 0; j < vd.NumField(); j++ { vdf := vd.Field(j) if vdf.Type() == tf { diff --git a/coderd/features/features.go b/coderd/features/features.go new file mode 100644 index 0000000000000..d44bd5f2e40d1 --- /dev/null +++ b/coderd/features/features.go @@ -0,0 +1,13 @@ +package features + +import "net/http" + +// Service is the interface for interacting with enterprise features. +type Service interface { + EntitlementsAPI(w http.ResponseWriter, r *http.Request) + + // Get returns the implementations for feature interfaces. Parameter `s` must be a pointer to a + // struct type containing feature interfaces as fields. The FeatureService sets all fields to + // the correct implementations depending on whether the features are turned on. + Get(s any) error +} diff --git a/coderd/features_internal_test.go b/coderd/features_internal_test.go index 0c6a7f052b73a..cba3f3da89e50 100644 --- a/coderd/features_internal_test.go +++ b/coderd/features_internal_test.go @@ -19,7 +19,7 @@ func TestEntitlements(t *testing.T) { t.Parallel() r := httptest.NewRequest("GET", "https://example.com/api/v2/entitlements", nil) rw := httptest.NewRecorder() - featuresService{}.EntitlementsAPI(rw, r) + (&featuresService{}).EntitlementsAPI(rw, r) resp := rw.Result() defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/coderd/users.go b/coderd/users.go index 3303c48230b08..6c8046d94665d 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -21,6 +21,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" @@ -254,6 +255,14 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) { // Creates a new user. func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { + aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{ + Features: api.FeaturesService, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) + defer commitAudit() + // Create the user on the site. if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceUser) { httpapi.Forbidden(rw) @@ -319,6 +328,8 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) { return } + aReq.New = user + // Report when users are added! api.Telemetry.Report(&telemetry.Snapshot{ Users: []telemetry.User{telemetry.ConvertUser(user)}, @@ -350,7 +361,17 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) { } func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) + var ( + user = httpmw.UserParam(r) + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Features: api.FeaturesService, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + aReq.Old = user if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceUser) { httpapi.ResourceNotFound(rw) @@ -395,6 +416,7 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) { Username: params.Username, UpdatedAt: database.Now(), }) + aReq.New = updatedUserProfile if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ @@ -418,8 +440,18 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) { func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseWriter, r *http.Request) { return func(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) - apiKey := httpmw.APIKey(r) + var ( + user = httpmw.UserParam(r) + apiKey = httpmw.APIKey(r) + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Features: api.FeaturesService, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + aReq.Old = user if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceUser) { httpapi.ResourceNotFound(rw) @@ -451,7 +483,6 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW Status: status, UpdatedAt: database.Now(), }) - if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error updating user's status to %q.", status), @@ -459,6 +490,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW }) return } + aReq.New = suspendedUser organizations, err := userOrganizationIDs(r.Context(), api, user) if err != nil { @@ -475,9 +507,17 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { var ( - user = httpmw.UserParam(r) - params codersdk.UpdateUserPasswordRequest + user = httpmw.UserParam(r) + params codersdk.UpdateUserPasswordRequest + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Features: api.FeaturesService, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) ) + defer commitAudit() + aReq.Old = user if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceUserData.WithOwner(user.ID.String())) { httpapi.ResourceNotFound(rw) @@ -552,6 +592,10 @@ func (api *API) putUserPassword(rw http.ResponseWriter, r *http.Request) { return } + newUser := user + newUser.HashedPassword = []byte(hashedPassword) + aReq.New = newUser + httpapi.Write(rw, http.StatusNoContent, nil) } @@ -598,10 +642,20 @@ func (api *API) userRoles(rw http.ResponseWriter, r *http.Request) { } func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { - // User is the user to modify. - user := httpmw.UserParam(r) - actorRoles := httpmw.AuthorizationUserRoles(r) - apiKey := httpmw.APIKey(r) + var ( + // User is the user to modify. + user = httpmw.UserParam(r) + actorRoles = httpmw.AuthorizationUserRoles(r) + apiKey = httpmw.APIKey(r) + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Features: api.FeaturesService, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + aReq.Old = user if apiKey.UserID == user.ID { httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ @@ -654,6 +708,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { }) return } + aReq.New = updatedUser organizationIDs, err := userOrganizationIDs(r.Context(), api, user) if err != nil { diff --git a/coderd/users_test.go b/coderd/users_test.go index 190de632a99f1..766dc2c9d00a1 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -10,11 +10,14 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" "github.com/coder/coder/testutil" @@ -374,7 +377,8 @@ func TestPostUsers(t *testing.T) { t.Run("Create", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) user := coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -387,6 +391,8 @@ func TestPostUsers(t *testing.T) { Password: "testing", }) require.NoError(t, err) + assert.Len(t, auditor.AuditLogs, 1) + assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action) }) } @@ -435,7 +441,8 @@ func TestUpdateUserProfile(t *testing.T) { t.Run("UpdateUsername", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -447,6 +454,8 @@ func TestUpdateUserProfile(t *testing.T) { }) require.NoError(t, err) require.Equal(t, userProfile.Username, "newusername") + assert.Len(t, auditor.AuditLogs, 1) + assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action) }) } @@ -496,7 +505,8 @@ func TestUpdateUserPassword(t *testing.T) { }) t.Run("MemberCanUpdateOwnPassword", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) admin := coderdtest.CreateFirstUser(t, client) member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) @@ -508,6 +518,8 @@ func TestUpdateUserPassword(t *testing.T) { Password: "newpassword", }) require.NoError(t, err, "member should be able to update own password") + assert.Len(t, auditor.AuditLogs, 2) + assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action) }) t.Run("MemberCantUpdateOwnPasswordWithoutOldPassword", func(t *testing.T) { t.Parallel() @@ -525,7 +537,8 @@ func TestUpdateUserPassword(t *testing.T) { }) t.Run("AdminCanUpdateOwnPasswordWithoutOldPassword", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) _ = coderdtest.CreateFirstUser(t, client) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -535,6 +548,8 @@ func TestUpdateUserPassword(t *testing.T) { Password: "newpassword", }) require.NoError(t, err, "admin should be able to update own password without providing old password") + assert.Len(t, auditor.AuditLogs, 1) + assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[0].Action) }) } @@ -752,7 +767,8 @@ func TestPutUserSuspend(t *testing.T) { t.Run("SuspendAnotherUser", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor}) me := coderdtest.CreateFirstUser(t, client) _, user := coderdtest.CreateAnotherUserWithUser(t, client, me.OrganizationID) @@ -762,6 +778,8 @@ func TestPutUserSuspend(t *testing.T) { user, err := client.UpdateUserStatus(ctx, user.Username, codersdk.UserStatusSuspended) require.NoError(t, err) require.Equal(t, user.Status, codersdk.UserStatusSuspended) + assert.Len(t, auditor.AuditLogs, 2) + assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs[1].Action) }) t.Run("SuspendItSelf", func(t *testing.T) { diff --git a/enterprise/coderd/features.go b/enterprise/coderd/features.go index a851812594827..bc9977ff18441 100644 --- a/enterprise/coderd/features.go +++ b/enterprise/coderd/features.go @@ -19,6 +19,7 @@ import ( agpl "github.com/coder/coder/coderd" agplAudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/features" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" @@ -55,7 +56,7 @@ func newFeaturesService( db database.Store, pubsub database.Pubsub, enablements Enablements, -) agpl.FeaturesService { +) features.Service { fs := &featuresService{ logger: logger, database: db, diff --git a/enterprise/coderd/features_internal_test.go b/enterprise/coderd/features_internal_test.go index 629e427d2749a..a195c2ffe784b 100644 --- a/enterprise/coderd/features_internal_test.go +++ b/enterprise/coderd/features_internal_test.go @@ -22,6 +22,7 @@ import ( agplAudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/features" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" @@ -285,7 +286,7 @@ func TestFeaturesServiceSyncEntitlements(t *testing.T) { }) } -func requestEntitlements(t *testing.T, uut agplCoderd.FeaturesService) codersdk.Entitlements { +func requestEntitlements(t *testing.T, uut features.Service) codersdk.Entitlements { t.Helper() r := httptest.NewRequest("GET", "https://example.com/api/v2/entitlements", nil) rw := httptest.NewRecorder()