feat: add auditing to user routes (#3961)

This commit is contained in:
Colin Adler
2022-09-08 21:16:16 -05:00
committed by GitHub
parent c026464375
commit 4e26e325a6
11 changed files with 258 additions and 61 deletions

View File

@ -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{} }

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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 {

View 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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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) {