849 lines
30 KiB
Go
849 lines
30 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
|
rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder"
|
|
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
|
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// testScopedObj is a minimal model.ScopedStorable used exclusively by
|
|
// ScopedDBImp tests.
|
|
type testScopedObj struct {
|
|
id bson.ObjectID
|
|
orgRef bson.ObjectID
|
|
ownerKind model.OwnerKind
|
|
ownerRef bson.ObjectID
|
|
permissionRef bson.ObjectID
|
|
}
|
|
|
|
func (o *testScopedObj) GetID() *bson.ObjectID { return &o.id }
|
|
func (o *testScopedObj) SetID(id bson.ObjectID) { o.id = id }
|
|
func (o *testScopedObj) Update() {}
|
|
func (o *testScopedObj) Collection() string { return "test_scoped" }
|
|
func (o *testScopedObj) GetOrganizationRef() bson.ObjectID { return o.orgRef }
|
|
func (o *testScopedObj) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref }
|
|
func (o *testScopedObj) GetOwnerKind() model.OwnerKind { return o.ownerKind }
|
|
func (o *testScopedObj) GetOwnerRef() bson.ObjectID { return o.ownerRef }
|
|
func (o *testScopedObj) SetOwnerKind(k model.OwnerKind) { o.ownerKind = k }
|
|
func (o *testScopedObj) SetOwnerRef(ref bson.ObjectID) { o.ownerRef = ref }
|
|
func (o *testScopedObj) GetPermissionRef() bson.ObjectID { return o.permissionRef }
|
|
func (o *testScopedObj) SetPermissionRef(ref bson.ObjectID) { o.permissionRef = ref }
|
|
|
|
var _ model.ScopedStorable = (*testScopedObj)(nil)
|
|
|
|
// fakeScopedRepository is a minimal in-memory repository.Repository that
|
|
// supports Insert, InsertMany, Get, Update, Patch, Delete, and ListIDs.
|
|
// All other methods return merrors.NotImplemented.
|
|
type fakeScopedRepository struct {
|
|
objects map[bson.ObjectID]*testScopedObj
|
|
insertErr error
|
|
getErr error
|
|
updateErr error
|
|
patchErr error
|
|
deleteErr error
|
|
listIDsErr error
|
|
patchedIDs []bson.ObjectID
|
|
// patchManyIDs is the set of IDs that PatchMany will record as affected.
|
|
// Tests using PatchMany must set this to the expected authorized IDs so the
|
|
// fake can simulate per-ID tracking without parsing the bulk filter.
|
|
patchManyIDs []bson.ObjectID
|
|
}
|
|
|
|
func newFakeScopedRepository() *fakeScopedRepository {
|
|
return &fakeScopedRepository{objects: make(map[bson.ObjectID]*testScopedObj)}
|
|
}
|
|
|
|
func (f *fakeScopedRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
|
if f.insertErr != nil {
|
|
return f.insertErr
|
|
}
|
|
o := obj.(*testScopedObj)
|
|
if o.id == bson.NilObjectID {
|
|
o.id = bson.NewObjectID()
|
|
}
|
|
clone := *o
|
|
f.objects[o.id] = &clone
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) InsertMany(_ context.Context, objects []storable.Storable) error {
|
|
if f.insertErr != nil {
|
|
return f.insertErr
|
|
}
|
|
for _, obj := range objects {
|
|
o := obj.(*testScopedObj)
|
|
if o.id == bson.NilObjectID {
|
|
o.id = bson.NewObjectID()
|
|
}
|
|
clone := *o
|
|
f.objects[o.id] = &clone
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
|
|
if f.getErr != nil {
|
|
return f.getErr
|
|
}
|
|
obj, ok := f.objects[id]
|
|
if !ok {
|
|
return merrors.NoData(fmt.Sprintf("test_scoped %s not found", id.Hex()))
|
|
}
|
|
*result.(*testScopedObj) = *obj
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) Update(_ context.Context, obj storable.Storable) error {
|
|
if f.updateErr != nil {
|
|
return f.updateErr
|
|
}
|
|
o := obj.(*testScopedObj)
|
|
clone := *o
|
|
f.objects[*o.GetID()] = &clone
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) Patch(_ context.Context, id bson.ObjectID, _ builder.Patch) error {
|
|
if f.patchErr != nil {
|
|
return f.patchErr
|
|
}
|
|
f.patchedIDs = append(f.patchedIDs, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) Delete(_ context.Context, id bson.ObjectID) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
delete(f.objects, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeScopedRepository) ListIDs(_ context.Context, _ builder.Query) ([]bson.ObjectID, error) {
|
|
if f.listIDsErr != nil {
|
|
return nil, f.listIDsErr
|
|
}
|
|
ids := make([]bson.ObjectID, 0, len(f.objects))
|
|
for id := range f.objects {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// Unimplemented repository.Repository stubs.
|
|
func (f *fakeScopedRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
|
|
return merrors.NotImplemented("aggregate")
|
|
}
|
|
func (f *fakeScopedRepository) FindOneByFilter(context.Context, builder.Query, storable.Storable) error {
|
|
return merrors.NotImplemented("FindOneByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) FindFirstByFilter(context.Context, builder.Query, storable.Storable) error {
|
|
return merrors.NotImplemented("FindFirstByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
|
|
return merrors.NotImplemented("FindManyByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) FindAllByFilter(context.Context, builder.Query, any) error {
|
|
return merrors.NotImplemented("FindAllByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) CountByFilter(context.Context, builder.Query) (int64, error) {
|
|
return 0, merrors.NotImplemented("CountByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) ReplaceOneByFilter(context.Context, builder.Query, storable.Storable) (bool, error) {
|
|
return false, merrors.NotImplemented("ReplaceOneByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) Upsert(context.Context, storable.Storable) error {
|
|
return merrors.NotImplemented("Upsert")
|
|
}
|
|
func (f *fakeScopedRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) {
|
|
return false, merrors.NotImplemented("PatchOneByFilter")
|
|
}
|
|
func (f *fakeScopedRepository) PatchMany(_ context.Context, _ builder.Query, _ builder.Patch) (int, error) {
|
|
f.patchedIDs = append(f.patchedIDs, f.patchManyIDs...)
|
|
return len(f.patchManyIDs), nil
|
|
}
|
|
func (f *fakeScopedRepository) DeleteMany(context.Context, builder.Query) error {
|
|
return merrors.NotImplemented("DeleteMany")
|
|
}
|
|
func (f *fakeScopedRepository) CreateIndex(*ri.Definition) error { return nil }
|
|
func (f *fakeScopedRepository) DropIndex(context.Context, string) error {
|
|
return merrors.NotImplemented("DropIndex")
|
|
}
|
|
func (f *fakeScopedRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
|
|
return nil, merrors.NotImplemented("ListIndexDefinitions")
|
|
}
|
|
func (f *fakeScopedRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) {
|
|
return nil, merrors.NotImplemented("ListPermissionBound")
|
|
}
|
|
func (f *fakeScopedRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
|
|
return nil, merrors.NotImplemented("ListAccountBound")
|
|
}
|
|
func (f *fakeScopedRepository) WithTransaction(_ context.Context, cb transaction.Callback) (any, error) {
|
|
return cb(context.Background())
|
|
}
|
|
func (f *fakeScopedRepository) Collection() string { return "test_scoped" }
|
|
|
|
// newTestScopedDBImp wires a ScopedDBImp backed by the given in-memory repo,
|
|
// membership, and enforcer. The orgPolicyRef is returned so callers can verify
|
|
// it was applied correctly.
|
|
func newTestScopedDBImp(repo *fakeScopedRepository, membership *fakeMembership, enforcer Enforcer) (*ScopedDBImp[*testScopedObj], bson.ObjectID) {
|
|
orgPolicyRef := bson.NewObjectID()
|
|
dbImp := &template.DBImp[*testScopedObj]{
|
|
Logger: zap.NewNop(),
|
|
Repository: repo,
|
|
}
|
|
dbImp.SetDeleter(func(_ context.Context, id bson.ObjectID) error {
|
|
return repo.Delete(context.Background(), id)
|
|
})
|
|
imp := NewScopedDBImp[*testScopedObj](
|
|
dbImp,
|
|
enforcer,
|
|
membership,
|
|
orgPolicyRef,
|
|
mservice.Type("test_scoped"),
|
|
func() *testScopedObj { return &testScopedObj{} },
|
|
zap.NewNop(),
|
|
)
|
|
return imp, orgPolicyRef
|
|
}
|
|
|
|
// ── Create ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestScopedDBImp_Create_Personal_SkipsPolicy(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindAccount}
|
|
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("personal create must not consult the enforcer")
|
|
}
|
|
if obj.GetOwnerRef() != caller {
|
|
t.Fatalf("expected ownerRef=%s, got %s", caller.Hex(), obj.GetOwnerRef().Hex())
|
|
}
|
|
if obj.GetOrganizationRef() != orgRef {
|
|
t.Fatal("organizationRef must be set on create")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Personal_DefaultsToAccountWhenKindMissing(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
obj := &testScopedObj{} // ownerKind is zero value (invalid)
|
|
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
|
t.Fatalf("expected success with defaulted ownerKind, got %v", err)
|
|
}
|
|
if obj.GetOwnerKind() != model.OwnerKindAccount {
|
|
t.Fatalf("expected ownerKind=account, got %s", obj.GetOwnerKind())
|
|
}
|
|
if enf.called {
|
|
t.Fatal("must not consult enforcer when defaulting to personal")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Personal_DeniedWhenCallerIsNotMember(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{member: false}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindAccount}
|
|
err := db.Create(context.Background(), caller, orgRef, obj)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied for non-member personal create, got %v", err)
|
|
}
|
|
if !membership.called {
|
|
t.Fatal("personal create must check organization membership before insert")
|
|
}
|
|
if enf.called {
|
|
t.Fatal("personal create must not consult policy when caller is not an organization member")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("object must not be inserted when membership denies")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Org_CallsPolicyAndSetsFields(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, orgPolicyRef := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
|
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if !enf.called {
|
|
t.Fatal("org create must consult the enforcer")
|
|
}
|
|
if enf.gotAction != model.ActionCreate {
|
|
t.Fatalf("expected ActionCreate, got %s", enf.gotAction)
|
|
}
|
|
if obj.GetOwnerRef() != orgRef {
|
|
t.Fatalf("expected ownerRef=orgRef for org object, got %s", obj.GetOwnerRef().Hex())
|
|
}
|
|
if obj.GetPermissionRef() != orgPolicyRef {
|
|
t.Fatal("permissionRef must be set to OrgPolicyRef for org objects")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Org_DeniedByPolicy(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{allow: false}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
|
err := db.Create(context.Background(), caller, orgRef, obj)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied, got %v", err)
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("object must not be inserted when policy denies")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Org_DeniedWhenCallerIsNotMember(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{member: false}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
|
err := db.Create(context.Background(), caller, orgRef, obj)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied for non-member org create, got %v", err)
|
|
}
|
|
if !membership.called {
|
|
t.Fatal("org create must check organization membership before policy")
|
|
}
|
|
if enf.called {
|
|
t.Fatal("org create must not consult policy when caller is not an organization member")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("object must not be inserted when membership denies")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Create_Org_MembershipErrorFailsClosed(t *testing.T) {
|
|
boom := merrors.Define("membership backend down")
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{err: boom}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
|
err := db.Create(context.Background(), bson.NewObjectID(), bson.NewObjectID(), obj)
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("expected membership backend error propagated, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("org create must fail closed and not consult policy on membership error")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("object must not be inserted when the membership check errors")
|
|
}
|
|
}
|
|
|
|
// ── InsertMany ────────────────────────────────────────────────────────────────
|
|
|
|
func TestScopedDBImp_InsertMany_Empty_IsNoOp(t *testing.T) {
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
if err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), nil); err != nil {
|
|
t.Fatalf("empty InsertMany must succeed, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("empty InsertMany must not call enforcer")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_AllPersonal_SkipsPolicy(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
objects := []*testScopedObj{
|
|
{ownerKind: model.OwnerKindAccount},
|
|
{ownerKind: model.OwnerKindAccount},
|
|
}
|
|
if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("all-personal InsertMany must not consult the enforcer")
|
|
}
|
|
if len(repo.objects) != 2 {
|
|
t.Fatalf("expected 2 inserted objects, got %d", len(repo.objects))
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_AllPersonalDeniedWhenCallerIsNotMember(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{member: false}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
objects := []*testScopedObj{
|
|
{ownerKind: model.OwnerKindAccount},
|
|
{ownerKind: model.OwnerKindAccount},
|
|
}
|
|
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied for non-member personal InsertMany, got %v", err)
|
|
}
|
|
if !membership.called {
|
|
t.Fatal("personal InsertMany must check organization membership before insert")
|
|
}
|
|
if enf.called {
|
|
t.Fatal("personal InsertMany must not consult policy when caller is not an organization member")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("objects must not be inserted when membership denies")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_MixedBatch_OrgPolicyCheckedOnce(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
objects := []*testScopedObj{
|
|
{ownerKind: model.OwnerKindAccount},
|
|
{ownerKind: model.OwnerKindOrganization},
|
|
{ownerKind: model.OwnerKindOrganization},
|
|
}
|
|
if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if !enf.called {
|
|
t.Fatal("enforcer must be called for mixed batch with org objects")
|
|
}
|
|
// Enforcer is called only once regardless of how many org objects are in the batch.
|
|
callCount := 0
|
|
if enf.called {
|
|
callCount = 1
|
|
}
|
|
if callCount != 1 {
|
|
t.Fatal("org policy must be checked exactly once for the entire batch")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_OrgDenied_NoObjectsInsertedOrMutated(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{allow: false}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
personal := &testScopedObj{}
|
|
org := &testScopedObj{
|
|
orgRef: bson.NewObjectID(),
|
|
ownerKind: model.OwnerKindOrganization,
|
|
ownerRef: bson.NewObjectID(),
|
|
permissionRef: bson.NewObjectID(),
|
|
}
|
|
originalPersonal := *personal
|
|
originalOrg := *org
|
|
objects := []*testScopedObj{personal, org}
|
|
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied, got %v", err)
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("objects must not be inserted when policy denies")
|
|
}
|
|
if *personal != originalPersonal {
|
|
t.Fatalf("personal object was mutated on denied InsertMany: got %+v want %+v", *personal, originalPersonal)
|
|
}
|
|
if *org != originalOrg {
|
|
t.Fatalf("org object was mutated on denied InsertMany: got %+v want %+v", *org, originalOrg)
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_OrgDeniedWhenCallerIsNotMember(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{member: false}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}}
|
|
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied for non-member org InsertMany, got %v", err)
|
|
}
|
|
if !membership.called {
|
|
t.Fatal("org InsertMany must check organization membership before policy")
|
|
}
|
|
if enf.called {
|
|
t.Fatal("org InsertMany must not consult policy when caller is not an organization member")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("objects must not be inserted when membership denies")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_InsertMany_OrgMembershipErrorFailsClosed(t *testing.T) {
|
|
boom := merrors.Define("membership backend down")
|
|
repo := newFakeScopedRepository()
|
|
membership := &fakeMembership{err: boom}
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(repo, membership, enf)
|
|
|
|
objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}}
|
|
err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), objects)
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("expected membership backend error propagated, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("org InsertMany must fail closed and not consult policy on membership error")
|
|
}
|
|
if len(repo.objects) != 0 {
|
|
t.Fatal("objects must not be inserted when the membership check errors")
|
|
}
|
|
}
|
|
|
|
// ── Update ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestScopedDBImp_Update_RestoresOwnershipFields(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
objectRef := bson.NewObjectID()
|
|
orgPolicyRef := bson.NewObjectID()
|
|
|
|
// Seed the repo with a personal object
|
|
repo := newFakeScopedRepository()
|
|
repo.objects[objectRef] = &testScopedObj{
|
|
id: objectRef,
|
|
orgRef: orgRef,
|
|
ownerKind: model.OwnerKindAccount,
|
|
ownerRef: caller,
|
|
permissionRef: orgPolicyRef,
|
|
}
|
|
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
|
|
|
// Submit an update that tries to change ownership
|
|
updated := &testScopedObj{
|
|
id: objectRef,
|
|
ownerKind: model.OwnerKindOrganization, // attempt to escalate
|
|
ownerRef: bson.NewObjectID(), // attempt to hijack owner
|
|
}
|
|
if err := db.Update(context.Background(), caller, updated); err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
// After update the ownership fields must match the DB, not the submitted payload
|
|
if updated.GetOwnerKind() != model.OwnerKindAccount {
|
|
t.Fatalf("ownerKind must not change: got %s", updated.GetOwnerKind())
|
|
}
|
|
if updated.GetOwnerRef() != caller {
|
|
t.Fatalf("ownerRef must not change: got %s", updated.GetOwnerRef().Hex())
|
|
}
|
|
if updated.GetOrganizationRef() != orgRef {
|
|
t.Fatal("organizationRef must not change")
|
|
}
|
|
if updated.GetPermissionRef() != orgPolicyRef {
|
|
t.Fatal("permissionRef must not change")
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_Update_DeniedForNonOwner(t *testing.T) {
|
|
owner := bson.NewObjectID()
|
|
attacker := bson.NewObjectID()
|
|
objectRef := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
|
|
repo := newFakeScopedRepository()
|
|
repo.objects[objectRef] = &testScopedObj{
|
|
id: objectRef,
|
|
orgRef: orgRef,
|
|
ownerKind: model.OwnerKindAccount,
|
|
ownerRef: owner,
|
|
}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
|
|
|
err := db.Update(context.Background(), attacker, &testScopedObj{id: objectRef})
|
|
if !errors.Is(err, merrors.ErrAccessDenied) {
|
|
t.Fatalf("expected access denied for non-owner, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ── ListIDs ───────────────────────────────────────────────────────────────────
|
|
|
|
func TestScopedDBImp_ListIDs_FiltersUnauthorized(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
other := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
|
|
id1 := bson.NewObjectID()
|
|
id2 := bson.NewObjectID()
|
|
id3 := bson.NewObjectID()
|
|
|
|
repo := newFakeScopedRepository()
|
|
repo.objects = map[bson.ObjectID]*testScopedObj{
|
|
id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
|
id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, // unauthorized
|
|
id3: {id: id3, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
|
}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
|
|
|
ids, err := db.ListIDs(context.Background(), model.ActionRead, caller, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if len(ids) != 2 {
|
|
t.Fatalf("expected 2 authorized IDs, got %d", len(ids))
|
|
}
|
|
for _, id := range ids {
|
|
if id == id2 {
|
|
t.Fatal("unauthorized ID must not be returned")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_ListIDs_PropagatesNonAuthError(t *testing.T) {
|
|
boom := merrors.Define("enforcer backend down")
|
|
objectRef := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
|
|
repo := newFakeScopedRepository()
|
|
repo.objects[objectRef] = &testScopedObj{
|
|
id: objectRef,
|
|
orgRef: orgRef,
|
|
ownerKind: model.OwnerKindOrganization,
|
|
ownerRef: orgRef,
|
|
}
|
|
enf := &fakeEnforcer{err: boom}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
_, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil)
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("expected underlying error propagated, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_ListIDs_RawRepoErrorPropagated(t *testing.T) {
|
|
boom := merrors.Define("mongo down")
|
|
repo := newFakeScopedRepository()
|
|
repo.listIDsErr = boom
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
|
|
|
_, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil)
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("expected raw list error propagated, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ── PatchMany ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestScopedDBImp_PatchMany_PatchesOnlyAuthorizedIDs(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
other := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
|
|
id1 := bson.NewObjectID()
|
|
id2 := bson.NewObjectID()
|
|
|
|
repo := newFakeScopedRepository()
|
|
repo.objects = map[bson.ObjectID]*testScopedObj{
|
|
id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
|
id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other},
|
|
}
|
|
// patchManyIDs declares which IDs the bulk DB call will report as affected.
|
|
// Only id1 is authorized (ownerRef==caller), so the IN filter contains only id1.
|
|
repo.patchManyIDs = []bson.ObjectID{id1}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
|
|
|
count, err := db.PatchMany(context.Background(), caller, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 patched object, got %d", count)
|
|
}
|
|
if len(repo.patchedIDs) != 1 || repo.patchedIDs[0] != id1 {
|
|
t.Fatal("only the authorized ID must be patched")
|
|
}
|
|
}
|
|
|
|
// ── BuildScopedQuery ──────────────────────────────────────────────────────────
|
|
|
|
// bsonHasPair reports whether the key/value pair occurs anywhere within the
|
|
// bson.D/bson.A tree produced by builder.Query.BuildQuery().
|
|
func bsonHasPair(node any, key string, value any) bool {
|
|
switch n := node.(type) {
|
|
case bson.D:
|
|
for _, e := range n {
|
|
if e.Key == key && e.Value == value {
|
|
return true
|
|
}
|
|
if bsonHasPair(e.Value, key, value) {
|
|
return true
|
|
}
|
|
}
|
|
case bson.A:
|
|
for _, item := range n {
|
|
if bsonHasPair(item, key, value) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bsonHasKey reports whether the key occurs anywhere within the tree.
|
|
func bsonHasKey(node any, key string) bool {
|
|
switch n := node.(type) {
|
|
case bson.D:
|
|
for _, e := range n {
|
|
if e.Key == key {
|
|
return true
|
|
}
|
|
if bsonHasKey(e.Value, key) {
|
|
return true
|
|
}
|
|
}
|
|
case bson.A:
|
|
for _, item := range n {
|
|
if bsonHasKey(item, key) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestScopedDBImp_BuildScopedQuery_WithOrgAccess_PersonalBranchIsCrossOrg(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf)
|
|
|
|
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
if !enf.called {
|
|
t.Fatal("enforcer must be called to check org access")
|
|
}
|
|
|
|
built := q.BuildQuery()
|
|
if len(built) != 1 || built[0].Key != "$or" {
|
|
t.Fatalf("expected a single top-level $or, got %v", built)
|
|
}
|
|
branches, ok := built[0].Value.(bson.A)
|
|
if !ok || len(branches) != 2 {
|
|
t.Fatalf("expected 2 $or branches, got %v", built[0].Value)
|
|
}
|
|
|
|
personal := branches[0]
|
|
if !bsonHasPair(personal, ownerKindField, model.OwnerKindAccount) ||
|
|
!bsonHasPair(personal, ownerRefField, caller) {
|
|
t.Fatalf("personal branch must match owner kind/ref, got %v", personal)
|
|
}
|
|
if bsonHasKey(personal, storable.OrganizationRefField) {
|
|
t.Fatalf("personal branch must not be org-bounded, got %v", personal)
|
|
}
|
|
|
|
org := branches[1]
|
|
if !bsonHasPair(org, storable.OrganizationRefField, orgRef) ||
|
|
!bsonHasPair(org, ownerKindField, model.OwnerKindOrganization) {
|
|
t.Fatalf("org branch must be org-bounded and org-owned, got %v", org)
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_BuildScopedQuery_WithoutOrgAccess_PersonalOnly(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
enf := &fakeEnforcer{allow: false}
|
|
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf)
|
|
|
|
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got %v", err)
|
|
}
|
|
|
|
built := q.BuildQuery()
|
|
if bsonHasKey(built, storable.OrganizationRefField) {
|
|
t.Fatalf("query must be personal-only without org access, got %v", built)
|
|
}
|
|
if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) ||
|
|
!bsonHasPair(built, ownerRefField, caller) {
|
|
t.Fatalf("query must match personal owner kind/ref, got %v", built)
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_BuildScopedQuery_MembershipError_PersonalOnly(t *testing.T) {
|
|
caller := bson.NewObjectID()
|
|
orgRef := bson.NewObjectID()
|
|
enf := &fakeEnforcer{allow: true}
|
|
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{err: merrors.Define("membership down")}, enf)
|
|
|
|
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
|
if err != nil {
|
|
t.Fatalf("membership failure must degrade to personal-only, got %v", err)
|
|
}
|
|
if enf.called {
|
|
t.Fatal("enforcer must not be consulted when the membership check fails")
|
|
}
|
|
|
|
built := q.BuildQuery()
|
|
if bsonHasKey(built, storable.OrganizationRefField) {
|
|
t.Fatalf("query must be personal-only on membership failure, got %v", built)
|
|
}
|
|
if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) ||
|
|
!bsonHasPair(built, ownerRefField, caller) {
|
|
t.Fatalf("query must match personal owner kind/ref, got %v", built)
|
|
}
|
|
}
|
|
|
|
func TestScopedDBImp_BuildScopedQuery_EnforcerError_Propagated(t *testing.T) {
|
|
boom := merrors.Define("enforcer down")
|
|
repo := newFakeScopedRepository()
|
|
enf := &fakeEnforcer{err: boom}
|
|
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
|
|
|
_, err := db.BuildScopedQuery(context.Background(), bson.NewObjectID(), bson.NewObjectID())
|
|
if !errors.Is(err, boom) {
|
|
t.Fatalf("expected enforcer error propagated, got %v", err)
|
|
}
|
|
}
|