mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat: add auditing to user routes (#3961)
This commit is contained in:
@ -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{} }
|
||||
|
@ -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
|
||||
Features features.Service
|
||||
Log slog.Logger
|
||||
|
||||
Request *http.Request
|
||||
ResourceID uuid.UUID
|
||||
ResourceTarget string
|
||||
Action database.AuditAction
|
||||
ResourceType database.ResourceType
|
||||
Actor uuid.UUID
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
13
coderd/features/features.go
Normal file
13
coderd/features/features.go
Normal file
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
Reference in New Issue
Block a user