Ctrl K
ComunidadGuías…

coderd/coderdtest/authorize.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package coderdtest

import (
	"context"
	"fmt"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
	"sync/atomic"
	"testing"

	"github.com/google/uuid"
	"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/coderd/util/namesgenerator"
	"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:  key.ScopeSet(),
		},
		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...)
}

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

// PrepareCall is a recorded call to Authorizer.Prepare. Unlike AuthCall it has
// no rbac.Object, only the object type string that Prepare receives.
type PrepareCall struct {
	Actor      rbac.Subject
	Action     policy.Action
	ObjectType 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
	Prepared []PrepareCall
	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
}

// PrepareCount returns how many Prepare calls were recorded for the given
// subject, action, and object type. Counts are keyed by subject ID so a test
// can isolate the prepares made on behalf of a specific user and ignore
// background work performed under system subjects.
func (r *RecordingAuthorizer) PrepareCount(subjectID string, action policy.Action, objectType string) int {
	r.RLock()
	defer r.RUnlock()
	n := 0
	for _, p := range r.Prepared {
		if p.Actor.ID == subjectID && p.Action == action && p.ObjectType == objectType {
			n++
		}
	}
	return n
}

// 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) {
	if r.Wrapped == nil {
		panic("Developer error: RecordingAuthorizer.Wrapped is nil")
	}
	r.recordPrepare(subject, action, objectType)

	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
}

// recordPrepare is the internal method that records the Prepare() call.
func (r *RecordingAuthorizer) recordPrepare(subject rbac.Subject, action policy.Action, objectType string) {
	r.Lock()
	defer r.Unlock()
	r.Prepared = append(r.Prepared, PrepareCall{
		Actor:      subject,
		Action:     action,
		ObjectType: objectType,
	})
}

// Reset clears the recorded Authorize() and Prepare() calls.
func (r *RecordingAuthorizer) Reset() {
	r.Lock()
	defer r.Unlock()
	r.Called = nil
	r.Prepared = 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.UniqueName(): {RandomRBACAction()},
		},
		ACLGroupList: map[string][]policy.Action{
			namesgenerator.UniqueName(): {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.UniqueName()},
		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
}