mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
08e17a07fc
### Breaking Change (changelog note): > User connections to workspaces, and the opening of workspace apps or ports will no longer create entries in the audit log. Those events will now be included in the 'Connection Log'. Please see the 'Connection Log' page in the dashboard, and the Connection Log [documentation](https://coder.com/docs/admin/monitoring/connection-logs) for details. Those with permission to view the Audit Log will also be able to view the Connection Log. The new Connection Log has the same licensing restrictions as the Audit Log, and requires a Premium Coder deployment. ### Context This is the first PR of a few for moving connection events out of the audit log, and into a new database table and web UI page called the 'Connection Log'. This PR: - Creates the new table - Adds and tests queries for inserting and reading, including reading with an RBAC filter. - Implements the corresponding RBAC changes, such that anyone who can view the audit log can read from the table - Implements, under the enterprise package, a `ConnectionLogger` abstraction to replace the `Auditor` abstraction for these logs. (No-op'd in AGPL, like the `Auditor`) - Routes SSH connection and Workspace App events into the new `ConnectionLogger` - Updates all existing tests to check the values of the `ConnectionLogger` instead of the `Auditor`. Future PRs: - Add filtering to the query - Add an enterprise endpoint to query the new table - Write a query to delete old events from the audit log, call it from dbpurge. - Implement a table in the Web UI for viewing connection logs. > [!NOTE] > The PRs in this stack obviously won't be (completely) atomic. Whilst they'll each pass CI, the stack is designed to be merged all at once. I'm splitting them up for the sake of those reviewing, and so changes can be reviewed as early as possible. Despite this, it's really hard to make this PR any smaller than it already is. I'll be keeping it in draft until it's actually ready to merge.
504 lines
15 KiB
Go
504 lines
15 KiB
Go
package coderdtest
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/moby/moby/pkg/namesgenerator"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/rbac/regosql"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/cryptorand"
|
|
)
|
|
|
|
// RBACAsserter is a helper for asserting that the correct RBAC checks are
|
|
// performed. This struct is tied to a given user, and only authorizes calls
|
|
// for this user are checked.
|
|
type RBACAsserter struct {
|
|
Subject rbac.Subject
|
|
|
|
Recorder *RecordingAuthorizer
|
|
}
|
|
|
|
// AssertRBAC returns an RBACAsserter for the given user. This asserter will
|
|
// allow asserting that the correct RBAC checks are performed for the given user.
|
|
// All checks that are not run against this user will be ignored.
|
|
func AssertRBAC(t *testing.T, api *coderd.API, client *codersdk.Client) RBACAsserter {
|
|
if client.SessionToken() == "" {
|
|
t.Fatal("client must be logged in")
|
|
}
|
|
recorder, ok := api.Authorizer.(*RecordingAuthorizer)
|
|
if !ok {
|
|
t.Fatal("expected RecordingAuthorizer")
|
|
}
|
|
|
|
// We use the database directly to not cause additional auth checks on behalf
|
|
// of the user. This does add authz checks on behalf of the system user, but
|
|
// it is hard to avoid that.
|
|
// nolint:gocritic
|
|
ctx := dbauthz.AsSystemRestricted(context.Background())
|
|
token := client.SessionToken()
|
|
parts := strings.Split(token, "-")
|
|
key, err := api.Database.GetAPIKeyByID(ctx, parts[0])
|
|
require.NoError(t, err, "fetch client api key")
|
|
|
|
roles, err := api.Database.GetAuthorizationUserRoles(ctx, key.UserID)
|
|
require.NoError(t, err, "fetch user roles")
|
|
|
|
roleNames, err := roles.RoleNames()
|
|
require.NoError(t, err)
|
|
|
|
return RBACAsserter{
|
|
Subject: rbac.Subject{
|
|
ID: key.UserID.String(),
|
|
Roles: rbac.RoleIdentifiers(roleNames),
|
|
Groups: roles.Groups,
|
|
Scope: rbac.ScopeName(key.Scope),
|
|
},
|
|
Recorder: recorder,
|
|
}
|
|
}
|
|
|
|
// AllCalls is for debugging. If you are not sure where calls are coming from,
|
|
// call this and use a debugger or print them. They have small callstacks
|
|
// on them to help locate the 'Authorize' call.
|
|
// Only calls to Authorize by the given subject will be returned.
|
|
// Note that duplicate rbac calls are handled by the rbac.Cacher(), but
|
|
// will be recorded twice. So AllCalls() returns calls regardless if they
|
|
// were returned from the cached or not.
|
|
func (a RBACAsserter) AllCalls() AuthCalls {
|
|
return a.Recorder.AllCalls(&a.Subject)
|
|
}
|
|
|
|
// AssertChecked will assert a given rbac check was performed. It does not care
|
|
// about order of checks, or any other checks. This is useful when you do not
|
|
// care about asserting every check that was performed.
|
|
func (a RBACAsserter) AssertChecked(t *testing.T, action policy.Action, objects ...interface{}) {
|
|
converted := a.convertObjects(t, objects...)
|
|
pairs := make([]ActionObjectPair, 0, len(converted))
|
|
for _, obj := range converted {
|
|
pairs = append(pairs, a.Recorder.Pair(action, obj))
|
|
}
|
|
a.Recorder.AssertOutOfOrder(t, a.Subject, pairs...)
|
|
}
|
|
|
|
// AssertInOrder must be called in the correct order of authz checks. If the objects
|
|
// or actions are not in the correct order, the test will fail.
|
|
func (a RBACAsserter) AssertInOrder(t *testing.T, action policy.Action, objects ...interface{}) {
|
|
converted := a.convertObjects(t, objects...)
|
|
pairs := make([]ActionObjectPair, 0, len(converted))
|
|
for _, obj := range converted {
|
|
pairs = append(pairs, a.Recorder.Pair(action, obj))
|
|
}
|
|
a.Recorder.AssertActor(t, a.Subject, pairs...)
|
|
}
|
|
|
|
// convertObjects converts the codersdk types to rbac.Object. Unfortunately
|
|
// does not have type safety, and instead uses a t.Fatal to enforce types.
|
|
func (RBACAsserter) convertObjects(t *testing.T, objs ...interface{}) []rbac.Object {
|
|
converted := make([]rbac.Object, 0, len(objs))
|
|
for _, obj := range objs {
|
|
var robj rbac.Object
|
|
switch obj := obj.(type) {
|
|
case rbac.Object:
|
|
robj = obj
|
|
case rbac.Objecter:
|
|
robj = obj.RBACObject()
|
|
case codersdk.TemplateVersion:
|
|
robj = rbac.ResourceTemplate.InOrg(obj.OrganizationID)
|
|
case codersdk.User:
|
|
robj = rbac.ResourceUserObject(obj.ID)
|
|
case codersdk.Workspace:
|
|
robj = rbac.ResourceWorkspace.WithID(obj.ID).InOrg(obj.OrganizationID).WithOwner(obj.OwnerID.String())
|
|
default:
|
|
t.Fatalf("unsupported type %T to convert to rbac.Object, add the implementation", obj)
|
|
}
|
|
converted = append(converted, robj)
|
|
}
|
|
return converted
|
|
}
|
|
|
|
// Reset will clear all previously recorded authz calls.
|
|
// This is helpful when wanting to ignore checks run in test setup.
|
|
func (a RBACAsserter) Reset() RBACAsserter {
|
|
a.Recorder.Reset()
|
|
return a
|
|
}
|
|
|
|
type AuthCalls []AuthCall
|
|
|
|
type AuthCall struct {
|
|
rbac.AuthCall
|
|
Err error
|
|
|
|
asserted bool
|
|
// callers is a small stack trace for debugging.
|
|
callers []string
|
|
}
|
|
|
|
var _ rbac.Authorizer = (*RecordingAuthorizer)(nil)
|
|
|
|
// RecordingAuthorizer wraps any rbac.Authorizer and records all Authorize()
|
|
// calls made. This is useful for testing as these calls can later be asserted.
|
|
type RecordingAuthorizer struct {
|
|
sync.RWMutex
|
|
Called []AuthCall
|
|
Wrapped rbac.Authorizer
|
|
}
|
|
|
|
type ActionObjectPair struct {
|
|
Action policy.Action
|
|
Object rbac.Object
|
|
}
|
|
|
|
// Pair is on the RecordingAuthorizer to be easy to find and keep the pkg
|
|
// interface smaller.
|
|
func (*RecordingAuthorizer) Pair(action policy.Action, object rbac.Objecter) ActionObjectPair {
|
|
return ActionObjectPair{
|
|
Action: action,
|
|
Object: object.RBACObject(),
|
|
}
|
|
}
|
|
|
|
// AllAsserted returns an error if all calls to Authorize() have not been
|
|
// asserted and checked. This is useful for testing to ensure that all
|
|
// Authorize() calls are checked in the unit test.
|
|
func (r *RecordingAuthorizer) AllAsserted() error {
|
|
r.RLock()
|
|
defer r.RUnlock()
|
|
missed := []AuthCall{}
|
|
for _, c := range r.Called {
|
|
if !c.asserted {
|
|
missed = append(missed, c)
|
|
}
|
|
}
|
|
|
|
if len(missed) > 0 {
|
|
return xerrors.Errorf("missed calls: %+v", missed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AllCalls is useful for debugging.
|
|
func (r *RecordingAuthorizer) AllCalls(actor *rbac.Subject) []AuthCall {
|
|
r.RLock()
|
|
defer r.RUnlock()
|
|
|
|
called := make([]AuthCall, 0, len(r.Called))
|
|
for _, c := range r.Called {
|
|
if actor != nil && !c.Actor.Equal(*actor) {
|
|
continue
|
|
}
|
|
called = append(called, c)
|
|
}
|
|
return called
|
|
}
|
|
|
|
// AssertOutOfOrder asserts that the given actor performed the given action
|
|
// on the given objects. It does not care about the order of the calls.
|
|
// When marking authz calls as asserted, it will mark the first matching
|
|
// calls first.
|
|
func (r *RecordingAuthorizer) AssertOutOfOrder(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) {
|
|
r.Lock()
|
|
defer r.Unlock()
|
|
|
|
for _, do := range did {
|
|
found := false
|
|
// Find the first non-asserted call that matches the actor, action, and object.
|
|
for i, call := range r.Called {
|
|
if !call.asserted && call.Actor.Equal(actor) && call.Action == do.Action && call.Object.Equal(do.Object) {
|
|
r.Called[i].asserted = true
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, found, "assertion missing: %s %s %s", actor, do.Action, do.Object)
|
|
}
|
|
}
|
|
|
|
// AssertActor asserts in order. If the order of authz calls does not match,
|
|
// this will fail.
|
|
func (r *RecordingAuthorizer) AssertActor(t *testing.T, actor rbac.Subject, did ...ActionObjectPair) {
|
|
r.AssertActorID(t, actor.ID, did...)
|
|
}
|
|
|
|
func (r *RecordingAuthorizer) AssertActorID(t *testing.T, id string, did ...ActionObjectPair) {
|
|
r.Lock()
|
|
defer r.Unlock()
|
|
ptr := 0
|
|
for i, call := range r.Called {
|
|
if ptr == len(did) {
|
|
// Finished all assertions
|
|
return
|
|
}
|
|
if call.Actor.ID == id {
|
|
action, object := did[ptr].Action, did[ptr].Object
|
|
assert.Equalf(t, action, call.Action, "assert action %d", ptr)
|
|
assert.Equalf(t, object, call.Object, "assert object %d", ptr)
|
|
r.Called[i].asserted = true
|
|
ptr++
|
|
}
|
|
}
|
|
|
|
assert.Equalf(t, len(did), ptr, "assert actor: didn't find all actions, %d missing actions", len(did)-ptr)
|
|
}
|
|
|
|
// recordAuthorize is the internal method that records the Authorize() call.
|
|
func (r *RecordingAuthorizer) recordAuthorize(subject rbac.Subject, action policy.Action, object rbac.Object, authzErr error) {
|
|
r.Lock()
|
|
defer r.Unlock()
|
|
|
|
r.Called = append(r.Called, AuthCall{
|
|
AuthCall: rbac.AuthCall{
|
|
Actor: subject,
|
|
Action: action,
|
|
Object: object,
|
|
},
|
|
Err: authzErr,
|
|
callers: []string{
|
|
// This is a decent stack trace for debugging.
|
|
// Some dbauthz calls are a bit nested, so we skip a few.
|
|
caller(2),
|
|
caller(3),
|
|
caller(4),
|
|
caller(5),
|
|
},
|
|
})
|
|
}
|
|
|
|
func caller(skip int) string {
|
|
pc, file, line, ok := runtime.Caller(skip + 1)
|
|
i := strings.Index(file, "coder")
|
|
if i >= 0 {
|
|
file = file[i:]
|
|
}
|
|
str := fmt.Sprintf("%s:%d", file, line)
|
|
if ok {
|
|
f := runtime.FuncForPC(pc)
|
|
str += " | " + filepath.Base(f.Name())
|
|
}
|
|
return str
|
|
}
|
|
|
|
func (r *RecordingAuthorizer) Authorize(ctx context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error {
|
|
if r.Wrapped == nil {
|
|
panic("Developer error: RecordingAuthorizer.Wrapped is nil")
|
|
}
|
|
authzErr := r.Wrapped.Authorize(ctx, subject, action, object)
|
|
r.recordAuthorize(subject, action, object, authzErr)
|
|
return authzErr
|
|
}
|
|
|
|
func (r *RecordingAuthorizer) Prepare(ctx context.Context, subject rbac.Subject, action policy.Action, objectType string) (rbac.PreparedAuthorized, error) {
|
|
r.RLock()
|
|
defer r.RUnlock()
|
|
if r.Wrapped == nil {
|
|
panic("Developer error: RecordingAuthorizer.Wrapped is nil")
|
|
}
|
|
|
|
prep, err := r.Wrapped.Prepare(ctx, subject, action, objectType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &PreparedRecorder{
|
|
rec: r,
|
|
prepped: prep,
|
|
subject: subject,
|
|
action: action,
|
|
}, nil
|
|
}
|
|
|
|
// Reset clears the recorded Authorize() calls.
|
|
func (r *RecordingAuthorizer) Reset() {
|
|
r.Lock()
|
|
defer r.Unlock()
|
|
r.Called = nil
|
|
}
|
|
|
|
// PreparedRecorder is the prepared version of the RecordingAuthorizer.
|
|
// It records the Authorize() calls to the original recorder. If the caller
|
|
// uses CompileToSQL, all recording stops. This is to support parity between
|
|
// memory and SQL backed dbs.
|
|
type PreparedRecorder struct {
|
|
rec *RecordingAuthorizer
|
|
prepped rbac.PreparedAuthorized
|
|
subject rbac.Subject
|
|
action policy.Action
|
|
|
|
rw sync.Mutex
|
|
usingSQL bool
|
|
}
|
|
|
|
func (s *PreparedRecorder) Authorize(ctx context.Context, object rbac.Object) error {
|
|
s.rw.Lock()
|
|
defer s.rw.Unlock()
|
|
|
|
authzErr := s.prepped.Authorize(ctx, object)
|
|
if !s.usingSQL {
|
|
s.rec.recordAuthorize(s.subject, s.action, object, authzErr)
|
|
}
|
|
return authzErr
|
|
}
|
|
|
|
func (s *PreparedRecorder) CompileToSQL(ctx context.Context, cfg regosql.ConvertConfig) (string, error) {
|
|
s.rw.Lock()
|
|
defer s.rw.Unlock()
|
|
|
|
s.usingSQL = true
|
|
return s.prepped.CompileToSQL(ctx, cfg)
|
|
}
|
|
|
|
// FakeAuthorizer is an Authorizer that will return an error based on the
|
|
// "ConditionalReturn" function. By default, **no error** is returned.
|
|
// Meaning 'FakeAuthorizer' by default will never return "unauthorized".
|
|
type FakeAuthorizer struct {
|
|
ConditionalReturn func(context.Context, rbac.Subject, policy.Action, rbac.Object) error
|
|
sqlFilter string
|
|
}
|
|
|
|
var _ rbac.Authorizer = (*FakeAuthorizer)(nil)
|
|
|
|
// AlwaysReturn is the error that will be returned by Authorize.
|
|
func (d *FakeAuthorizer) AlwaysReturn(err error) *FakeAuthorizer {
|
|
d.ConditionalReturn = func(_ context.Context, _ rbac.Subject, _ policy.Action, _ rbac.Object) error {
|
|
return err
|
|
}
|
|
return d
|
|
}
|
|
|
|
// OverrideSQLFilter sets the SQL filter that will always be returned by CompileToSQL.
|
|
func (d *FakeAuthorizer) OverrideSQLFilter(filter string) *FakeAuthorizer {
|
|
d.sqlFilter = filter
|
|
return d
|
|
}
|
|
|
|
func (d *FakeAuthorizer) Authorize(ctx context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error {
|
|
if d.ConditionalReturn != nil {
|
|
return d.ConditionalReturn(ctx, subject, action, object)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *FakeAuthorizer) Prepare(_ context.Context, subject rbac.Subject, action policy.Action, _ string) (rbac.PreparedAuthorized, error) {
|
|
return &fakePreparedAuthorizer{
|
|
Original: d,
|
|
Subject: subject,
|
|
Action: action,
|
|
}, nil
|
|
}
|
|
|
|
var _ rbac.PreparedAuthorized = (*fakePreparedAuthorizer)(nil)
|
|
|
|
// fakePreparedAuthorizer is the prepared version of a FakeAuthorizer. It will
|
|
// return the same error as the original FakeAuthorizer.
|
|
type fakePreparedAuthorizer struct {
|
|
sync.RWMutex
|
|
Original *FakeAuthorizer
|
|
Subject rbac.Subject
|
|
Action policy.Action
|
|
}
|
|
|
|
func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error {
|
|
return f.Original.Authorize(ctx, f.Subject, f.Action, object)
|
|
}
|
|
|
|
func (f *fakePreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) {
|
|
if f.Original.sqlFilter != "" {
|
|
return f.Original.sqlFilter, nil
|
|
}
|
|
// By default, allow all SQL queries.
|
|
return "TRUE", nil
|
|
}
|
|
|
|
// Random rbac helper funcs
|
|
|
|
func RandomRBACAction() policy.Action {
|
|
all := rbac.AllActions()
|
|
return all[must(cryptorand.Intn(len(all)))]
|
|
}
|
|
|
|
func RandomRBACObject() rbac.Object {
|
|
return rbac.Object{
|
|
ID: uuid.NewString(),
|
|
Owner: uuid.NewString(),
|
|
OrgID: uuid.NewString(),
|
|
Type: randomRBACType(),
|
|
ACLUserList: map[string][]policy.Action{
|
|
namesgenerator.GetRandomName(1): {RandomRBACAction()},
|
|
},
|
|
ACLGroupList: map[string][]policy.Action{
|
|
namesgenerator.GetRandomName(1): {RandomRBACAction()},
|
|
},
|
|
}
|
|
}
|
|
|
|
func randomRBACType() string {
|
|
all := []string{
|
|
rbac.ResourceWorkspace.Type,
|
|
rbac.ResourceAuditLog.Type,
|
|
rbac.ResourceConnectionLog.Type,
|
|
rbac.ResourceTemplate.Type,
|
|
rbac.ResourceGroup.Type,
|
|
rbac.ResourceFile.Type,
|
|
rbac.ResourceProvisionerDaemon.Type,
|
|
rbac.ResourceOrganization.Type,
|
|
rbac.ResourceUser.Type,
|
|
rbac.ResourceOrganizationMember.Type,
|
|
rbac.ResourceWildcard.Type,
|
|
rbac.ResourceLicense.Type,
|
|
rbac.ResourceReplicas.Type,
|
|
rbac.ResourceDebugInfo.Type,
|
|
}
|
|
return all[must(cryptorand.Intn(len(all)))]
|
|
}
|
|
|
|
func RandomRBACSubject() rbac.Subject {
|
|
return rbac.Subject{
|
|
ID: uuid.NewString(),
|
|
Roles: rbac.RoleIdentifiers{rbac.RoleMember()},
|
|
Groups: []string{namesgenerator.GetRandomName(1)},
|
|
Scope: rbac.ScopeAll,
|
|
}
|
|
}
|
|
|
|
func must[T any](value T, err error) T {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
type FakeAccessControlStore struct{}
|
|
|
|
func (FakeAccessControlStore) GetTemplateAccessControl(t database.Template) dbauthz.TemplateAccessControl {
|
|
return dbauthz.TemplateAccessControl{
|
|
RequireActiveVersion: t.RequireActiveVersion,
|
|
}
|
|
}
|
|
|
|
func (FakeAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, dbauthz.TemplateAccessControl) error {
|
|
panic("not implemented")
|
|
}
|
|
|
|
func AccessControlStorePointer() *atomic.Pointer[dbauthz.AccessControlStore] {
|
|
acs := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
|
var tacs dbauthz.AccessControlStore = FakeAccessControlStore{}
|
|
acs.Store(&tacs)
|
|
return acs
|
|
}
|