Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -0,0 +1,3 @@
|
||||
package anyobject
|
||||
|
||||
const ID = "*"
|
||||
@@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ArchivableDB implements archive operations with permission checking
|
||||
type ArchivableDB[T model.PermissionBoundStorable] interface {
|
||||
// SetArchived sets the archived status of an entity with permission checking
|
||||
SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error
|
||||
// IsArchived checks if an entity is archived with permission checking
|
||||
IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error)
|
||||
|
||||
// Archive archives an entity with permission checking (sets archived to true)
|
||||
Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
|
||||
// Unarchive unarchives an entity with permission checking (sets archived to false)
|
||||
Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
}
|
||||
|
||||
// NewArchivableDB creates a new auth.ArchivableDB instance
|
||||
func NewArchivableDB[T model.PermissionBoundStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getArchivable func(T) model.Archivable,
|
||||
) ArchivableDB[T] {
|
||||
return newArchivableDBImp(dbImp, logger, enforcer, createEmpty, getArchivable)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ArchivableDB implements archive operations with permission checking
|
||||
type ArchivableDBImp[T model.PermissionBoundStorable] struct {
|
||||
dbImp *template.DBImp[T]
|
||||
logger mlogger.Logger
|
||||
enforcer Enforcer
|
||||
createEmpty func() T
|
||||
getArchivable func(T) model.Archivable
|
||||
}
|
||||
|
||||
// NewArchivableDB creates a new auth.ArchivableDB instance
|
||||
func newArchivableDBImp[T model.PermissionBoundStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getArchivable func(T) model.Archivable,
|
||||
) ArchivableDB[T] {
|
||||
return &ArchivableDBImp[T]{
|
||||
dbImp: dbImp,
|
||||
logger: logger.Named("archivable"),
|
||||
enforcer: enforcer,
|
||||
createEmpty: createEmpty,
|
||||
getArchivable: getArchivable,
|
||||
}
|
||||
}
|
||||
|
||||
// SetArchived sets the archived status of an entity with permission checking
|
||||
func (db *ArchivableDBImp[T]) SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error {
|
||||
// Check permissions using enforceObject helper
|
||||
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
|
||||
db.logger.Warn("Failed to enforce object permission", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the object to check current archived status
|
||||
obj := db.createEmpty()
|
||||
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for setting archived status", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract archivable from the object
|
||||
archivable := db.getArchivable(obj)
|
||||
currentArchived := archivable.IsArchived()
|
||||
if currentArchived == archived {
|
||||
db.logger.Debug("No change needed - same archived status", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
// Set the archived status
|
||||
patch := repository.Patch().Set(repository.IsArchivedField(), archived)
|
||||
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.logger.Warn("Failed to set archived status on object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
db.logger.Debug("Successfully set archived status on object", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsArchived checks if an entity is archived with permission checking
|
||||
func (db *ArchivableDBImp[T]) IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) {
|
||||
// // Check permissions using single Enforce
|
||||
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil {
|
||||
db.logger.Debug("Permission denied for checking archived status", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionRead)))
|
||||
return false, merrors.AccessDenied("read", "object", objectRef)
|
||||
}
|
||||
obj := db.createEmpty()
|
||||
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for checking archived status", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return false, err
|
||||
}
|
||||
archivable := db.getArchivable(obj)
|
||||
return archivable.IsArchived(), nil
|
||||
}
|
||||
|
||||
// Archive archives an entity with permission checking (sets archived to true)
|
||||
func (db *ArchivableDBImp[T]) Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
return db.SetArchived(ctx, accountRef, objectRef, true)
|
||||
}
|
||||
|
||||
// Unarchive unarchives an entity with permission checking (sets archived to false)
|
||||
func (db *ArchivableDBImp[T]) Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
return db.SetArchived(ctx, accountRef, objectRef, false)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MembershipChecker interface {
|
||||
IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error)
|
||||
}
|
||||
|
||||
type ScopedAuthorizer struct {
|
||||
// Logger receives Debug/Warn messages from the enforcement path.
|
||||
Logger mlogger.Logger
|
||||
Membership MembershipChecker
|
||||
Enforcer Enforcer
|
||||
}
|
||||
|
||||
// NewScopedAuthorizer constructs a ScopedAuthorizer and validates required dependencies.
|
||||
// Logger defaults to zap.NewNop() if nil.
|
||||
func NewScopedAuthorizer(membership MembershipChecker, enforcer Enforcer, logger mlogger.Logger) *ScopedAuthorizer {
|
||||
if membership == nil {
|
||||
panic("auth.NewScopedAuthorizer: MembershipChecker must not be nil")
|
||||
}
|
||||
if enforcer == nil {
|
||||
panic("auth.NewScopedAuthorizer: Enforcer must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedAuthorizer{
|
||||
Logger: logger,
|
||||
Membership: membership,
|
||||
Enforcer: enforcer,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ScopedAuthorizer) Authorize(ctx context.Context, p ScopedAccessParams) error {
|
||||
a.Logger.Debug("scoped access check started",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
|
||||
// Personal resources are guarded by ownership alone — membership is not consulted.
|
||||
// Checking membership first would lock the owner out after org membership is revoked.
|
||||
if p.OwnerKind == model.OwnerKindAccount {
|
||||
err := enforceOwnershipAccess(ctx, a.Enforcer, p)
|
||||
if err != nil && errors.Is(err, merrors.ErrAccessDenied) {
|
||||
a.Logger.Debug("access denied: not the owner",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
zap.String("action", string(p.Action)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ok, err := a.Membership.IsMember(ctx, p.OrgRef, p.CallerRef)
|
||||
if err != nil {
|
||||
a.Logger.Warn("membership check failed",
|
||||
zap.Error(err), mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef))
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
a.Logger.Debug("access denied: caller is not a member of the organization",
|
||||
mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
|
||||
err = enforceOwnershipAccess(ctx, a.Enforcer, p)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
a.Logger.Debug("access denied",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
} else {
|
||||
a.Logger.Warn("enforcer returned error during scoped access check",
|
||||
zap.Error(err), mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
zap.String("action", string(p.Action)))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type fakeMembershipChecker struct {
|
||||
isMember bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMembershipChecker) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
|
||||
return f.isMember, f.err
|
||||
}
|
||||
|
||||
func TestScopedAuthorizer_Authorize(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
org := bson.NewObjectID()
|
||||
object := bson.NewObjectID()
|
||||
policy := bson.NewObjectID()
|
||||
|
||||
base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams {
|
||||
return ScopedAccessParams{
|
||||
Service: mservice.Recipients,
|
||||
PolicyRef: policy,
|
||||
CallerRef: caller,
|
||||
OrgRef: org,
|
||||
ObjectRef: object,
|
||||
OwnerKind: kind,
|
||||
OwnerRef: owner,
|
||||
Action: model.ActionRead,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("membership checker error propagates for org-owned resource and is not masked as denied", func(t *testing.T) {
|
||||
boom := merrors.Define("checker down")
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{err: boom},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("checker error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member owning account-owned resource is allowed", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: false},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member is denied for org-owned resource", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: false},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member on org resource, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member owning account-owned resource is allowed", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member accessing another account's resource is denied", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindAccount, other))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member with org-owned resource allowed when enforcer approves", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member with org-owned resource denied when enforcer refuses", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: false},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enforcer error propagates and is not masked as denied", func(t *testing.T) {
|
||||
boom := merrors.Define("policy backend down")
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{err: boom},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("enforcer error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown owner kind is denied", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKind("garbage"), caller))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for unknown owner kind, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type EnforcerType string
|
||||
|
||||
const (
|
||||
Casbin EnforcerType = "casbin"
|
||||
Native EnforcerType = "native"
|
||||
)
|
||||
|
||||
type Config = model.DriverConfig[EnforcerType]
|
||||
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type ProtectedDB[T model.PermissionBoundStorable] interface {
|
||||
Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error
|
||||
InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error
|
||||
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error
|
||||
Update(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error
|
||||
PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error)
|
||||
Unprotected() template.DB[T]
|
||||
ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error)
|
||||
}
|
||||
|
||||
func CreateDB[T model.PermissionBoundStorable](
|
||||
ctx context.Context,
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (ProtectedDB[T], error) {
|
||||
return CreateDBImp[T](ctx, l, pdb, enforcer, collection, db)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountBoundDB[T model.AccountBoundStorable] interface {
|
||||
Create(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error
|
||||
Update(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error
|
||||
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error
|
||||
FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error
|
||||
ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error)
|
||||
ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error)
|
||||
}
|
||||
|
||||
func CreateAccountBound[T model.AccountBoundStorable](
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (AccountBoundDB[T], error) {
|
||||
logger = logger.Named("account_bound")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch organization policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
res := &AccountBoundDBImp[T]{
|
||||
Logger: logger,
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
Enforcer: enforcer,
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
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/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtectedDBImp[T model.PermissionBoundStorable] struct {
|
||||
DBImp *template.DBImp[T]
|
||||
Enforcer Enforcer
|
||||
PermissionRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) enforce(ctx context.Context, action model.Action, object model.PermissionBoundStorable, accountRef, objectRef bson.ObjectID) error {
|
||||
res, err := db.Enforcer.Enforce(ctx, object.GetPermissionRef(), accountRef, object.GetOrganizationRef(), objectRef, action)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to enforce permission",
|
||||
zap.Error(err), mzap.PermissionRef(object.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if !res {
|
||||
db.DBImp.Logger.Debug("Access denied", mzap.PermissionRef(object.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), objectRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
|
||||
if object.GetPermissionRef() == bson.NilObjectID {
|
||||
object.SetPermissionRef(db.PermissionRef)
|
||||
}
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
|
||||
if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully created object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error {
|
||||
if len(objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Attempting to insert many objects", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
|
||||
// Set permission and organization refs for all objects and enforce permissions
|
||||
for _, object := range objects {
|
||||
if object.GetPermissionRef() == bson.NilObjectID {
|
||||
object.SetPermissionRef(db.PermissionRef)
|
||||
}
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
|
||||
if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DBImp.InsertMany(ctx, objects); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to insert many objects", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully inserted many objects", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) enforceObject(ctx context.Context, action model.Action, accountRef, objectRef bson.ObjectID) error {
|
||||
l, err := db.ListIDs(ctx, action, accountRef, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Error occured while checking access rights", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if len(l) == 0 {
|
||||
db.DBImp.Logger.Debug("Access denied", zap.String("action", string(action)), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return merrors.AccessDenied(db.Collection, string(action), objectRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionRead, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully retrieved object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(result.GetOrganizationRef()),
|
||||
mzap.StorableRef(result), mzap.PermissionRef(result.GetPermissionRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, *object.GetID()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully updated object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.StorableRef(object), mzap.PermissionRef(object.GetPermissionRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("Attempting to delete object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to delete object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully deleted object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) ListIDs(
|
||||
ctx context.Context,
|
||||
action model.Action,
|
||||
accountRef bson.ObjectID,
|
||||
query builder.Query,
|
||||
) ([]bson.ObjectID, error) {
|
||||
db.DBImp.Logger.Debug("Attempting to list object IDs",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery()))
|
||||
|
||||
// 1. Fetch all candidate IDs from the underlying DB
|
||||
allIDs, err := db.DBImp.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to list object IDs", zap.Error(err), mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return nil, err
|
||||
}
|
||||
if len(allIDs) == 0 {
|
||||
db.DBImp.Logger.Debug("No objects found matching filter", mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery()))
|
||||
return []bson.ObjectID{}, merrors.NoData(fmt.Sprintf("no %s found", db.Collection))
|
||||
}
|
||||
|
||||
// 2. Check read permission for each ID
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, desc := range allIDs {
|
||||
enforceErr := db.enforce(ctx, action, desc, accountRef, *desc.GetID())
|
||||
if enforceErr == nil {
|
||||
allowedIDs = append(allowedIDs, *desc.GetID())
|
||||
} else if !errors.Is(enforceErr, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.DBImp.Logger.Warn("Error while enforcing read permission", zap.Error(enforceErr),
|
||||
mzap.PermissionRef(desc.GetPermissionRef()), zap.String("action", string(action)),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", *desc.GetID()), zap.String("collection", db.Collection),
|
||||
)
|
||||
return nil, enforceErr
|
||||
}
|
||||
// If AccessDenied, we simply skip that ID.
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully enforced read permission on IDs", zap.Int("fetched_count", len(allIDs)),
|
||||
zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
|
||||
// 3. Return only the IDs that passed permission checks
|
||||
return allowedIDs, nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Unprotected() template.DB[T] {
|
||||
return db.DBImp
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to delete dependent object", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDBImp[T model.PermissionBoundStorable](
|
||||
ctx context.Context,
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (*ProtectedDBImp[T], error) {
|
||||
logger := l.Named("protected")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, collection, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch policy description", zap.Error(err), zap.String("resource_type", collection))
|
||||
return nil, err
|
||||
}
|
||||
p := &ProtectedDBImp[T]{
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
Enforcer: enforcer,
|
||||
}
|
||||
if err := p.DBImp.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: storable.OrganizationRefField, Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
logger.Warn("Failed to create index", zap.Error(err), zap.String("resource_type", collection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.DBImp.Logger.Debug("Attempting to patch object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Repository.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to patch object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully patched object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) {
|
||||
db.DBImp.Logger.Debug("Attempting to patch many objects",
|
||||
mzap.AccRef(accountRef), zap.Any("filter", query.BuildQuery()))
|
||||
|
||||
ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
values := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
values[i] = id
|
||||
}
|
||||
idFilter := repository.Query().In(repository.IDField(), values...)
|
||||
finalQuery := query.And(idFilter)
|
||||
|
||||
modified, err := db.DBImp.Repository.PatchMany(ctx, finalQuery, patch)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to patch many objects", zap.Error(err),
|
||||
mzap.AccRef(accountRef))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully patched many objects",
|
||||
mzap.AccRef(accountRef), zap.Int("modified_count", modified))
|
||||
return modified, nil
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountBoundDBImp[T model.AccountBoundStorable] struct {
|
||||
Logger mlogger.Logger
|
||||
DBImp *template.DBImp[T]
|
||||
Enforcer Enforcer
|
||||
PermissionRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) enforce(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error {
|
||||
// FIRST: Check if the object's AccountRef equals the calling accountRef - if so, ALLOW
|
||||
objectAccountRef := object.GetAccountRef()
|
||||
if objectAccountRef != nil && *objectAccountRef == accountRef {
|
||||
db.Logger.Debug("Access granted - object belongs to calling account",
|
||||
mzap.ObjRef("object_account_ref", *objectAccountRef),
|
||||
mzap.ObjRef("calling_account_ref", accountRef),
|
||||
zap.String("action", string(action)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SECOND: If not owned by calling account, check organization-level permissions
|
||||
organizationRef := object.GetOrganizationRef()
|
||||
res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, organizationRef, action)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce permission",
|
||||
zap.Error(err), mzap.PermissionRef(db.PermissionRef),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if !res {
|
||||
db.Logger.Debug("Access denied", mzap.PermissionRef(db.PermissionRef),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) enforceInterface(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error {
|
||||
return db.enforce(ctx, action, object, accountRef)
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Create(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
orgRef := object.GetOrganizationRef()
|
||||
db.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Check organization update permission for create operations
|
||||
if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully created object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully retrieved object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
// Check organization update permission
|
||||
if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully updated object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.Logger.Debug("Attempting to patch object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef))
|
||||
return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef)
|
||||
}
|
||||
|
||||
// Check organization update permission
|
||||
if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.Logger.Warn("Failed to patch object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully patched object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.Logger.Debug("Attempting to delete object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef))
|
||||
return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef)
|
||||
}
|
||||
// Check organization update permission for delete operations
|
||||
if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.Logger.Warn("Failed to delete object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error {
|
||||
db.Logger.Debug("Attempting to delete many objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Get all candidate objects for batch permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for delete many", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Use batch enforcement for efficiency
|
||||
allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce batch permissions for delete many", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Build query for objects that passed permission check
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if allowedResults[*obj.GetID()] {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIDs) == 0 {
|
||||
db.Logger.Debug("No objects allowed for deletion", mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete only the allowed objects
|
||||
allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs))
|
||||
if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil {
|
||||
db.Logger.Warn("Failed to delete many objects", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted many objects", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error {
|
||||
db.Logger.Debug("Attempting to find one object", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// For FindOne, we need to check read permission after finding the object
|
||||
if err := db.DBImp.FindOne(ctx, query, result); err != nil {
|
||||
db.Logger.Warn("Failed to find one object", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission for the found object
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully found one object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) {
|
||||
db.Logger.Debug("Attempting to list object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Get all candidate objects for batch permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for ID filtering", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use batch enforcement for efficiency
|
||||
allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionRead)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce batch permissions for ID listing", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only allowed object IDs
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if allowedResults[*obj.GetID()] {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully filtered object IDs", zap.Int("total_count", len(allObjects)),
|
||||
zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef))
|
||||
return allowedIDs, nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
db.Logger.Debug("Attempting to list account bound objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
accountQuery := repository.WithOrg(accountRef, organizationRef)
|
||||
|
||||
// Combine with the provided query
|
||||
finalQuery := query.And(accountQuery)
|
||||
|
||||
// Get all candidate objects
|
||||
allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, finalQuery)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list account bound objects", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter objects based on read permissions (AccountBoundStorable doesn't have permission info, so we check organization level)
|
||||
var allowedObjects []model.AccountBoundStorable
|
||||
for _, obj := range allObjects {
|
||||
if err := db.enforceInterface(ctx, model.ActionRead, obj, accountRef); err == nil {
|
||||
allowedObjects = append(allowedObjects, obj)
|
||||
} else if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.Logger.Warn("Error while enforcing read permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID()))
|
||||
return nil, err
|
||||
}
|
||||
// If AccessDenied, we simply skip that object
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully filtered account bound objects", zap.Int("total_count", len(allObjects)),
|
||||
zap.Int("allowed_count", len(allowedObjects)), mzap.AccRef(accountRef))
|
||||
return allowedObjects, nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) GetByAccountRef(ctx context.Context, accountRef bson.ObjectID, result T) error {
|
||||
db.Logger.Debug("Attempting to get object by account ref", mzap.AccRef(accountRef))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
query := repository.WithoutOrg(accountRef)
|
||||
|
||||
if err := db.DBImp.FindOne(ctx, query, result); err != nil {
|
||||
db.Logger.Warn("Failed to get object by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission for the found object
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully retrieved object by account ref", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteByAccountRef(ctx context.Context, accountRef bson.ObjectID) error {
|
||||
db.Logger.Debug("Attempting to delete objects by account ref", mzap.AccRef(accountRef))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
query := repository.WithoutOrg(accountRef)
|
||||
|
||||
// Get all candidate objects for individual permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for delete by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permissions for each object individually (AccountBoundStorable doesn't have permission info)
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if err := db.enforceInterface(ctx, model.ActionUpdate, obj, accountRef); err == nil {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
} else if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.Logger.Warn("Error while enforcing update permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID()))
|
||||
return err
|
||||
}
|
||||
// If AccessDenied, we simply skip that object
|
||||
}
|
||||
|
||||
if len(allowedIDs) == 0 {
|
||||
db.Logger.Debug("No objects allowed for deletion by account ref", mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete only the allowed objects
|
||||
allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs))
|
||||
if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil {
|
||||
db.Logger.Warn("Failed to delete objects by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted objects by account ref", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return db.DBImp.DeleteCascade(ctx, objectRef)
|
||||
}
|
||||
|
||||
// CreateAccountBoundImp creates a concrete AccountBoundDBImp instance for internal use
|
||||
func CreateAccountBoundImp[T model.AccountBoundStorable](
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (*AccountBoundDBImp[T], error) {
|
||||
logger = logger.Named("account_bound")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch organization policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
res := &AccountBoundDBImp[T]{
|
||||
Logger: logger,
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
Enforcer: enforcer,
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestAccountBoundDBImp_Enforce tests the enforce method
|
||||
func TestAccountBoundDBImp_Enforce(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
db := &AccountBoundDBImp[model.AccountBoundStorable]{
|
||||
Logger: logger,
|
||||
PermissionRef: bson.NewObjectID(),
|
||||
Collection: "test_collection",
|
||||
}
|
||||
|
||||
t.Run("EnforceMethodExists", func(t *testing.T) {
|
||||
// Test that the enforce method exists and can be called
|
||||
// This is a basic test to ensure the method signature is correct
|
||||
assert.NotNil(t, db.enforce)
|
||||
})
|
||||
|
||||
t.Run("PermissionRefSet", func(t *testing.T) {
|
||||
// Test that PermissionRef is properly set
|
||||
assert.NotEqual(t, bson.NilObjectID, db.PermissionRef)
|
||||
})
|
||||
|
||||
t.Run("CollectionSet", func(t *testing.T) {
|
||||
// Test that Collection is properly set
|
||||
assert.Equal(t, "test_collection", db.Collection)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountBoundDBImp_InterfaceCompliance tests that the struct implements required interfaces
|
||||
func TestAccountBoundDBImp_InterfaceCompliance(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
db := &AccountBoundDBImp[model.AccountBoundStorable]{
|
||||
Logger: logger,
|
||||
PermissionRef: bson.NewObjectID(),
|
||||
Collection: "test_collection",
|
||||
}
|
||||
|
||||
t.Run("StructInitialization", func(t *testing.T) {
|
||||
// Test that the struct can be initialized
|
||||
assert.NotNil(t, db)
|
||||
assert.NotNil(t, db.Logger)
|
||||
assert.NotEqual(t, bson.NilObjectID, db.PermissionRef)
|
||||
assert.NotEmpty(t, db.Collection)
|
||||
})
|
||||
|
||||
t.Run("LoggerInitialization", func(t *testing.T) {
|
||||
// Test that logger is properly initialized
|
||||
assert.NotNil(t, db.Logger)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountBoundDBImp_ErrorHandling tests error handling patterns
|
||||
func TestAccountBoundDBImp_ErrorHandling(t *testing.T) {
|
||||
t.Run("AccessDeniedError", func(t *testing.T) {
|
||||
// Test that AccessDenied error is properly created
|
||||
err := merrors.AccessDenied("test_collection", "read", bson.NilObjectID)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrAccessDenied))
|
||||
})
|
||||
|
||||
t.Run("ErrorTypeChecking", func(t *testing.T) {
|
||||
// Test error type checking
|
||||
accessDeniedErr := merrors.AccessDenied("test", "read", bson.NilObjectID)
|
||||
otherErr := merrors.Define("other error")
|
||||
|
||||
assert.True(t, errors.Is(accessDeniedErr, merrors.ErrAccessDenied))
|
||||
assert.False(t, errors.Is(otherErr, merrors.ErrAccessDenied))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ownerKindField and ownerRefField are the canonical BSON paths produced by
|
||||
// model.Owner embedded with bson:"owner". Resources managed by ScopedDBImp
|
||||
// must use these same tags for BuildScopedQuery to produce correct filters.
|
||||
const (
|
||||
ownerKindField = "owner.kind"
|
||||
ownerRefField = "owner.ref"
|
||||
)
|
||||
|
||||
// ScopedDBImp provides generic CRUD, patch, and bulk operations for resources
|
||||
// that follow the personal-vs-org dual-ownership model (ownerKind + ownerRef).
|
||||
//
|
||||
// Unlike ProtectedDBImp, it never uses permissionRef as a routing sentinel.
|
||||
// Every read/mutate operation calls LoadAndEnforce, which reads ownerKind and
|
||||
// ownerRef from the authoritative database record before making the access
|
||||
// decision:
|
||||
// - personal (ownerKind=account): caller must be the owner (no policy consulted)
|
||||
// - org (ownerKind=organization): caller must pass the org policy check
|
||||
//
|
||||
// This eliminates the permissionRef==NilObjectID pattern that would otherwise
|
||||
// be required for personal-owned resources under ProtectedDBImp.
|
||||
type ScopedDBImp[T model.ScopedStorable] struct {
|
||||
DBImp *template.DBImp[T]
|
||||
scoped *ScopedDB[T]
|
||||
Enforcer Enforcer
|
||||
OrgPolicyRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
// NewScopedDBImp wires a ScopedDBImp. Panics on nil required dependencies to
|
||||
// surface misconfiguration at startup, matching the contract of NewScopedDB
|
||||
// and CreateDBImp.
|
||||
func NewScopedDBImp[T model.ScopedStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
enforcer Enforcer,
|
||||
membership MembershipChecker,
|
||||
orgPolicyRef bson.ObjectID,
|
||||
collection mservice.Type,
|
||||
newFn func() T,
|
||||
logger mlogger.Logger,
|
||||
) *ScopedDBImp[T] {
|
||||
if dbImp == nil {
|
||||
panic("auth.NewScopedDBImp: DBImp must not be nil")
|
||||
}
|
||||
if enforcer == nil {
|
||||
panic("auth.NewScopedDBImp: Enforcer must not be nil")
|
||||
}
|
||||
if membership == nil {
|
||||
panic("auth.NewScopedDBImp: MembershipChecker must not be nil")
|
||||
}
|
||||
if newFn == nil {
|
||||
panic("auth.NewScopedDBImp: New must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedDBImp[T]{
|
||||
DBImp: dbImp,
|
||||
Enforcer: enforcer,
|
||||
OrgPolicyRef: orgPolicyRef,
|
||||
Collection: collection,
|
||||
scoped: NewScopedDB(
|
||||
dbImp,
|
||||
NewScopedAuthorizer(membership, enforcer, logger.Named("scoped")),
|
||||
orgPolicyRef,
|
||||
collection,
|
||||
newFn,
|
||||
logger.Named("scoped"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (db *ScopedDBImp[T]) loadAndEnforce(ctx context.Context, accountRef, objectRef bson.ObjectID, action model.Action) (T, error) {
|
||||
return db.scoped.LoadAndEnforce(ctx, accountRef, objectRef, action)
|
||||
}
|
||||
|
||||
// Unprotected returns the raw DBImp for operations that bypass scoped auth.
|
||||
// Callers are responsible for their own authorization.
|
||||
func (db *ScopedDBImp[T]) Unprotected() template.DB[T] {
|
||||
return db.DBImp
|
||||
}
|
||||
|
||||
// BuildScopedQuery returns the ownership-aware OR-query for use in List
|
||||
// implementations. It mirrors the access model used by all other ScopedDBImp
|
||||
// operations: membership is checked first, then org policy read access.
|
||||
//
|
||||
// The returned query always includes personal resources owned by accountRef,
|
||||
// regardless of the organization they were created in — personal resources
|
||||
// follow their owner across every org the account is a member of. Org-owned
|
||||
// resources are bounded to organizationRef and added only when the caller is
|
||||
// a member of the org AND the org policy grants read access.
|
||||
//
|
||||
// Callers must compose the returned query with resource-specific filters
|
||||
// (additional field conditions, cursor pagination, etc.).
|
||||
//
|
||||
// Resources must embed model.Owner with bson:"owner" so the generated field
|
||||
// paths (owner.kind, owner.ref) match the stored documents.
|
||||
func (db *ScopedDBImp[T]) BuildScopedQuery(ctx context.Context, accountRef, organizationRef bson.ObjectID) (builder.Query, error) {
|
||||
personalQ := repository.Query().
|
||||
Filter(repository.Field(ownerKindField), model.OwnerKindAccount).
|
||||
Filter(repository.Field(ownerRefField), accountRef)
|
||||
|
||||
conditions := []builder.Query{personalQ}
|
||||
|
||||
isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef)
|
||||
if err != nil {
|
||||
// Membership backend failure degrades org access but must not prevent
|
||||
// the caller from seeing their own personal resources.
|
||||
db.scoped.Logger.Warn("membership check failed, returning personal-only results",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
} else if isMember {
|
||||
hasOrgAccess, enforceErr := db.Enforcer.Enforce(
|
||||
ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, model.ActionRead,
|
||||
)
|
||||
if enforceErr != nil {
|
||||
return nil, enforceErr
|
||||
}
|
||||
if hasOrgAccess {
|
||||
conditions = append(conditions, repository.Query().And(
|
||||
repository.OrgFilter(organizationRef),
|
||||
repository.Filter(ownerKindField, model.OwnerKindOrganization),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return repository.Query().Or(conditions...), nil
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Create inserts a new resource. If ownerKind is absent or invalid it defaults
|
||||
// to account.
|
||||
//
|
||||
// Personal path (ownerKind=account): requires the caller to be a member of
|
||||
// organizationRef (so a non-member cannot plant personal objects in a foreign
|
||||
// org namespace), then sets ownerRef=accountRef and inserts. The org policy is
|
||||
// not consulted — after creation, access is governed by ownership alone.
|
||||
//
|
||||
// Org path (ownerKind=organization): checks org membership and the org policy
|
||||
// (via authorizeOrgMutation) first, then stamps ownerRef/permissionRef and
|
||||
// inserts. Ownership fields are only mutated after the access check so a denied
|
||||
// caller's object is unchanged.
|
||||
func (db *ScopedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("attempting to create scoped object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection))
|
||||
|
||||
if !object.GetOwnerKind().IsValid() {
|
||||
object.SetOwnerKind(model.OwnerKindAccount)
|
||||
}
|
||||
|
||||
switch object.GetOwnerKind() {
|
||||
case model.OwnerKindAccount:
|
||||
if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerRef(accountRef)
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to create personal scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
case model.OwnerKindOrganization:
|
||||
if err := db.authorizeOrgMutation(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerRef(organizationRef)
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
object.SetPermissionRef(db.OrgPolicyRef)
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to create org scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
db.DBImp.Logger.Warn("unsupported ownerKind on create",
|
||||
zap.String("ownerKind", string(object.GetOwnerKind())),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return merrors.InvalidArgument("unsupported ownerKind", string(object.GetOwnerKind()))
|
||||
}
|
||||
}
|
||||
|
||||
// InsertMany bulk-inserts resources. Ownership fields are normalized per
|
||||
// object (defaulting to account when ownerKind is absent). Org membership, and
|
||||
// any required org policy check, run before any object is mutated.
|
||||
func (db *ScopedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error {
|
||||
if len(objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("attempting to insert many scoped objects",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.Int("count", len(objects)), zap.String("collection", db.Collection))
|
||||
|
||||
if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ownerKinds := make([]model.OwnerKind, len(objects))
|
||||
var hasOrgObjects bool
|
||||
for i, obj := range objects {
|
||||
ownerKind := obj.GetOwnerKind()
|
||||
if !ownerKind.IsValid() {
|
||||
ownerKind = model.OwnerKindAccount
|
||||
}
|
||||
switch ownerKind {
|
||||
case model.OwnerKindAccount:
|
||||
case model.OwnerKindOrganization:
|
||||
hasOrgObjects = true
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKind))
|
||||
}
|
||||
ownerKinds[i] = ownerKind
|
||||
}
|
||||
|
||||
if hasOrgObjects {
|
||||
if err := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i, obj := range objects {
|
||||
switch ownerKinds[i] {
|
||||
case model.OwnerKindAccount:
|
||||
obj.SetOwnerKind(model.OwnerKindAccount)
|
||||
obj.SetOwnerRef(accountRef)
|
||||
obj.SetOrganizationRef(organizationRef)
|
||||
case model.OwnerKindOrganization:
|
||||
obj.SetOwnerKind(model.OwnerKindOrganization)
|
||||
obj.SetOwnerRef(organizationRef)
|
||||
obj.SetOrganizationRef(organizationRef)
|
||||
obj.SetPermissionRef(db.OrgPolicyRef)
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKinds[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DBImp.InsertMany(ctx, objects); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to insert many scoped objects",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.Int("count", len(objects)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgMembership requires the caller to be a member of the
|
||||
// organization. It is the create-time gate shared by the personal and org
|
||||
// paths: since no record exists yet to read ownership from, membership in the
|
||||
// target org is the minimum proof that the caller may write into its
|
||||
// namespace. It fails closed — a membership backend error denies the mutation.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgMembership(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("membership check failed during create-time mutation",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
if !isMember {
|
||||
db.DBImp.Logger.Debug("access denied: caller is not a member of the organization",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgPolicy checks that the org policy allows the caller to perform
|
||||
// the action. It assumes membership has already been verified — callers must
|
||||
// go through authorizeOrgMembership (or authorizeOrgMutation) first.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgPolicy(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
ok, err := db.Enforcer.Enforce(ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
db.DBImp.Logger.Debug("access denied: org mutation policy",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgMutation gates a create-time org mutation: the caller must be a
|
||||
// member of the organization (authorizeOrgMembership) AND the org policy must
|
||||
// allow the action (authorizeOrgPolicy). Because no record exists yet to read
|
||||
// ownerKind from, it cannot go through loadAndEnforce; this mirrors the
|
||||
// membership-first check ScopedAuthorizer applies to read/update/delete of
|
||||
// org-owned resources.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgMutation(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
isMemberErr := db.authorizeOrgMembership(ctx, accountRef, organizationRef, action)
|
||||
if isMemberErr != nil {
|
||||
return isMemberErr
|
||||
}
|
||||
if isPolicyErr := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, action); isPolicyErr != nil {
|
||||
return isPolicyErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get fetches a resource after verifying ActionRead via LoadAndEnforce.
|
||||
func (db *ScopedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.DBImp.Logger.Debug("attempting to get scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionRead); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to get scoped object after auth",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update replaces a resource after verifying ActionUpdate. Immutable ownership
|
||||
// fields (ownerKind, ownerRef, orgRef, permissionRef) are always restored from
|
||||
// the database record so callers cannot change resource ownership via the payload.
|
||||
func (db *ScopedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("attempting to update scoped object",
|
||||
mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
existing, err := db.loadAndEnforce(ctx, accountRef, *object.GetID(), model.ActionUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerKind(existing.GetOwnerKind())
|
||||
object.SetOwnerRef(existing.GetOwnerRef())
|
||||
object.SetOrganizationRef(existing.GetOrganizationRef())
|
||||
object.SetPermissionRef(existing.GetPermissionRef())
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to update scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a resource after verifying ActionDelete.
|
||||
func (db *ScopedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("attempting to delete scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to delete scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCascadeAuth removes a resource (with cascade via the registered deleter)
|
||||
// after verifying ActionDelete.
|
||||
func (db *ScopedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("attempting cascade-delete scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to cascade-delete scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Patch applies a partial update after verifying ActionUpdate.
|
||||
func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.DBImp.Logger.Debug("attempting to patch scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := denyImmutablePatch(patch); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to patch scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetArchived patches the isArchived field after verifying ActionUpdate.
|
||||
//
|
||||
// organizationRef is accepted for interface compatibility but not used for
|
||||
// enforcement — ownership is read from the DB via LoadAndEnforce.
|
||||
// cascade is accepted but not applied; resources that require cascading archived
|
||||
// status must implement cascade logic in a dedicated wrapper.
|
||||
func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error {
|
||||
db.DBImp.Logger.Debug("attempting to change archive status of scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Bool("archived", isArchived))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
patch := repository.Patch().Set(repository.IsArchivedField(), isArchived)
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to change archive status of scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIDs returns the authorized subset of IDs matching query for the given
|
||||
// action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are
|
||||
// silently skipped. Non-access-denied errors abort the call.
|
||||
func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) {
|
||||
db.DBImp.Logger.Debug("attempting to list scoped object IDs",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
rawIDs, err := db.DBImp.ListIDs(ctx, query)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("failed to list raw object IDs",
|
||||
zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed := make([]bson.ObjectID, 0, len(rawIDs))
|
||||
for _, id := range rawIDs {
|
||||
if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil {
|
||||
allowed = append(allowed, id)
|
||||
} else if !errors.Is(authErr, merrors.ErrAccessDenied) {
|
||||
db.DBImp.Logger.Warn("enforcer error during ListIDs",
|
||||
zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id))
|
||||
return nil, authErr
|
||||
}
|
||||
// ErrAccessDenied: silently skip
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("completed ListIDs",
|
||||
mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed)))
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// PatchMany applies patch to all resources in query that accountRef is
|
||||
// authorized to update. Authorization is resolved via ListIDs; the write is a
|
||||
// single bulk operation using originalQuery AND _id IN (authorizedIDs) so
|
||||
// records that leave the original query between the read and write are not
|
||||
// inadvertently mutated.
|
||||
func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) {
|
||||
db.DBImp.Logger.Debug("attempting to patch many scoped objects",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
if err := denyImmutablePatch(patch); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
values := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
values[i] = id
|
||||
}
|
||||
idFilter := repository.Query().In(repository.IDField(), values...)
|
||||
finalQuery := idFilter
|
||||
if query != nil {
|
||||
finalQuery = query.And(idFilter)
|
||||
}
|
||||
modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("failed to patch many scoped objects",
|
||||
zap.Error(err), mzap.AccRef(accountRef))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("patched many scoped objects",
|
||||
mzap.AccRef(accountRef), zap.Int("count", modified))
|
||||
return modified, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Enforcer interface {
|
||||
// Enforce checks if accountRef can do `action` on objectRef in an org (domainRef).
|
||||
Enforce(
|
||||
ctx context.Context,
|
||||
permissionRef, accountRef, orgRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error)
|
||||
|
||||
// Enforce batch of objects
|
||||
EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error)
|
||||
|
||||
// GetRoles returns the user's roles in a given org domain, plus any partial scopes if relevant.
|
||||
GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error)
|
||||
|
||||
// GetPermissions returns all effective permissions (with effect, object scoping) for a user in org domain.
|
||||
// Merges from all roles the user holds, plus any denies/exceptions.
|
||||
GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func CreateAuth(
|
||||
logger mlogger.Logger,
|
||||
client *mongo.Client,
|
||||
db *mongo.Database,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
config *Config,
|
||||
) (Enforcer, Manager, error) {
|
||||
lg := logger.Named("auth")
|
||||
if config == nil || config.Driver == "" {
|
||||
lg.Warn("Permissions enforcer config is missing, defaulting to native enforcer")
|
||||
config = &Config{
|
||||
Driver: Native,
|
||||
}
|
||||
}
|
||||
lg.Debug("Creating enforcer...", zap.String("driver", string(config.Driver)))
|
||||
l := lg.Named(string(config.Driver))
|
||||
if config.Driver == Casbin {
|
||||
enforcer, err := casbin.NewEnforcer(l, client, config.Settings)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create enforcer", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, err := casbin.NewManager(l, pdb, rdb, enforcer, config.Settings)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create managment interface", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return enforcer, manager, nil
|
||||
}
|
||||
if config.Driver == Native {
|
||||
enforcer, err := native.NewEnforcer(l, db)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create enforcer", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, err := native.NewManager(l, pdb, rdb, enforcer)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create managment interface", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return enforcer, manager, nil
|
||||
}
|
||||
return nil, nil, merrors.InvalidArgument("Unknown enforcer type: "+string(config.Driver), "config.driver")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func enforceObject[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef bson.ObjectID, query builder.Query) error {
|
||||
l, err := db.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Error occured while checking access rights", zap.Error(err),
|
||||
mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if len(l) == 0 {
|
||||
db.Logger.Debug("Access denied", mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), bson.NilObjectID)
|
||||
}
|
||||
for _, item := range l {
|
||||
db.Logger.Debug("Object found", mzap.ObjRef("object_ref", *item.GetID()),
|
||||
mzap.OrgRef(item.GetOrganizationRef()),
|
||||
mzap.PermissionRef(item.GetPermissionRef()),
|
||||
zap.String("collection", item.Collection()))
|
||||
}
|
||||
res, err := enforcer.EnforceBatch(ctx, l, accountRef, action)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce permission", zap.Error(err),
|
||||
mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
}
|
||||
for objectRef, hasPermission := range res {
|
||||
if !hasPermission {
|
||||
db.Logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enforceObjectByRef[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef, objectRef bson.ObjectID) error {
|
||||
err := enforceObject(ctx, db, enforcer, action, accountRef, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
db.Logger.Debug("Access denied", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef)
|
||||
} else {
|
||||
db.Logger.Warn("Error occurred while checking permissions", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// IndexableDB implements reordering with permission checking
|
||||
type IndexableDB[T storable.Storable] interface {
|
||||
// Reorder implements reordering with permission checking using EnforceBatch
|
||||
Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error
|
||||
}
|
||||
|
||||
// NewIndexableDB creates a new auth.IndexableDB instance
|
||||
func NewIndexableDB[T storable.Storable](
|
||||
repo repository.Repository,
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getIndexable func(T) *model.Indexable,
|
||||
) IndexableDB[T] {
|
||||
return newIndexableDBImp(repo, logger, enforcer, createEmpty, getIndexable)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// IndexableDB implements reordering with permission checking
|
||||
type indexableDBImp[T storable.Storable] struct {
|
||||
repo repository.Repository
|
||||
logger mlogger.Logger
|
||||
enforcer Enforcer
|
||||
createEmpty func() T
|
||||
getIndexable func(T) *model.Indexable
|
||||
}
|
||||
|
||||
// NewIndexableDB creates a new auth.IndexableDB instance
|
||||
func newIndexableDBImp[T storable.Storable](
|
||||
repo repository.Repository,
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getIndexable func(T) *model.Indexable,
|
||||
) IndexableDB[T] {
|
||||
return &indexableDBImp[T]{
|
||||
repo: repo,
|
||||
logger: logger.Named("indexable"),
|
||||
enforcer: enforcer,
|
||||
createEmpty: createEmpty,
|
||||
getIndexable: getIndexable,
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder implements reordering with permission checking using EnforceBatch
|
||||
func (db *indexableDBImp[T]) Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error {
|
||||
// Get current object to find its index
|
||||
obj := db.createEmpty()
|
||||
if err := db.repo.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for reordering", zap.Error(err), zap.Int("new_index", newIndex),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract index from the object
|
||||
indexable := db.getIndexable(obj)
|
||||
currentIndex := indexable.Index
|
||||
if currentIndex == newIndex {
|
||||
db.logger.Debug("No reordering needed - same index", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
// Determine which objects will be affected by the reordering
|
||||
var affectedObjects []model.PermissionBoundStorable
|
||||
|
||||
if currentIndex < newIndex {
|
||||
// Moving down: items between currentIndex+1 and newIndex will be shifted up by -1
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(currentIndex+1, builder.Gte)).
|
||||
And(repository.IndexOpFilter(newIndex, builder.Lte))
|
||||
|
||||
// Get all affected objects using ListPermissionBound
|
||||
objects, err := db.repo.ListPermissionBound(ctx, reorderFilter)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get affected objects for reordering (moving down)",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
affectedObjects = append(affectedObjects, objects...)
|
||||
db.logger.Debug("Found affected objects for moving down",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects)))
|
||||
} else {
|
||||
// Moving up: items between newIndex and currentIndex-1 will be shifted down by +1
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(newIndex, builder.Gte)).
|
||||
And(repository.IndexOpFilter(currentIndex-1, builder.Lte))
|
||||
|
||||
// Get all affected objects using ListPermissionBound
|
||||
objects, err := db.repo.ListPermissionBound(ctx, reorderFilter)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get affected objects for reordering (moving up)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
affectedObjects = append(affectedObjects, objects...)
|
||||
db.logger.Debug("Found affected objects for moving up", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects)))
|
||||
}
|
||||
|
||||
// Add the target object to the list of objects that need permission checking
|
||||
targetObjects, err := db.repo.ListPermissionBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get target object for permission checking", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(targetObjects) > 0 {
|
||||
affectedObjects = append(affectedObjects, targetObjects[0])
|
||||
}
|
||||
|
||||
// Check permissions for all affected objects using EnforceBatch
|
||||
db.logger.Debug("Checking permissions for reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
|
||||
permissions, err := db.enforcer.EnforceBatch(ctx, affectedObjects, accountRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to check permissions for reordering", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)))
|
||||
return merrors.Internal("failed to check permissions for reordering")
|
||||
}
|
||||
|
||||
// Verify all objects have update permission
|
||||
for resObjectRef, hasPermission := range permissions {
|
||||
if !hasPermission {
|
||||
db.logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate)))
|
||||
return merrors.AccessDenied(db.repo.Collection(), string(model.ActionUpdate), resObjectRef)
|
||||
}
|
||||
}
|
||||
|
||||
db.logger.Debug("All permissions granted, proceeding with reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("permission_count", len(permissions)))
|
||||
|
||||
// All permissions checked, proceed with reordering
|
||||
if currentIndex < newIndex {
|
||||
// Moving down: shift items between currentIndex+1 and newIndex up by -1
|
||||
patch := repository.Patch().Inc(repository.IndexField(), -1)
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(currentIndex+1, builder.Gte)).
|
||||
And(repository.IndexOpFilter(newIndex, builder.Lte))
|
||||
|
||||
updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to shift objects during reordering (moving down)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount))
|
||||
return err
|
||||
}
|
||||
db.logger.Debug("Successfully shifted objects (moving down)", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount))
|
||||
} else {
|
||||
// Moving up: shift items between newIndex and currentIndex-1 down by +1
|
||||
patch := repository.Patch().Inc(repository.IndexField(), 1)
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(newIndex, builder.Gte)).
|
||||
And(repository.IndexOpFilter(currentIndex-1, builder.Lte))
|
||||
|
||||
updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to shift objects during reordering (moving up)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount))
|
||||
return err
|
||||
}
|
||||
db.logger.Debug("Successfully shifted objects (moving up)", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount))
|
||||
}
|
||||
|
||||
// Update the target object to new index
|
||||
if err := db.repo.Patch(ctx, objectRef, repository.Patch().Set(repository.IndexField(), newIndex)); err != nil {
|
||||
db.logger.Warn("Failed to update target object index", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
|
||||
db.logger.Debug("Successfully reordered object with permission checking",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("old_index", currentIndex),
|
||||
zap.Int("new_index", newIndex), zap.Int("affected_count", len(affectedObjects)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AdapterConfig struct {
|
||||
DatabaseName *string `mapstructure:"database_name"`
|
||||
DatabaseNameEnv *string `mapstructure:"database_name_env"`
|
||||
CollectionName *string `mapstructure:"collection_name"`
|
||||
CollectionNameEnv *string `mapstructure:"collection_name_env"`
|
||||
TimeoutSeconds *int `mapstructure:"timeout_seconds"`
|
||||
TimeoutSecondsEnv *string `mapstructure:"timeout_seconds_env"`
|
||||
IsFiltered *bool `mapstructure:"is_filtered"`
|
||||
IsFilteredEnv *string `mapstructure:"is_filtered_env"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ModelPath *string `mapstructure:"model_path"`
|
||||
ModelPathEnv *string `mapstructure:"model_path_env"`
|
||||
Adapter *AdapterConfig `mapstructure:"adapter"`
|
||||
}
|
||||
|
||||
type EnforcerConfig struct {
|
||||
ModelPath string
|
||||
Adapter *mongoadapter.Config
|
||||
}
|
||||
|
||||
func getEnvValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
return os.Getenv(*envValue)
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getEnvIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
envStr := os.Getenv(*envValue)
|
||||
if envStr != "" {
|
||||
if parsed, err := time.ParseDuration(envStr + "s"); err == nil {
|
||||
return int(parsed.Seconds())
|
||||
}
|
||||
logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr))
|
||||
}
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return 30 // Default timeout in seconds
|
||||
}
|
||||
|
||||
func getEnvBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
envStr := os.Getenv(*envValue)
|
||||
switch envStr {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
}
|
||||
logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr))
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return false // Default for boolean
|
||||
}
|
||||
|
||||
func PrepareConfig(logger mlogger.Logger, config *Config) (*EnforcerConfig, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.Internal("No configuration provided")
|
||||
}
|
||||
|
||||
adapter := &mongoadapter.Config{
|
||||
DatabaseName: getEnvValue(logger, "database_name", "database_name_env", config.Adapter.DatabaseName, config.Adapter.DatabaseNameEnv),
|
||||
CollectionName: getEnvValue(logger, "collection_name", "collection_name_env", config.Adapter.CollectionName, config.Adapter.CollectionNameEnv),
|
||||
Timeout: time.Duration(getEnvIntValue(logger, "timeout_seconds", "timeout_seconds_env", config.Adapter.TimeoutSeconds, config.Adapter.TimeoutSecondsEnv)) * time.Second,
|
||||
IsFiltered: getEnvBoolValue(logger, "is_filtered", "is_filtered_env", config.Adapter.IsFiltered, config.Adapter.IsFilteredEnv),
|
||||
}
|
||||
|
||||
if len(adapter.DatabaseName) == 0 {
|
||||
logger.Error("Database name is not set")
|
||||
return nil, merrors.InvalidArgument("database name must be provided", "adapter.databaseName")
|
||||
}
|
||||
|
||||
path := getEnvValue(logger, "model_path", "model_path_env", config.ModelPath, config.ModelPathEnv)
|
||||
|
||||
logger.Info("Configuration prepared",
|
||||
zap.String("model_path", path),
|
||||
zap.String("database_name", adapter.DatabaseName),
|
||||
zap.String("collection_name", adapter.CollectionName),
|
||||
zap.Duration("timeout", adapter.Timeout),
|
||||
zap.Bool("is_filtered", adapter.IsFiltered),
|
||||
)
|
||||
|
||||
return &EnforcerConfig{ModelPath: path, Adapter: adapter}, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// casbin_enforcer.go
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"github.com/casbin/casbin/v3"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinEnforcer implements the Enforcer interface using Casbin.
|
||||
type CasbinEnforcer struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *casbin.Enforcer
|
||||
roleSerializer serialization.Role
|
||||
permissionSerializer serialization.Policy
|
||||
}
|
||||
|
||||
// NewCasbinEnforcer initializes a new CasbinEnforcer with a MongoDB adapter, logger, and PolicySerializer.
|
||||
// The 'domain' parameter is no longer stored internally, as the interface requires passing a domainRef per method call.
|
||||
func NewEnforcer(
|
||||
logger mlogger.Logger,
|
||||
client *mongo.Client,
|
||||
settings model.SettingsT,
|
||||
) (*CasbinEnforcer, error) {
|
||||
var config cc.Config
|
||||
if err := mapstructure.Decode(settings, &config); err != nil {
|
||||
logger.Warn("Failed to decode Casbin configuration", zap.Error(err), zap.Any("settings", settings))
|
||||
return nil, merrors.Internal("failed to decode Casbin configuration")
|
||||
}
|
||||
|
||||
// Create a Casbin adapter + enforcer from your config and client.
|
||||
l := logger.Named("enforcer")
|
||||
e, err := createAdapter(l, &config, client)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create Casbin enforcer", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create Casbin enforcer")
|
||||
}
|
||||
|
||||
logger.Info("Casbin enforcer created")
|
||||
return &CasbinEnforcer{
|
||||
logger: l,
|
||||
enforcer: e,
|
||||
permissionSerializer: serialization.NewPolicySerializer(),
|
||||
roleSerializer: serialization.NewRoleSerializer(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enforce checks if a user has the specified action permission on an object within a domain.
|
||||
func (c *CasbinEnforcer) Enforce(
|
||||
_ context.Context,
|
||||
permissionRef, accountRef, organizationRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error) {
|
||||
// Convert ObjectIDs to strings for Casbin
|
||||
account := accountRef.Hex()
|
||||
organization := organizationRef.Hex()
|
||||
permission := permissionRef.Hex()
|
||||
object := anyobject.ID
|
||||
if objectRef != bson.NilObjectID {
|
||||
object = objectRef.Hex()
|
||||
}
|
||||
act := string(action)
|
||||
|
||||
c.logger.Debug("Enforcing policy",
|
||||
zap.String("account", account), zap.String("organization", organization),
|
||||
zap.String("permission", permission), zap.String("object", object),
|
||||
zap.String("action", act))
|
||||
|
||||
// Perform the enforcement
|
||||
result, err := c.enforcer.Enforce(account, organization, permission, object, act)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to enforce policy", zap.Error(err),
|
||||
zap.String("account", account), zap.String("organization", organization),
|
||||
zap.String("permission", permission), zap.String("object", object),
|
||||
zap.String("action", act))
|
||||
return false, err
|
||||
}
|
||||
|
||||
c.logger.Debug("Policy enforcement result", zap.Bool("result", result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EnforceBatch checks a user’s permission for multiple objects at once.
|
||||
// It returns a map from objectRef -> boolean indicating whether access is granted.
|
||||
func (c *CasbinEnforcer) EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error) {
|
||||
results := make(map[bson.ObjectID]bool, len(objectRefs))
|
||||
for _, desc := range objectRefs {
|
||||
ok, err := c.Enforce(ctx, desc.GetPermissionRef(), accountRef, desc.GetOrganizationRef(), *desc.GetID(), action)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to enforce", zap.Error(err), mzap.PermissionRef(desc.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()),
|
||||
mzap.StorableRef(desc), zap.String("action", string(action)))
|
||||
return nil, err
|
||||
}
|
||||
results[*desc.GetID()] = ok
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRoles retrieves all roles assigned to the user within the domain.
|
||||
func (c *CasbinEnforcer) GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) {
|
||||
sub := accountRef.Hex()
|
||||
dom := orgRef.Hex()
|
||||
|
||||
c.logger.Debug("Fetching roles for user", zap.String("subject", sub), zap.String("domain", dom))
|
||||
|
||||
// Get all roles for the user in the domain
|
||||
sroles, err := c.enforcer.GetFilteredGroupingPolicy(0, sub, "", dom)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get roles from policies", zap.Error(err),
|
||||
zap.String("account_ref", sub), zap.String("organization_ref", dom),
|
||||
)
|
||||
return nil, merrors.Internal("failed to fetch roles from policies")
|
||||
}
|
||||
|
||||
roles := make([]model.Role, 0, len(sroles))
|
||||
for _, srole := range sroles {
|
||||
role, err := c.roleSerializer.Deserialize(srole)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to deserialize role", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
|
||||
c.logger.Debug("Roles fetched successfully", zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// GetPermissions retrieves all effective policies for the user within the domain.
|
||||
func (c *CasbinEnforcer) GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
c.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
|
||||
// Step 1: Retrieve all roles assigned to the user within the domain
|
||||
roles, err := c.GetRoles(ctx, accountRef, orgRef)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get roles", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Map to hold unique policies
|
||||
permissionsMap := make(map[string]*model.Permission)
|
||||
for _, role := range roles {
|
||||
// Step 2a: Retrieve all policies associated with the role within the domain
|
||||
policies, err := c.enforcer.GetFilteredPolicy(0, role.DescriptionRef.Hex())
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2b: Process each policy to extract Permission, Action, and Effect
|
||||
for _, policy := range policies {
|
||||
|
||||
if len(policy) < 5 {
|
||||
c.logger.Warn("Incomplete policy encountered", zap.Strings("policy", policy))
|
||||
continue // Ensure the policy line has enough fields
|
||||
}
|
||||
|
||||
// Deserialize the policy using
|
||||
deserializedPolicy, err := c.permissionSerializer.Deserialize(policy)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.Strings("policy", policy))
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct a unique key combining Permission ID and Action to prevent duplicates
|
||||
policyKey := deserializedPolicy.DescriptionRef.Hex() + ":" + string(deserializedPolicy.Effect.Action)
|
||||
if _, exists := permissionsMap[policyKey]; exists {
|
||||
continue // Policy-action pair already accounted for
|
||||
}
|
||||
|
||||
// Add the Policy to the map
|
||||
permissionsMap[policyKey] = &model.Permission{
|
||||
RolePolicy: *deserializedPolicy,
|
||||
AccountRef: accountRef,
|
||||
}
|
||||
c.logger.Debug("Policy added to policyMap", zap.Any("policy_key", policyKey))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map to a slice
|
||||
permissions := make([]model.Permission, 0, len(permissionsMap))
|
||||
for _, permission := range permissionsMap {
|
||||
permissions = append(permissions, *permission)
|
||||
}
|
||||
|
||||
c.logger.Debug("Permissions fetched successfully", zap.Int("count", len(permissions)))
|
||||
return roles, permissions, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/casbin/casbin/v3"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func createAdapter(logger mlogger.Logger, config *cc.Config, client *mongo.Client) (*casbin.Enforcer, error) {
|
||||
dbc, err := cc.PrepareConfig(logger, config)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to prepare database configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adapter, err := mongoadapter.New(client, dbc.Adapter)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create DB adapter", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e, err := casbin.NewEnforcer(dbc.ModelPath, adapter, NewCasbinLogger(logger))
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create permissions enforcer", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
e.EnableAutoSave(true)
|
||||
|
||||
// No need to manually load policy. Casbin does it for us
|
||||
return e, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinZapLogger wraps a zap.Logger to implement Casbin's Logger interface.
|
||||
type CasbinZapLogger struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
// NewCasbinLogger constructs a new CasbinZapLogger.
|
||||
func NewCasbinLogger(logger mlogger.Logger) *CasbinZapLogger {
|
||||
return &CasbinZapLogger{
|
||||
logger: logger.Named("driver"),
|
||||
}
|
||||
}
|
||||
|
||||
// EnableLog enables or disables logging.
|
||||
func (l *CasbinZapLogger) EnableLog(_ bool) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// IsEnabled returns whether logging is currently enabled.
|
||||
func (l *CasbinZapLogger) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// LogModel is called by Casbin when loading model settings (you can customize if you want).
|
||||
func (l *CasbinZapLogger) LogModel(m [][]string) {
|
||||
l.logger.Info("Model loaded", zap.Any("model", m))
|
||||
}
|
||||
|
||||
func (l *CasbinZapLogger) LogPolicy(m map[string][][]string) {
|
||||
l.logger.Info("Policy loaded", zap.Int("entries", len(m)))
|
||||
}
|
||||
|
||||
func (l *CasbinZapLogger) LogError(err error, msg ...string) {
|
||||
// If no custom message was passed, log a generic one
|
||||
if len(msg) == 0 {
|
||||
l.logger.Warn("Error occurred", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, join any provided messages and include them
|
||||
l.logger.Warn(strings.Join(msg, " "), zap.Error(err))
|
||||
}
|
||||
|
||||
// LogEnforce is called by Casbin to log each Enforce() call if logging is enabled.
|
||||
func (l *CasbinZapLogger) LogEnforce(matcher string, request []any, result bool, explains [][]string) {
|
||||
l.logger.Debug("Enforcing policy...", zap.String("matcher", matcher), zap.Any("request", request),
|
||||
zap.Bool("result", result), zap.Any("explains", explains))
|
||||
}
|
||||
|
||||
// LogRole is called by Casbin when role manager adds or deletes a role.
|
||||
func (l *CasbinZapLogger) LogRole(roles []string) {
|
||||
l.logger.Debug("Changing roles...", zap.Strings("roles", roles))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// package casbin
|
||||
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinManager implements the auth.Manager interface by aggregating Role and Permission managers.
|
||||
type CasbinManager struct {
|
||||
logger mlogger.Logger
|
||||
roleManager management.Role
|
||||
permManager management.Permission
|
||||
}
|
||||
|
||||
// NewManager creates a new CasbinManager with specified domains and role-domain mappings.
|
||||
func NewManager(
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
enforcer *CasbinEnforcer,
|
||||
settings model.SettingsT,
|
||||
) (*CasbinManager, error) {
|
||||
logger := l.Named("manager")
|
||||
|
||||
var pdesc model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil {
|
||||
logger.Warn("Failed to fetch roles permission reference", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CasbinManager{
|
||||
logger: logger,
|
||||
roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb),
|
||||
permManager: NewPermissionManager(logger, enforcer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Permission returns the Permission manager.
|
||||
func (m *CasbinManager) Permission() management.Permission {
|
||||
return m.permManager
|
||||
}
|
||||
|
||||
// Role returns the Role manager.
|
||||
func (m *CasbinManager) Role() management.Role {
|
||||
return m.roleManager
|
||||
}
|
||||
|
||||
// TransactionalGrants reports that casbin grants live in the enforcer's memory
|
||||
// until Save rewrites the whole policy store: they neither respect a Mongo
|
||||
// transaction context nor survive concurrent Save calls from other instances.
|
||||
func (m *CasbinManager) TransactionalGrants() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package mongoadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"time"
|
||||
|
||||
"github.com/casbin/casbin/v3/model"
|
||||
"github.com/casbin/casbin/v3/persist"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDatabaseName = "casbin"
|
||||
defaultCollectionName = "casbin_rule"
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
_ persist.BatchAdapter = (*Adapter)(nil)
|
||||
_ persist.FilteredAdapter = (*Adapter)(nil)
|
||||
_ persist.UpdatableAdapter = (*Adapter)(nil)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseName string
|
||||
CollectionName string
|
||||
Timeout time.Duration
|
||||
IsFiltered bool
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
PType string
|
||||
V0 string
|
||||
V1 string
|
||||
V2 string
|
||||
V3 string
|
||||
V4 string
|
||||
V5 string
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
collection *mongo.Collection
|
||||
timeout time.Duration
|
||||
filtered bool
|
||||
}
|
||||
|
||||
func New(client *mongo.Client, config *Config) (*Adapter, error) {
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
|
||||
databaseName := config.DatabaseName
|
||||
if databaseName == "" {
|
||||
databaseName = defaultDatabaseName
|
||||
}
|
||||
collectionName := config.CollectionName
|
||||
if collectionName == "" {
|
||||
collectionName = defaultCollectionName
|
||||
}
|
||||
timeout := config.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
adapter := &Adapter{
|
||||
collection: client.Database(databaseName).Collection(collectionName),
|
||||
timeout: timeout,
|
||||
filtered: config.IsFiltered,
|
||||
}
|
||||
if err := adapter.prepareIndexes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) prepareIndexes() error {
|
||||
keys := bson.D{
|
||||
{Key: "ptype", Value: 1},
|
||||
{Key: "v0", Value: 1},
|
||||
{Key: "v1", Value: 1},
|
||||
{Key: "v2", Value: 1},
|
||||
{Key: "v3", Value: 1},
|
||||
{Key: "v4", Value: 1},
|
||||
{Key: "v5", Value: 1},
|
||||
}
|
||||
|
||||
_, err := a.collection.Indexes().CreateOne(
|
||||
context.Background(),
|
||||
mongo.IndexModel{
|
||||
Keys: keys,
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) withTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), a.timeout)
|
||||
}
|
||||
|
||||
func loadPolicyLine(line Rule, m model.Model) error {
|
||||
policy := line.policyWithType()
|
||||
for len(policy) > 0 && policy[len(policy)-1] == "" {
|
||||
policy = policy[:len(policy)-1]
|
||||
}
|
||||
if len(policy) == 0 {
|
||||
return nil
|
||||
}
|
||||
return persist.LoadPolicyArray(policy, m)
|
||||
}
|
||||
|
||||
func (r Rule) policyWithType() []string {
|
||||
return []string{r.PType, r.V0, r.V1, r.V2, r.V3, r.V4, r.V5}
|
||||
}
|
||||
|
||||
func (r Rule) policyValues() []string {
|
||||
values := []string{r.V0, r.V1, r.V2, r.V3, r.V4, r.V5}
|
||||
for len(values) > 0 && values[len(values)-1] == "" {
|
||||
values = values[:len(values)-1]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func ruleFromPolicy(ptype string, policy []string) Rule {
|
||||
rule := Rule{PType: ptype}
|
||||
if len(policy) > 0 {
|
||||
rule.V0 = policy[0]
|
||||
}
|
||||
if len(policy) > 1 {
|
||||
rule.V1 = policy[1]
|
||||
}
|
||||
if len(policy) > 2 {
|
||||
rule.V2 = policy[2]
|
||||
}
|
||||
if len(policy) > 3 {
|
||||
rule.V3 = policy[3]
|
||||
}
|
||||
if len(policy) > 4 {
|
||||
rule.V4 = policy[4]
|
||||
}
|
||||
if len(policy) > 5 {
|
||||
rule.V5 = policy[5]
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func rulesFromPolicies(ptype string, policies [][]string) []Rule {
|
||||
rules := make([]Rule, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
rules = append(rules, ruleFromPolicy(ptype, policy))
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func rulesAsDocuments(rules []Rule) []any {
|
||||
docs := make([]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
docs = append(docs, rule)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func selectorForPolicy(ptype string, fieldIndex int, fieldValues ...string) bson.M {
|
||||
selector := bson.M{"ptype": ptype}
|
||||
fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"}
|
||||
for i, fieldValue := range fieldValues {
|
||||
if fieldValue == "" {
|
||||
continue
|
||||
}
|
||||
field := fieldIndex + i
|
||||
if field < 0 || field >= len(fields) {
|
||||
continue
|
||||
}
|
||||
selector[fields[field]] = fieldValue
|
||||
}
|
||||
return selector
|
||||
}
|
||||
|
||||
func (a *Adapter) LoadPolicy(m model.Model) error {
|
||||
return a.LoadFilteredPolicy(m, nil)
|
||||
}
|
||||
|
||||
func (a *Adapter) LoadFilteredPolicy(m model.Model, filter any) error {
|
||||
if filter == nil {
|
||||
a.filtered = false
|
||||
filter = bson.D{}
|
||||
} else {
|
||||
a.filtered = true
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
cursor, err := a.collection.Find(ctx, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = cursor.Close(ctx)
|
||||
}()
|
||||
|
||||
for cursor.Next(ctx) {
|
||||
var line Rule
|
||||
if err := cursor.Decode(&line); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadPolicyLine(line, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cursor.Err()
|
||||
}
|
||||
|
||||
func (a *Adapter) IsFiltered() bool {
|
||||
return a.filtered
|
||||
}
|
||||
|
||||
func (a *Adapter) SavePolicy(m model.Model) error {
|
||||
if a.filtered {
|
||||
return merrors.Define("cannot save a filtered policy")
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
if _, err := a.collection.DeleteMany(ctx, bson.D{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var docs []any
|
||||
for ptype, ast := range m["p"] {
|
||||
docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...)
|
||||
}
|
||||
for ptype, ast := range m["g"] {
|
||||
docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...)
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := a.collection.InsertMany(ctx, docs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) AddPolicy(_ string, ptype string, policy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.InsertOne(ctx, ruleFromPolicy(ptype, policy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) AddPolicies(_ string, ptype string, policies [][]string) error {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, policies)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) RemovePolicy(_ string, ptype string, policy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.DeleteOne(ctx, ruleFromPolicy(ptype, policy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) RemovePolicies(_ string, ptype string, policies [][]string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
for _, rule := range rulesFromPolicies(ptype, policies) {
|
||||
if _, err := a.collection.DeleteOne(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.DeleteMany(ctx, selectorForPolicy(ptype, fieldIndex, fieldValues...))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdatePolicy(_ string, ptype string, oldPolicy, newPolicy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicy), ruleFromPolicy(ptype, newPolicy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdatePolicies(_ string, ptype string, oldPolicies, newPolicies [][]string) error {
|
||||
if len(oldPolicies) != len(newPolicies) {
|
||||
return merrors.Define("old and new policy counts differ")
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
for i := range oldPolicies {
|
||||
if _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicies[i]), ruleFromPolicy(ptype, newPolicies[i])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdateFilteredPolicies(_ string, ptype string, newPolicies [][]string, fieldIndex int, fieldValues ...string) ([][]string, error) {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
selector := selectorForPolicy(ptype, fieldIndex, fieldValues...)
|
||||
cursor, err := a.collection.Find(ctx, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var oldPolicies [][]string
|
||||
for cursor.Next(ctx) {
|
||||
var line Rule
|
||||
if err := cursor.Decode(&line); err != nil {
|
||||
_ = cursor.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
oldPolicies = append(oldPolicies, line.policyValues())
|
||||
}
|
||||
if err := cursor.Close(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cursor.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := a.collection.DeleteMany(ctx, selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(newPolicies) == 0 {
|
||||
return oldPolicies, nil
|
||||
}
|
||||
|
||||
if _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, newPolicies))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oldPolicies, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package mongoadapter
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/casbin/casbin/v3/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestRuleFromPolicyTrimsOnlyTrailingEmptyValues(t *testing.T) {
|
||||
rule := ruleFromPolicy("p", []string{"role", "org", "", "object", ""})
|
||||
|
||||
if got, want := rule.PType, "p"; got != want {
|
||||
t.Fatalf("PType = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := rule.policyValues(), []string{"role", "org", "", "object"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("policyValues() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorForPolicySkipsEmptyAndOutOfRangeFields(t *testing.T) {
|
||||
got := selectorForPolicy("p", 1, "role", "", "object", "read", "ignored", "ignored")
|
||||
want := bson.M{
|
||||
"ptype": "p",
|
||||
"v1": "role",
|
||||
"v3": "object",
|
||||
"v4": "read",
|
||||
"v5": "ignored",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("selectorForPolicy() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPolicyLineAddsPolicyToModel(t *testing.T) {
|
||||
m, err := model.NewModelFromString(`
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("NewModelFromString() error = %v", err)
|
||||
}
|
||||
|
||||
err = loadPolicyLine(Rule{PType: "p", V0: "alice", V1: "data1", V2: "read"}, m)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPolicyLine() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := m.GetPolicy("p", "p")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicy() error = %v", err)
|
||||
}
|
||||
want := [][]string{{"alice", "data1", "read"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("GetPolicy() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinPermissionManager manages permissions using Casbin.
|
||||
type CasbinPermissionManager struct {
|
||||
logger mlogger.Logger // Logger for logging operations
|
||||
enforcer *CasbinEnforcer // Casbin enforcer for managing policies
|
||||
serializer serialization.Policy // Serializer for converting policies to/from Casbin
|
||||
}
|
||||
|
||||
// GrantToRole adds a permission to a role in Casbin.
|
||||
func (m *CasbinPermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := anyobject.ID
|
||||
if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
m.logger.Debug("Granting permission to role",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)),
|
||||
zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
// Serialize permission
|
||||
serializedPolicy, err := m.serializer.Serialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to serialize permission while granting permission", zap.Error(err),
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
mzap.OrgRef(policy.OrganizationRef),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add policy to Casbin
|
||||
added, err := m.enforcer.enforcer.AddPolicy(serializedPolicy...)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to add policy to Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if added {
|
||||
m.logger.Info("Policy added to Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
} else {
|
||||
m.logger.Warn("Policy already exists in Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeFromRole removes a permission from a role in Casbin.
|
||||
func (m *CasbinPermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := anyobject.ID
|
||||
if policy.ObjectRef != nil {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
m.logger.Debug("Revoking permission from role",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)),
|
||||
zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
// Serialize policy
|
||||
serializedPolicy, err := m.serializer.Serialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to serialize policy while revoking permission from role",
|
||||
zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.ObjRef("policy_ref", policy.DescriptionRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove policy from Casbin
|
||||
removed, err := m.enforcer.enforcer.RemovePolicy(serializedPolicy...)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to remove policy from Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if removed {
|
||||
m.logger.Info("Policy removed from Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
} else {
|
||||
m.logger.Warn("Policy does not exist in Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicies retrieves all policies for a specific role.
|
||||
func (m *CasbinPermissionManager) GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error) {
|
||||
m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef))
|
||||
|
||||
// Retrieve Casbin policies for the role
|
||||
policies, err := m.enforcer.enforcer.GetFilteredPolicy(0, roleRef.Hex())
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to get policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return nil, err
|
||||
}
|
||||
if len(policies) == 0 {
|
||||
m.logger.Info("No policies found for role", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil, merrors.NoData("no policies")
|
||||
}
|
||||
|
||||
// Deserialize policies
|
||||
var result []model.RolePolicy
|
||||
for _, policy := range policies {
|
||||
permission, err := m.serializer.Deserialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.String("policy", policy[0]))
|
||||
continue
|
||||
}
|
||||
result = append(result, *permission)
|
||||
}
|
||||
|
||||
m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(result)))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Save persists changes to the Casbin policy store.
|
||||
func (m *CasbinPermissionManager) Save() error {
|
||||
if err := m.enforcer.enforcer.SavePolicy(); err != nil {
|
||||
m.logger.Error("Failed to save policies in Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
m.logger.Info("Policies successfully saved in Casbin")
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPermissionManager(logger mlogger.Logger, enforcer *CasbinEnforcer) *CasbinPermissionManager {
|
||||
return &CasbinPermissionManager{
|
||||
logger: logger.Named("permission"),
|
||||
enforcer: enforcer,
|
||||
serializer: serialization.NewPolicySerializer(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RoleManager manages roles using Casbin.
|
||||
type RoleManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *CasbinEnforcer
|
||||
rdb role.DB
|
||||
rolePermissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
// NewRoleManager creates a new RoleManager.
|
||||
func NewRoleManager(logger mlogger.Logger, enforcer *CasbinEnforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager {
|
||||
return &RoleManager{
|
||||
logger: logger.Named("role"),
|
||||
enforcer: enforcer,
|
||||
rdb: rdb,
|
||||
rolePermissionRef: rolePermissionRef,
|
||||
}
|
||||
}
|
||||
|
||||
// validateObjectIDs ensures that all provided ObjectIDs are non-zero.
|
||||
func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error {
|
||||
for _, id := range ids {
|
||||
if id.IsZero() {
|
||||
return merrors.InvalidArgument("Object references cannot be zero", "objectRef")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePolicies removes policies based on the provided filter and logs the results.
|
||||
func (rm *RoleManager) removePolicies(policyType, role string, roleRef bson.ObjectID) error {
|
||||
filterIndex := 1
|
||||
if policyType == "permission" {
|
||||
filterIndex = 0
|
||||
}
|
||||
policies, err := rm.enforcer.enforcer.GetFilteredPolicy(filterIndex, role)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch "+policyType+" policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
args := make([]any, len(policy))
|
||||
for i, v := range policy {
|
||||
args[i] = v
|
||||
}
|
||||
var removed bool
|
||||
var removeErr error
|
||||
if policyType == "grouping" {
|
||||
removed, removeErr = rm.enforcer.enforcer.RemoveGroupingPolicy(args...)
|
||||
} else {
|
||||
removed, removeErr = rm.enforcer.enforcer.RemovePolicy(args...)
|
||||
}
|
||||
|
||||
if removeErr != nil {
|
||||
rm.logger.Warn("Failed to remove "+policyType+" policy for role", zap.Error(removeErr), mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy))
|
||||
return removeErr
|
||||
}
|
||||
if removed {
|
||||
rm.logger.Info("Removed "+policyType+" policy for role", mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRolesFromPolicies retrieves and converts policies to roles.
|
||||
func (rm *RoleManager) fetchRolesFromPolicies(policies [][]string, orgRef bson.ObjectID) []model.RoleDescription {
|
||||
roles := make([]model.RoleDescription, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
if len(policy) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
roleID, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
rm.logger.Warn("Invalid role ID", zap.String("roleID", policy[1]))
|
||||
continue
|
||||
}
|
||||
roles = append(roles, model.RoleDescription{Base: storable.Base{ID: roleID}, OrganizationRef: orgRef})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// Create creates a new role in an organization.
|
||||
func (rm *RoleManager) Create(ctx context.Context, orgRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) {
|
||||
if err := rm.validateObjectIDs(orgRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := &model.RoleDescription{
|
||||
Describable: *description,
|
||||
OrganizationRef: orgRef,
|
||||
}
|
||||
if err := rm.rdb.Create(ctx, role); err != nil {
|
||||
rm.logger.Warn("Failed to create role", zap.Error(err), mzap.ObjRef("organiztion_ref", orgRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(orgRef))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Assign assigns a role to a user in the given organization.
|
||||
func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error {
|
||||
if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub := role.AccountRef.Hex()
|
||||
roleID := role.DescriptionRef.Hex()
|
||||
domain := role.OrganizationRef.Hex()
|
||||
|
||||
added, err := rm.enforcer.enforcer.AddGroupingPolicy(sub, roleID, domain)
|
||||
return rm.logPolicyResult("assign", added, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef)
|
||||
}
|
||||
|
||||
// Delete removes a role entirely and cleans up associated Casbin policies.
|
||||
func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.rdb.Delete(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
role := roleRef.Hex()
|
||||
|
||||
// Remove grouping policies
|
||||
if err := rm.removePolicies("grouping", role, roleRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove permission policies
|
||||
if err := rm.removePolicies("permission", role, roleRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// // Save changes
|
||||
// if err := rm.enforcer.enforcer.SavePolicy(); err != nil {
|
||||
// rm.logger.Warn("Failed to save Casbin policies after role deletion",
|
||||
// zap.Error(err),
|
||||
// mzap.ObjRef("role_ref", roleRef),
|
||||
// )
|
||||
// return err
|
||||
// }
|
||||
|
||||
rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke removes a role from a user.
|
||||
func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, orgRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef, accountRef, orgRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub := accountRef.Hex()
|
||||
role := roleRef.Hex()
|
||||
domain := orgRef.Hex()
|
||||
|
||||
removed, err := rm.enforcer.enforcer.RemoveGroupingPolicy(sub, role, domain)
|
||||
return rm.logPolicyResult("revoke", removed, err, roleRef, accountRef, orgRef)
|
||||
}
|
||||
|
||||
// logPolicyResult logs results for Assign and Revoke.
|
||||
func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, orgRef bson.ObjectID) error {
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
return err
|
||||
}
|
||||
msg := "Role " + action + "ed successfully"
|
||||
if !result {
|
||||
msg = "Role already " + action + "ed"
|
||||
}
|
||||
rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all roles in an organization or all roles if orgRef is zero.
|
||||
func (rm *RoleManager) List(ctx context.Context, orgRef bson.ObjectID) ([]model.RoleDescription, error) {
|
||||
domain := orgRef.Hex()
|
||||
groupingPolicies, err := rm.enforcer.enforcer.GetFilteredGroupingPolicy(2, domain)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(orgRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := rm.fetchRolesFromPolicies(groupingPolicies, orgRef)
|
||||
|
||||
rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(orgRef), zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package serializationimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// PolicySerializer implements CasbinSerializer for Permission.
|
||||
type PolicySerializer struct{}
|
||||
|
||||
// Serialize converts a Permission object into a Casbin policy.
|
||||
func (s *PolicySerializer) Serialize(entity *model.RolePolicy) ([]any, error) {
|
||||
if entity.RoleDescriptionRef.IsZero() ||
|
||||
entity.OrganizationRef.IsZero() ||
|
||||
entity.DescriptionRef.IsZero() || // Ensure permissionRef is valid
|
||||
entity.Effect.Action == "" || // Ensure action is not empty
|
||||
entity.Effect.Effect == "" { // Ensure effect (eft) is not empty
|
||||
return nil, merrors.InvalidArgument("permission contains invalid object references or missing fields")
|
||||
}
|
||||
|
||||
objectRef := anyobject.ID
|
||||
if entity.ObjectRef != nil {
|
||||
objectRef = entity.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
return []any{
|
||||
entity.RoleDescriptionRef.Hex(), // Maps to p.roleRef
|
||||
entity.OrganizationRef.Hex(), // Maps to p.organizationRef
|
||||
entity.DescriptionRef.Hex(), // Maps to p.permissionRef
|
||||
objectRef, // Maps to p.objectRef (wildcard if empty)
|
||||
string(entity.Effect.Action), // Maps to p.action
|
||||
string(entity.Effect.Effect), // Maps to p.eft
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deserialize converts a Casbin policy into a Permission object.
|
||||
func (s *PolicySerializer) Deserialize(policy []string) (*model.RolePolicy, error) {
|
||||
if len(policy) != 6 { // Ensure policy has the correct number of fields
|
||||
return nil, merrors.Internal("invalid policy format")
|
||||
}
|
||||
|
||||
roleRef, err := bson.ObjectIDFromHex(policy[0])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid roleRef in policy")
|
||||
}
|
||||
|
||||
organizationRef, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid organizationRef in policy")
|
||||
}
|
||||
|
||||
permissionRef, err := bson.ObjectIDFromHex(policy[2])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid permissionRef in policy")
|
||||
}
|
||||
|
||||
// Handle wildcard for ObjectRef
|
||||
var objectRef *bson.ObjectID
|
||||
if policy[3] != anyobject.ID {
|
||||
ref, err := bson.ObjectIDFromHex(policy[3])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid objectRef in policy")
|
||||
}
|
||||
objectRef = &ref
|
||||
}
|
||||
|
||||
return &model.RolePolicy{
|
||||
RoleDescriptionRef: roleRef,
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: permissionRef,
|
||||
ObjectRef: objectRef,
|
||||
Effect: model.ActionEffect{
|
||||
Action: model.Action(policy[4]),
|
||||
Effect: model.Effect(policy[5]),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package serializationimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// RoleSerializer implements CasbinSerializer for Role.
|
||||
type RoleSerializer struct{}
|
||||
|
||||
// Serialize converts a Role object into a Casbin grouping policy.
|
||||
func (s *RoleSerializer) Serialize(entity *model.Role) ([]any, error) {
|
||||
// Validate required fields
|
||||
if entity.AccountRef.IsZero() || entity.DescriptionRef.IsZero() || entity.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("role contains invalid object references")
|
||||
}
|
||||
|
||||
return []any{
|
||||
entity.AccountRef.Hex(), // Maps to g(_, _, _) accountRef
|
||||
entity.DescriptionRef.Hex(), // Maps to g(_, _, _) roleRef
|
||||
entity.OrganizationRef.Hex(), // Maps to g(_, _, _) organizationRef
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deserialize converts a Casbin grouping policy into a Role object.
|
||||
func (s *RoleSerializer) Deserialize(policy []string) (*model.Role, error) {
|
||||
// Ensure the policy has exactly 3 fields
|
||||
if len(policy) != 3 {
|
||||
return nil, merrors.Internal("invalid grouping policy format")
|
||||
}
|
||||
|
||||
// Parse accountRef
|
||||
accountRef, err := bson.ObjectIDFromHex(policy[0])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid accountRef in grouping policy")
|
||||
}
|
||||
|
||||
// Parse roleDescriptionRef (roleRef)
|
||||
roleDescriptionRef, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid roleRef in grouping policy")
|
||||
}
|
||||
|
||||
// Parse organizationRef
|
||||
organizationRef, err := bson.ObjectIDFromHex(policy[2])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid organizationRef in grouping policy")
|
||||
}
|
||||
|
||||
// Return the constructed Role object
|
||||
return &model.Role{
|
||||
AccountRef: accountRef,
|
||||
DescriptionRef: roleDescriptionRef,
|
||||
OrganizationRef: organizationRef,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type Policy = CasbinSerializer[model.RolePolicy]
|
||||
|
||||
func NewPolicySerializer() Policy {
|
||||
return &serializationimp.PolicySerializer{}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type Role = CasbinSerializer[model.Role]
|
||||
|
||||
func NewRoleSerializer() Role {
|
||||
return &serializationimp.RoleSerializer{}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package serialization
|
||||
|
||||
// CasbinSerializer defines methods for serializing and deserializing any Casbin-compatible entity.
|
||||
type CasbinSerializer[T any] interface {
|
||||
// Serialize converts an entity (Role or Permission) into a Casbin policy.
|
||||
Serialize(entity *T) ([]any, error)
|
||||
|
||||
// Deserialize converts a Casbin policy into an entity (Role or Permission).
|
||||
Deserialize(policy []string) (*T, error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PermissionsDBImp struct {
|
||||
template.DBImp[*nstructures.PolicyAssignment]
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("policy.organizationRef", object.GetOrganizationRef()),
|
||||
repository.Filter("policy.descriptionRef", object.GetPermissionRef()),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
repository.Query().Or(
|
||||
repository.Filter("policy.objectRef", *object.GetID()),
|
||||
repository.Filter("policy.objectRef", nil),
|
||||
),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("roleRef", roleRef),
|
||||
repository.Filter("policy.descriptionRef", permissionRef),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) Remove(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRefFilter := repository.Query().Or(
|
||||
repository.Filter("policy.objectRef", nil),
|
||||
repository.Filter("policy.objectRef", bson.NilObjectID),
|
||||
)
|
||||
if policy.ObjectRef != nil {
|
||||
objRefFilter = repository.Filter("policy.objectRef", *policy.ObjectRef)
|
||||
}
|
||||
return db.Repository.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("roleRef", policy.RoleDescriptionRef),
|
||||
repository.Filter("policy.organizationRef", policy.OrganizationRef),
|
||||
repository.Filter("policy.descriptionRef", policy.DescriptionRef),
|
||||
objRefFilter,
|
||||
repository.Filter("policy.effect.action", policy.Effect.Action),
|
||||
repository.Filter("policy.effect.effect", policy.Effect.Effect),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Filter("roleRef", roleRef),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
if len(roleRefs) == 0 {
|
||||
db.Logger.Debug("Empty role references list provided, returning empty resposnse")
|
||||
return []nstructures.PolicyAssignment{}, nil
|
||||
}
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Query().In(repository.Field("roleRef"), roleRefs),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func NewPoliciesDB(logger mlogger.Logger, db *mongo.Database) (*PermissionsDBImp, error) {
|
||||
p := &PermissionsDBImp{
|
||||
DBImp: *template.Create[*nstructures.PolicyAssignment](logger, mservice.PolicyAssignements, db),
|
||||
}
|
||||
|
||||
// faster
|
||||
// harder
|
||||
// index
|
||||
policiesQueryIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "policy.organizationRef", Sort: ri.Asc},
|
||||
{Field: "policy.descriptionRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
{Field: "policy.objectRef", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := p.Repository.CreateIndex(policiesQueryIndex); err != nil {
|
||||
p.Logger.Warn("Failed to prepare policies query index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleBasedQueriesIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "roleRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := p.Repository.CreateIndex(roleBasedQueriesIndex); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role based query index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniquePolicyConstaint := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "policy.organizationRef", Sort: ri.Asc},
|
||||
{Field: "roleRef", Sort: ri.Asc},
|
||||
{Field: "policy.descriptionRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
{Field: "policy.objectRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := p.Repository.CreateIndex(uniquePolicyConstaint); err != nil {
|
||||
p.Logger.Warn("Failed to unique policy assignment index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RolesDBImp struct {
|
||||
template.DBImp[*nstructures.RoleAssignment]
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.RoleAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.accountRef", accountRef),
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.RoleAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
return db.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.descriptionRef", roleRef),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error {
|
||||
return db.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.accountRef", accountRef),
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
repository.Filter("role.descriptionRef", roleRef),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func NewRolesDB(logger mlogger.Logger, db *mongo.Database) (*RolesDBImp, error) {
|
||||
p := &RolesDBImp{
|
||||
DBImp: *template.Create[*nstructures.RoleAssignment](logger, "role_assignments", db),
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "role.organizationRef", Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
p.Logger.Warn("Failed to prepare venue index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "role.descriptionRef", Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role description index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniqueRoleConstaint := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "role.organizationRef", Sort: ri.Asc},
|
||||
{Field: "role.accountRef", Sort: ri.Asc},
|
||||
{Field: "role.descriptionRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := p.Repository.CreateIndex(uniqueRoleConstaint); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role assignment index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type PoliciesDB interface {
|
||||
template.DB[*nstructures.PolicyAssignment]
|
||||
// plenty of interfaces for performance reasons
|
||||
Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
Remove(ctx context.Context, policy *model.RolePolicy) error
|
||||
}
|
||||
|
||||
func NewPoliciesDBDB(logger mlogger.Logger, conn *mongo.Database) (PoliciesDB, error) {
|
||||
return db.NewPoliciesDB(logger, conn)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type RolesDB interface {
|
||||
template.DB[*nstructures.RoleAssignment]
|
||||
Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error)
|
||||
RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error)
|
||||
RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error
|
||||
DeleteRole(ctx context.Context, roleRef bson.ObjectID) error
|
||||
}
|
||||
|
||||
func NewRolesDB(logger mlogger.Logger, conn *mongo.Database) (RolesDB, error) {
|
||||
return db.NewRolesDB(logger, conn)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Enforcer struct {
|
||||
logger mlogger.Logger
|
||||
pdb PoliciesDB
|
||||
rdb RolesDB
|
||||
}
|
||||
|
||||
func NewEnforcer(
|
||||
logger mlogger.Logger,
|
||||
db *mongo.Database,
|
||||
) (*Enforcer, error) {
|
||||
e := &Enforcer{logger: logger.Named("enforcer")}
|
||||
|
||||
var err error
|
||||
if e.pdb, err = NewPoliciesDBDB(e.logger, db); err != nil {
|
||||
e.logger.Warn("Failed to create permission assignments database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.rdb, err = NewRolesDB(e.logger, db); err != nil {
|
||||
e.logger.Warn("Failed to create role assignments database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Native enforcer created")
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Enforce checks if a user has the specified action permission on an object within a domain.
|
||||
func (n *Enforcer) Enforce(
|
||||
ctx context.Context,
|
||||
permissionRef, accountRef, organizationRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error) {
|
||||
if organizationRef.IsZero() {
|
||||
n.logger.Warn("Missing organization context", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object", objectRef), zap.String("action", string(action)))
|
||||
return false, merrors.InvalidArgument("organization context missing", "organizationRef")
|
||||
}
|
||||
roleAssignments, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles defined for account", mzap.AccRef(accountRef))
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch roles while checking permissions", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object", objectRef), zap.String("action", string(action)))
|
||||
return false, err
|
||||
}
|
||||
if len(roleAssignments) == 0 {
|
||||
n.logger.Warn("No roles found for account", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, merrors.Internal("No roles found for account " + accountRef.Hex())
|
||||
}
|
||||
allowFound := false // Track if any allow is found across roles
|
||||
|
||||
for _, roleAssignment := range roleAssignments {
|
||||
policies, err := n.pdb.PoliciesForPermissionAction(ctx, roleAssignment.DescriptionRef, permissionRef, action)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range policies {
|
||||
if !policyAppliesToObject(permission.ObjectRef, objectRef) {
|
||||
continue // Scoped to a different object; it says nothing about this one.
|
||||
}
|
||||
|
||||
if permission.Effect.Effect == model.EffectDeny {
|
||||
n.logger.Debug("Found denying policy", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, nil // Deny takes precedence immediately
|
||||
}
|
||||
|
||||
if permission.Effect.Effect == model.EffectAllow {
|
||||
n.logger.Debug("Allowing policy found", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
allowFound = true // At least one allow found
|
||||
} else {
|
||||
n.logger.Warn("Corrupted policy", mzap.StorableRef(&permission))
|
||||
return false, merrors.Internal("Corrupted action effect data for permissions entry " + permission.ID.Hex() + ": " + string(permission.Effect.Effect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final decision based on whether any allow was found
|
||||
if allowFound {
|
||||
return true, nil // At least one allow and no deny
|
||||
}
|
||||
|
||||
n.logger.Debug("No allowing policy found", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
|
||||
return false, nil // No allow found, default deny
|
||||
}
|
||||
|
||||
// EnforceBatch checks a user’s permission for multiple objects at once.
|
||||
// It returns a map from objectRef -> boolean indicating whether access is granted.
|
||||
func (n *Enforcer) EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error) {
|
||||
results := make(map[bson.ObjectID]bool, len(objectRefs))
|
||||
|
||||
// Group objectRefs by organizationRef.
|
||||
objectsByVenue := make(map[bson.ObjectID][]model.PermissionBoundStorable)
|
||||
for _, obj := range objectRefs {
|
||||
organizationRef := obj.GetOrganizationRef()
|
||||
objectsByVenue[organizationRef] = append(objectsByVenue[organizationRef], obj)
|
||||
}
|
||||
|
||||
// Process each venue group separately.
|
||||
for organizationRef, objs := range objectsByVenue {
|
||||
// 1. Fetch roles once for this account and venue.
|
||||
roles, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles defined for account", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
// With no roles, mark all objects in this venue as denied.
|
||||
for _, obj := range objs {
|
||||
results[*obj.GetID()] = false
|
||||
}
|
||||
// Continue to next venue
|
||||
continue
|
||||
}
|
||||
n.logger.Warn("Failed to fetch roles", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Extract role description references
|
||||
var roleRefs []bson.ObjectID
|
||||
for _, role := range roles {
|
||||
roleRefs = append(roleRefs, role.DescriptionRef)
|
||||
}
|
||||
|
||||
// 3. Fetch all policies for these roles and the given action in one call.
|
||||
allPolicies, err := n.pdb.PoliciesForRoles(ctx, roleRefs, action)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch policies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Build a lookup map keyed by PermissionRef.
|
||||
policyMap := make(map[bson.ObjectID][]nstructures.PolicyAssignment)
|
||||
for _, policy := range allPolicies {
|
||||
policyMap[policy.DescriptionRef] = append(policyMap[policy.DescriptionRef], policy)
|
||||
}
|
||||
|
||||
// 5. Evaluate permissions for each object in this venue group.
|
||||
for _, obj := range objs {
|
||||
permRef := obj.GetPermissionRef()
|
||||
allow := false
|
||||
if policies, ok := policyMap[permRef]; ok {
|
||||
for _, policy := range policies {
|
||||
if !policyAppliesToObject(policy.ObjectRef, *obj.GetID()) {
|
||||
continue // Scoped to a different object.
|
||||
}
|
||||
// Deny takes precedence.
|
||||
if policy.Effect.Effect == model.EffectDeny {
|
||||
allow = false
|
||||
break
|
||||
}
|
||||
if policy.Effect.Effect == model.EffectAllow {
|
||||
allow = true
|
||||
// Continue checking in case a deny exists among policies.
|
||||
} else {
|
||||
// should never get here
|
||||
return nil, merrors.Internal("Corrupted permissions effect in policy assignment '" + policy.GetID().Hex() + "': " + string(policy.Effect.Effect))
|
||||
}
|
||||
}
|
||||
}
|
||||
results[*obj.GetID()] = allow
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRoles retrieves all roles assigned to the user within the domain.
|
||||
func (n *Enforcer) GetRoles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, error) {
|
||||
n.logger.Debug("Fetching roles for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
ra, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles assigned to user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return []model.Role{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch roles", zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]model.Role, len(ra))
|
||||
for i, roleAssignement := range ra {
|
||||
roles[i] = roleAssignement.Role
|
||||
}
|
||||
|
||||
n.logger.Debug("Fetched roles", zap.Int("roles_count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (n *Enforcer) Reload() error {
|
||||
n.logger.Info("Policies reloaded") // do nothing actually
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPermissions retrieves all effective policies for the user within the domain.
|
||||
func (n *Enforcer) GetPermissions(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
n.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
|
||||
roles, err := n.GetRoles(ctx, accountRef, organizationRef)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to get roles", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uniquePermissions := make(map[bson.ObjectID]model.Permission)
|
||||
for _, role := range roles {
|
||||
perms, err := n.pdb.PoliciesForRole(ctx, role.DescriptionRef)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
continue
|
||||
}
|
||||
n.logger.Debug("Policies fetched for role", mzap.ObjRef("role_ref", role.DescriptionRef), zap.Int("count", len(perms)))
|
||||
for _, p := range perms {
|
||||
uniquePermissions[*p.GetID()] = model.Permission{
|
||||
RolePolicy: model.RolePolicy{
|
||||
Policy: p.Policy,
|
||||
RoleDescriptionRef: p.RoleRef,
|
||||
},
|
||||
AccountRef: accountRef,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionsSlice := make([]model.Permission, 0, len(uniquePermissions))
|
||||
for _, permission := range uniquePermissions {
|
||||
permissionsSlice = append(permissionsSlice, permission)
|
||||
}
|
||||
|
||||
n.logger.Debug("Policies fetched successfully", zap.Int("count", len(permissionsSlice)))
|
||||
return roles, permissionsSlice, nil
|
||||
}
|
||||
|
||||
// policyAppliesToObject reports whether a policy governs the object being
|
||||
// checked. A policy without an object reference — nil or the zero value — is a
|
||||
// wildcard covering every object of its resource type. A policy naming a
|
||||
// specific object says nothing about any other one, so it is skipped rather
|
||||
// than counted: otherwise an allow on one object would leak onto its siblings,
|
||||
// and a deny on one object would block them.
|
||||
func policyAppliesToObject(policyObjectRef *bson.ObjectID, objectRef bson.ObjectID) bool {
|
||||
if policyObjectRef == nil || policyObjectRef.IsZero() {
|
||||
return true
|
||||
}
|
||||
return *policyObjectRef == objectRef
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type MockPoliciesDB struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRef, permissionRef, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRef)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRefs, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, object, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Remove(ctx context.Context, policy *model.RolePolicy) error {
|
||||
args := m.Called(ctx, policy)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Template DB methods - implement as needed for testing
|
||||
func (m *MockPoliciesDB) Create(ctx context.Context, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, id, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Update(ctx context.Context, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
args := m.Called(ctx, objectRef, patch)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Delete(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]bson.ObjectID), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, query, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) List(ctx context.Context, query builder.Query) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Name() string {
|
||||
return "mock_policies"
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) InsertMany(ctx context.Context, objects []*nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, objects)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type MockRolesDB struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error {
|
||||
args := m.Called(ctx, roleRef, organizationRef, accountRef)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
args := m.Called(ctx, roleRef)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Template DB methods - implement as needed for testing
|
||||
func (m *MockRolesDB) Create(ctx context.Context, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, id, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Update(ctx context.Context, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
args := m.Called(ctx, objectRef, patch)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Delete(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]bson.ObjectID), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, query, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) List(ctx context.Context, query builder.Query) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Name() string {
|
||||
return "mock_roles"
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) InsertMany(ctx context.Context, objects []*nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, objects)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Test helper functions
|
||||
func createTestObjectID() bson.ObjectID {
|
||||
return bson.NewObjectID()
|
||||
}
|
||||
|
||||
func createTestRoleAssignment(roleRef, accountRef, organizationRef bson.ObjectID) nstructures.RoleAssignment {
|
||||
return nstructures.RoleAssignment{
|
||||
Role: model.Role{
|
||||
AccountRef: accountRef,
|
||||
DescriptionRef: roleRef,
|
||||
OrganizationRef: organizationRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestPolicyAssignment(roleRef bson.ObjectID, effect model.Effect, organizationRef, descriptionRef bson.ObjectID, objectRef *bson.ObjectID) nstructures.PolicyAssignment {
|
||||
return nstructures.PolicyAssignment{
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: descriptionRef,
|
||||
ObjectRef: objectRef,
|
||||
Effect: model.ActionEffect{
|
||||
Action: model.ActionRead,
|
||||
Effect: effect,
|
||||
},
|
||||
},
|
||||
RoleRef: roleRef,
|
||||
}
|
||||
}
|
||||
|
||||
func createTestEnforcer(pdb PoliciesDB, rdb RolesDB) *Enforcer {
|
||||
logger := factory.NewLogger(true)
|
||||
enforcer := &Enforcer{
|
||||
logger: logger.Named("test"),
|
||||
pdb: pdb,
|
||||
rdb: rdb,
|
||||
}
|
||||
return enforcer
|
||||
}
|
||||
|
||||
func TestEnforcer_Enforce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
objectRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("Allow_SingleRole_SinglePolicy", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with ALLOW effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
// Create enforcer
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Deny_SingleRole_SinglePolicy", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with DENY effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DenyTakesPrecedence_MultipleRoles", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
role1Ref := createTestObjectID()
|
||||
role2Ref := createTestObjectID()
|
||||
|
||||
// Mock multiple role assignments
|
||||
roleAssignment1 := createTestRoleAssignment(role1Ref, accountRef, organizationRef)
|
||||
roleAssignment2 := createTestRoleAssignment(role2Ref, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment1, roleAssignment2}, nil)
|
||||
|
||||
// First role has ALLOW policy
|
||||
allowPolicy := createTestPolicyAssignment(role1Ref, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, role1Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy}, nil)
|
||||
|
||||
// Second role has DENY policy - should take precedence
|
||||
denyPolicy := createTestPolicyAssignment(role2Ref, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, role2Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{denyPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - DENY should take precedence
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("NoRoles_ReturnsFalse", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("EmptyRoles_ReturnsError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock empty roles list (not NoData error)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Contains(t, err.Error(), "No roles found for account")
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DatabaseError_RolesDB", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error
|
||||
dbError := merrors.Define("database connection failed")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DatabaseError_PoliciesDB", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock database error in policies
|
||||
dbError := merrors.Define("policies database error")
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("NoPolicies_ReturnsFalse", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock no policies found
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("CorruptedPolicy_ReturnsError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock corrupted policy with invalid effect
|
||||
corruptedPolicy := createTestPolicyAssignment(roleRef, "invalid_effect", organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{corruptedPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Contains(t, err.Error(), "Corrupted action effect data")
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Mock implementation for PermissionBoundStorable
|
||||
type MockPermissionBoundStorable struct {
|
||||
id bson.ObjectID
|
||||
permissionRef bson.ObjectID
|
||||
organizationRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetID() *bson.ObjectID {
|
||||
return &m.id
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetPermissionRef() bson.ObjectID {
|
||||
return m.permissionRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetOrganizationRef() bson.ObjectID {
|
||||
return m.organizationRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) Collection() string {
|
||||
return "test_objects"
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetID(objID bson.ObjectID) {
|
||||
m.id = objID
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) Update() {
|
||||
// Do nothing for mock
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetPermissionRef(permissionRef bson.ObjectID) {
|
||||
m.permissionRef = permissionRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetOrganizationRef(organizationRef bson.ObjectID) {
|
||||
m.organizationRef = organizationRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) IsArchived() bool {
|
||||
return false // Default to not archived for testing
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetArchived(archived bool) {
|
||||
// No-op for testing
|
||||
}
|
||||
|
||||
func TestEnforcer_EnforceBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
// Create test objects
|
||||
object1 := &MockPermissionBoundStorable{
|
||||
id: createTestObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
object2 := &MockPermissionBoundStorable{
|
||||
id: createTestObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
|
||||
t.Run("BatchEnforce_MultipleObjects_SameVenue", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with ALLOW effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil)
|
||||
mockPDB.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.True(t, results[object1.id])
|
||||
assert.True(t, results[object2.id])
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("BatchEnforce_NoRoles_AllObjectsDenied", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.False(t, results[object1.id])
|
||||
assert.False(t, results[object2.id])
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("BatchEnforce_DatabaseError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error
|
||||
dbError := merrors.Define("database connection failed")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforcer_GetRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("GetRoles_Success", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 1)
|
||||
assert.Equal(t, roleRef, roles[0].DescriptionRef)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("GetRoles_NoRoles", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 0)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforcer_GetPermissions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("GetPermissions_Success", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, createTestObjectID(), nil)
|
||||
mockPDB.On("PoliciesForRole", ctx, roleRef).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, permissions, err := enforcer.GetPermissions(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 1)
|
||||
assert.Len(t, permissions, 1)
|
||||
assert.Equal(t, accountRef, permissions[0].AccountRef)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Security-focused test scenarios
|
||||
func TestEnforcer_SecurityScenarios(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
objectRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("Security_DenyAlwaysWins", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock multiple policies: both ALLOW and DENY
|
||||
allowPolicy := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
denyPolicy := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy, denyPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - DENY should always win
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Security_InvalidObjectID", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error for invalid ObjectID
|
||||
dbError := merrors.Define("invalid ObjectID")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute with invalid ObjectID
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - should fail securely
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Note: This test provides comprehensive coverage of the native enforcer including:
|
||||
// 1. Basic enforcement logic with deny-takes-precedence
|
||||
// 2. Batch operations for performance
|
||||
// 3. Role and permission retrieval
|
||||
// 4. Security scenarios and edge cases
|
||||
// 5. Error handling and database failures
|
||||
// 6. All critical security paths are tested
|
||||
@@ -0,0 +1,57 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NativeManager implements the auth.Manager interface by aggregating Role and Permission managers.
|
||||
type NativeManager struct {
|
||||
logger mlogger.Logger
|
||||
roleManager management.Role
|
||||
permManager management.Permission
|
||||
}
|
||||
|
||||
// NewManager creates a new CasbinManager with specified domains and role-domain mappings.
|
||||
func NewManager(
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
enforcer *Enforcer,
|
||||
) (*NativeManager, error) {
|
||||
logger := l.Named("manager")
|
||||
|
||||
var pdesc model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil {
|
||||
logger.Warn("Failed to fetch roles permission reference", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NativeManager{
|
||||
logger: logger,
|
||||
roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb),
|
||||
permManager: NewPermissionManager(logger, enforcer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Permission returns the Permission manager.
|
||||
func (m *NativeManager) Permission() management.Permission {
|
||||
return m.permManager
|
||||
}
|
||||
|
||||
// Role returns the Role manager.
|
||||
func (m *NativeManager) Role() management.Role {
|
||||
return m.roleManager
|
||||
}
|
||||
|
||||
// TransactionalGrants reports that native grants are plain database writes:
|
||||
// they join the caller's transaction context and roll back with it.
|
||||
func (m *NativeManager) TransactionalGrants() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package nstructures
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type PolicyAssignment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Policy `bson:"policy" json:"policy"`
|
||||
RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"`
|
||||
}
|
||||
|
||||
func (*PolicyAssignment) Collection() string {
|
||||
return "permission_assignments"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package nstructures
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type RoleAssignment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Role `bson:"role" json:"role"`
|
||||
}
|
||||
|
||||
func (*RoleAssignment) Collection() string {
|
||||
return "role_assignments"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestEnforcer_EnforceHonorsObjectScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
organizationRef := bson.NewObjectID()
|
||||
permissionRef := bson.NewObjectID()
|
||||
roleRef := bson.NewObjectID()
|
||||
objectA := bson.NewObjectID()
|
||||
objectB := bson.NewObjectID()
|
||||
wildcard := bson.NilObjectID
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policies []nstructures.PolicyAssignment
|
||||
objectRef bson.ObjectID
|
||||
wantAccess bool
|
||||
}{
|
||||
{
|
||||
name: "matching object allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "different object does not inherit allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: false,
|
||||
},
|
||||
{
|
||||
name: "nil object is wildcard",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "zero object is wildcard",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &wildcard),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated object deny does not override wildcard allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "matching object deny overrides wildcard allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard deny overrides matching object allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, nil),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
roles := &MockRolesDB{}
|
||||
policies := &MockPoliciesDB{}
|
||||
role := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil)
|
||||
policies.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return(test.policies, nil)
|
||||
|
||||
allowed, err := createTestEnforcer(policies, roles).Enforce(
|
||||
ctx,
|
||||
permissionRef,
|
||||
accountRef,
|
||||
organizationRef,
|
||||
test.objectRef,
|
||||
model.ActionRead,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.wantAccess, allowed)
|
||||
roles.AssertExpectations(t)
|
||||
policies.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforcer_EnforceBatchHonorsObjectScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
organizationRef := bson.NewObjectID()
|
||||
permissionRef := bson.NewObjectID()
|
||||
roleRef := bson.NewObjectID()
|
||||
objectA := &MockPermissionBoundStorable{
|
||||
id: bson.NewObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
objectB := &MockPermissionBoundStorable{
|
||||
id: bson.NewObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policies []nstructures.PolicyAssignment
|
||||
want map[bson.ObjectID]bool
|
||||
}{
|
||||
{
|
||||
name: "specific allow applies only to matching object",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA.id),
|
||||
},
|
||||
want: map[bson.ObjectID]bool{objectA.id: true, objectB.id: false},
|
||||
},
|
||||
{
|
||||
name: "specific deny affects only matching object",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA.id),
|
||||
},
|
||||
want: map[bson.ObjectID]bool{objectA.id: false, objectB.id: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
roles := &MockRolesDB{}
|
||||
policies := &MockPoliciesDB{}
|
||||
role := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil)
|
||||
policies.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return(test.policies, nil)
|
||||
|
||||
result, err := createTestEnforcer(policies, roles).EnforceBatch(
|
||||
ctx,
|
||||
[]model.PermissionBoundStorable{objectA, objectB},
|
||||
accountRef,
|
||||
model.ActionRead,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, result)
|
||||
roles.AssertExpectations(t)
|
||||
policies.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PermissionManager manages permissions using Casbin.
|
||||
type PermissionManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *Enforcer
|
||||
}
|
||||
|
||||
// GrantToRole adds a permission to a role in Casbin.
|
||||
func (m *PermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := "any"
|
||||
if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
m.logger.Debug("Granting permission to role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
assignment := nstructures.PolicyAssignment{
|
||||
Policy: policy.Policy,
|
||||
RoleRef: policy.RoleDescriptionRef,
|
||||
}
|
||||
if err := m.enforcer.pdb.Create(ctx, &assignment); err != nil {
|
||||
m.logger.Warn("Failed to grant policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeFromRole removes a permission from a role in Casbin.
|
||||
func (m *PermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := "*"
|
||||
if policy.ObjectRef != nil {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
m.logger.Debug("Revoking permission from role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
if err := m.enforcer.pdb.Remove(ctx, policy); err != nil {
|
||||
m.logger.Warn("Failed to revoke policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicies retrieves all policies for a specific role.
|
||||
func (m *PermissionManager) GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error) {
|
||||
m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef))
|
||||
|
||||
assinments, err := m.enforcer.pdb.PoliciesForRole(ctx, roleRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
m.logger.Debug("No policies found", mzap.ObjRef("role_ref", roleRef))
|
||||
return []model.RolePolicy{}, nil
|
||||
}
|
||||
policies := make([]model.RolePolicy, len(assinments))
|
||||
for i, assinment := range assinments {
|
||||
policies[i] = model.RolePolicy{
|
||||
Policy: assinment.Policy,
|
||||
RoleDescriptionRef: assinment.RoleRef,
|
||||
}
|
||||
}
|
||||
m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(policies)))
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// Save persists changes to the Casbin policy store.
|
||||
func (m *PermissionManager) Save() error {
|
||||
m.logger.Info("Policies successfully saved") // do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPermissionManager(logger mlogger.Logger, enforcer *Enforcer) *PermissionManager {
|
||||
return &PermissionManager{
|
||||
logger: logger.Named("permission"),
|
||||
enforcer: enforcer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RoleManager manages roles using Casbin.
|
||||
type RoleManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *Enforcer
|
||||
rdb role.DB
|
||||
rolePermissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
// NewRoleManager creates a new RoleManager.
|
||||
func NewRoleManager(logger mlogger.Logger, enforcer *Enforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager {
|
||||
return &RoleManager{
|
||||
logger: logger.Named("role"),
|
||||
enforcer: enforcer,
|
||||
rdb: rdb,
|
||||
rolePermissionRef: rolePermissionRef,
|
||||
}
|
||||
}
|
||||
|
||||
// validateObjectIDs ensures that all provided ObjectIDs are non-zero.
|
||||
func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error {
|
||||
for _, id := range ids {
|
||||
if id.IsZero() {
|
||||
return merrors.InvalidArgument("Object references cannot be zero", "objectRef")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRolesFromPolicies retrieves and converts policies to roles.
|
||||
func (rm *RoleManager) fetchRolesFromPolicies(roles []nstructures.RoleAssignment, organizationRef bson.ObjectID) []model.RoleDescription {
|
||||
result := make([]model.RoleDescription, len(roles))
|
||||
for i, role := range roles {
|
||||
result[i] = model.RoleDescription{
|
||||
Base: storable.Base{ID: *role.GetID()},
|
||||
OrganizationRef: organizationRef,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Create creates a new role in an organization.
|
||||
func (rm *RoleManager) Create(ctx context.Context, organizationRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) {
|
||||
if err := rm.validateObjectIDs(organizationRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := &model.RoleDescription{
|
||||
OrganizationRef: organizationRef,
|
||||
Describable: *description,
|
||||
}
|
||||
if err := rm.rdb.Create(ctx, role); err != nil {
|
||||
rm.logger.Warn("Failed to create role", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(organizationRef))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Assign assigns a role to a user in the given organization.
|
||||
func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error {
|
||||
if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
assogment := nstructures.RoleAssignment{Role: *role}
|
||||
err := rm.enforcer.rdb.Create(ctx, &assogment)
|
||||
return rm.logPolicyResult("assign", err == nil, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef)
|
||||
}
|
||||
|
||||
// Delete removes a role entirely and cleans up associated Casbin policies.
|
||||
func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.rdb.Delete(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.enforcer.rdb.DeleteRole(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to remove role", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke removes a role from a user.
|
||||
func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, organizationRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef, accountRef, organizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := rm.enforcer.rdb.RemoveRole(ctx, roleRef, organizationRef, accountRef)
|
||||
return rm.logPolicyResult("revoke", err == nil, err, roleRef, accountRef, organizationRef)
|
||||
}
|
||||
|
||||
// logPolicyResult logs results for Assign and Revoke.
|
||||
func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, organizationRef bson.ObjectID) error {
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
msg := "Role " + action + "ed successfully"
|
||||
if !result {
|
||||
msg = "Role already " + action + "ed"
|
||||
}
|
||||
rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all roles in an organization or all roles if organizationRef is zero.
|
||||
func (rm *RoleManager) List(ctx context.Context, organizationRef bson.ObjectID) ([]model.RoleDescription, error) {
|
||||
roles4Venues, err := rm.enforcer.rdb.RolesForVenue(ctx, organizationRef)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := rm.fetchRolesFromPolicies(roles4Venues, organizationRef)
|
||||
rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(organizationRef), zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Permission interface {
|
||||
// Grant a permission to a role with an optional object scope and specified effect.
|
||||
// Use bson.NilObjectID for 'any' objectRef.
|
||||
GrantToRole(ctx context.Context, policy *model.RolePolicy) error
|
||||
|
||||
// Revoke a permission from a role with an optional object scope and specified effect.
|
||||
// Use bson.NilObjectID for 'any' objectRef.
|
||||
RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error
|
||||
|
||||
// Retrieve all policies assigned to a specific role, including scope and effects.
|
||||
GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error)
|
||||
|
||||
// Persist any changes made to permissions.
|
||||
Save() error
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Role interface {
|
||||
// Create a new role in an organization (returns the created Role with its ID).
|
||||
Create(
|
||||
ctx context.Context,
|
||||
orgRef bson.ObjectID,
|
||||
description *model.Describable,
|
||||
) (*model.RoleDescription, error)
|
||||
|
||||
// Delete a role entirely. This will cascade and remove all associated
|
||||
Delete(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// Assign a role to a user in a specific organization.
|
||||
Assign(
|
||||
ctx context.Context,
|
||||
role *model.Role,
|
||||
) error
|
||||
|
||||
// Revoke a role from a user in a specific organization.
|
||||
Revoke(
|
||||
ctx context.Context,
|
||||
roleRef, accountRef, orgRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// List all roles in an organization or globally if orgRef is bson.NilObjectID.
|
||||
List(
|
||||
ctx context.Context,
|
||||
orgRef bson.ObjectID,
|
||||
) ([]model.RoleDescription, error)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
)
|
||||
|
||||
// Manager provides access to domain-aware Permission and Role managers.
|
||||
type Manager interface {
|
||||
// Permission returns a manager that handles permission grants/revokes
|
||||
// for a specific resource type. (You might add domainRef here if desired.)
|
||||
Permission() management.Permission
|
||||
|
||||
// Role returns the domain-aware Role manager.
|
||||
Role() management.Role
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// denyImmutablePatch returns ErrInvalidArg if patch touches any field whose
|
||||
// mutation would silently undermine future access decisions.
|
||||
func denyImmutablePatch(patch builder.Patch) error {
|
||||
if patch == nil {
|
||||
return nil
|
||||
}
|
||||
for _, op := range patch.Build() {
|
||||
fields, ok := op.Value.(bson.D)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range fields {
|
||||
if f.Key == "owner" || strings.HasPrefix(f.Key, "owner.") ||
|
||||
f.Key == "organizationRef" || f.Key == "permissionRef" {
|
||||
return merrors.InvalidArgumentf("patch field %q is immutable", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Enforcer() Enforcer
|
||||
Manager() Manager
|
||||
GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ScopedAccessParams bundles everything needed to authorize an action against a
|
||||
// resource that follows the personal-vs-org ownership model.
|
||||
//
|
||||
// It is deliberately a plain struct (not an interface over a model) so it works
|
||||
// both for locally-stored objects and for objects whose ownership is learned
|
||||
// from a downstream service response.
|
||||
type ScopedAccessParams struct {
|
||||
// Service is the resource type, used only to build the access-denied error.
|
||||
Service mservice.Type
|
||||
// PolicyRef is the org policy consulted when the object is org-owned.
|
||||
PolicyRef bson.ObjectID
|
||||
// CallerRef is the subject taken from the auth token. Never from the request body.
|
||||
CallerRef bson.ObjectID
|
||||
// OrgRef is the organization the object belongs to. For org-owned objects
|
||||
// caller membership in it is checked before policy enforcement. Personal
|
||||
// objects are deliberately exempt from the membership check so the owner
|
||||
// keeps access after leaving the organization; membership for personal
|
||||
// objects is enforced once, at create time (see ScopedDBImp.Create).
|
||||
OrgRef bson.ObjectID
|
||||
// ObjectRef is the concrete object being accessed. Passing it enables
|
||||
// object-level checks in the Enforcer; use bson.NilObjectID for scope-only.
|
||||
ObjectRef bson.ObjectID
|
||||
// OwnerKind / OwnerRef describe the object's actual ownership, read FROM the
|
||||
// object — not derived from the request.
|
||||
OwnerKind model.OwnerKind
|
||||
OwnerRef bson.ObjectID
|
||||
// Action is the operation being authorized.
|
||||
Action model.Action
|
||||
}
|
||||
|
||||
// enforceOwnershipAccess decides access based on ownership and policy only.
|
||||
// It does NOT check organization membership — the caller must verify membership
|
||||
// before invoking this. Use ScopedAuthorizer.Authorize for the full
|
||||
// membership-aware access check.
|
||||
//
|
||||
// - personal (OwnerKindAccount): allowed iff OwnerRef == CallerRef.
|
||||
// - org (OwnerKindOrganization): allowed iff the Enforcer grants Action within OrgRef.
|
||||
// - anything else: denied by default.
|
||||
func enforceOwnershipAccess(ctx context.Context, enf Enforcer, p ScopedAccessParams) error {
|
||||
switch p.OwnerKind {
|
||||
case model.OwnerKindAccount:
|
||||
if p.OwnerRef == p.CallerRef {
|
||||
return nil
|
||||
}
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
|
||||
case model.OwnerKindOrganization:
|
||||
ok, err := enf.Enforce(ctx, p.PolicyRef, p.CallerRef, p.OrgRef, p.ObjectRef, p.Action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeEnforcer records the last call and returns canned results, so the scoped
|
||||
// access rules can be exercised without a real policy backend.
|
||||
type fakeEnforcer struct {
|
||||
allow bool
|
||||
err error
|
||||
called bool
|
||||
|
||||
gotPolicyRef bson.ObjectID
|
||||
gotAccount bson.ObjectID
|
||||
gotOrg bson.ObjectID
|
||||
gotObject bson.ObjectID
|
||||
gotAction model.Action
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) Enforce(_ context.Context, permissionRef, accountRef, orgRef, objectRef bson.ObjectID, action model.Action) (bool, error) {
|
||||
f.called = true
|
||||
f.gotPolicyRef, f.gotAccount, f.gotOrg, f.gotObject, f.gotAction = permissionRef, accountRef, orgRef, objectRef, action
|
||||
return f.allow, f.err
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
|
||||
return map[bson.ObjectID]bool{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func TestEnforceOwnershipAccess(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
org := bson.NewObjectID()
|
||||
object := bson.NewObjectID()
|
||||
policy := bson.NewObjectID()
|
||||
|
||||
base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams {
|
||||
return ScopedAccessParams{
|
||||
Service: mservice.Recipients,
|
||||
PolicyRef: policy,
|
||||
CallerRef: caller,
|
||||
OrgRef: org,
|
||||
ObjectRef: object,
|
||||
OwnerKind: kind,
|
||||
OwnerRef: owner,
|
||||
Action: model.ActionRead,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("personal owner is allowed without policy", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{}
|
||||
if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("personal non-owner is denied without policy", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, other))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org owner consults policy and passes object ref", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
if enf.gotObject != object || enf.gotOrg != org || enf.gotAccount != caller || enf.gotPolicyRef != policy {
|
||||
t.Fatalf("enforcer received unexpected args: %+v", enf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org owner denied when policy refuses", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org policy error is propagated, not masked as denial", func(t *testing.T) {
|
||||
boom := merrors.Define("backend down")
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("internal error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown owner kind is denied by default", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKind("garbage"), caller))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("unknown kind must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ownerLoader is the minimal DB contract that ScopedDB needs: fetch a resource
|
||||
// by ID and populate the caller-supplied result. *template.DBImp[T] satisfies
|
||||
// this interface without any adapter code.
|
||||
type ownerLoader[T any] interface {
|
||||
Get(ctx context.Context, objectRef bson.ObjectID, result T) error
|
||||
}
|
||||
|
||||
// ScopedDB[T] provides loadAndEnforce semantics for resources that implement
|
||||
// model.OwnerBound. It loads the resource from the database (so ownership is
|
||||
// always read from the authoritative source, never from request fields) and
|
||||
// then delegates access control to ScopedAuthorizer, which checks membership
|
||||
// before ownership/policy enforcement.
|
||||
//
|
||||
// Use ProtectedDB for resources that carry no ownerKind/ownerRef and rely
|
||||
// solely on org-level policy checks.
|
||||
type ScopedDB[T model.OwnerBound] struct {
|
||||
// DB is the raw repository accessor. *template.DBImp[T] satisfies this.
|
||||
DB ownerLoader[T]
|
||||
// Logger receives Debug/Warn messages from the enforcement path.
|
||||
Logger mlogger.Logger
|
||||
// Authorizer performs the full membership-aware access check.
|
||||
Authorizer *ScopedAuthorizer
|
||||
// OrgPolicyRef is the built-in policy checked when ownerKind==organization.
|
||||
OrgPolicyRef bson.ObjectID
|
||||
// Collection identifies the resource type in access-denied errors and logs.
|
||||
Collection mservice.Type
|
||||
// New allocates a zero-value T ready to be filled by DB.Get.
|
||||
New func() T
|
||||
}
|
||||
|
||||
// NewScopedDB constructs a ScopedDB and validates required dependencies.
|
||||
// Logger defaults to zap.NewNop() if nil.
|
||||
func NewScopedDB[T model.OwnerBound](
|
||||
db ownerLoader[T],
|
||||
authorizer *ScopedAuthorizer,
|
||||
orgPolicyRef bson.ObjectID,
|
||||
collection mservice.Type,
|
||||
newFn func() T,
|
||||
logger mlogger.Logger,
|
||||
) *ScopedDB[T] {
|
||||
if db == nil {
|
||||
panic("auth.NewScopedDB: DB must not be nil")
|
||||
}
|
||||
if authorizer == nil {
|
||||
panic("auth.NewScopedDB: Authorizer must not be nil")
|
||||
}
|
||||
if newFn == nil {
|
||||
panic("auth.NewScopedDB: New must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedDB[T]{
|
||||
DB: db,
|
||||
Logger: logger,
|
||||
Authorizer: authorizer,
|
||||
OrgPolicyRef: orgPolicyRef,
|
||||
Collection: collection,
|
||||
New: newFn,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAndEnforce fetches the resource identified by objectRef, reads the
|
||||
// ownerKind and ownerRef fields from the database record, and enforces access
|
||||
// via ScopedAuthorizer (membership check → ownership/policy check). It never
|
||||
// trusts ownership data from the caller.
|
||||
//
|
||||
// Logging contract:
|
||||
// - Debug on access-check start and after ownership is loaded from DB.
|
||||
// - Debug when access is denied (ErrAccessDenied) — intentional, not an error.
|
||||
// - Warn when the Enforcer itself returns a non-nil error (unexpected failure).
|
||||
// - No log on success.
|
||||
func (s *ScopedDB[T]) LoadAndEnforce(ctx context.Context, callerRef, objectRef bson.ObjectID, action model.Action) (T, error) {
|
||||
var zero T
|
||||
|
||||
s.Logger.Debug("access check started",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("collection", s.Collection), zap.String("action", string(action)))
|
||||
|
||||
obj := s.New()
|
||||
if err := s.DB.Get(ctx, objectRef, obj); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
s.Logger.Debug("ownership loaded from db",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("ownerKind", string(obj.GetOwnerKind())))
|
||||
|
||||
err := s.Authorizer.Authorize(ctx, ScopedAccessParams{
|
||||
Service: s.Collection,
|
||||
PolicyRef: s.OrgPolicyRef,
|
||||
CallerRef: callerRef,
|
||||
OrgRef: obj.GetOrganizationRef(),
|
||||
ObjectRef: objectRef,
|
||||
OwnerKind: obj.GetOwnerKind(),
|
||||
OwnerRef: obj.GetOwnerRef(),
|
||||
Action: action,
|
||||
})
|
||||
if err == nil {
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
s.Logger.Debug("access denied",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("ownerKind", string(obj.GetOwnerKind())),
|
||||
zap.String("action", string(action)))
|
||||
} else {
|
||||
s.Logger.Warn("enforcer returned error during scoped access check",
|
||||
zap.Error(err), mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("action", string(action)))
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// testOwned is a minimal model.OwnerBound used exclusively by ScopedDB tests.
|
||||
type testOwned struct {
|
||||
id bson.ObjectID
|
||||
orgRef bson.ObjectID
|
||||
kind model.OwnerKind
|
||||
ownerRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (o *testOwned) GetID() *bson.ObjectID { return &o.id }
|
||||
func (o *testOwned) SetID(id bson.ObjectID) { o.id = id }
|
||||
func (o *testOwned) Update() {}
|
||||
func (o *testOwned) Collection() string { return "test_owned" }
|
||||
func (o *testOwned) GetOrganizationRef() bson.ObjectID { return o.orgRef }
|
||||
func (o *testOwned) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref }
|
||||
func (o *testOwned) GetOwnerKind() model.OwnerKind { return o.kind }
|
||||
func (o *testOwned) GetOwnerRef() bson.ObjectID { return o.ownerRef }
|
||||
|
||||
var _ model.OwnerBound = (*testOwned)(nil)
|
||||
|
||||
// fakeOwnerLoader returns the pre-built object or the configured error.
|
||||
type fakeOwnerLoader struct {
|
||||
obj *testOwned
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeOwnerLoader) Get(_ context.Context, _ bson.ObjectID, result *testOwned) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
*result = *f.obj
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeMembership controls whether IsMember returns true/false/error.
|
||||
type fakeMembership struct {
|
||||
member bool
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeMembership) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
|
||||
f.called = true
|
||||
return f.member, f.err
|
||||
}
|
||||
|
||||
func newTestScopedDB(loader *fakeOwnerLoader, membership *fakeMembership, enf Enforcer) *ScopedDB[*testOwned] {
|
||||
authorizer := &ScopedAuthorizer{
|
||||
Logger: zap.NewNop(),
|
||||
Membership: membership,
|
||||
Enforcer: enf,
|
||||
}
|
||||
return &ScopedDB[*testOwned]{
|
||||
DB: loader,
|
||||
Logger: zap.NewNop(),
|
||||
Authorizer: authorizer,
|
||||
OrgPolicyRef: bson.NewObjectID(),
|
||||
Collection: mservice.Type("test_owned"),
|
||||
New: func() *testOwned { return &testOwned{} },
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_PersonalOwner_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: caller, // owner == caller
|
||||
}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_PersonalOwner_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: other, // owner != caller
|
||||
}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_EnforcerError_Propagated(t *testing.T) {
|
||||
boom := merrors.Define("enforcer backend down")
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error propagated, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("enforcer internal error must not be masked as access denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_DBError_Propagated(t *testing.T) {
|
||||
dbErr := merrors.Define("mongo unavailable")
|
||||
db := newTestScopedDB(&fakeOwnerLoader{err: dbErr}, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), bson.NewObjectID(), bson.NewObjectID(), model.ActionRead)
|
||||
if !errors.Is(err, dbErr) {
|
||||
t.Fatalf("expected DB error propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedDB_PersonalOwner_NonMember_Denied is the main regression case:
|
||||
// even when ownerRef == callerRef, a non-member of the organization must be
|
||||
// denied before ownership is checked, so the tenant boundary is enforced.
|
||||
// TestScopedDB_PersonalOwner_NonMember_Allowed: personal resource, caller is the
|
||||
// owner but is NOT a member of the org. Membership is not consulted for
|
||||
// account-owned resources — ownership alone is sufficient.
|
||||
func TestScopedDB_PersonalOwner_NonMember_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: caller, // owner == caller, membership is irrelevant
|
||||
}
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("enforcer must not be called for account-owned resource")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedDB_OrgOwner_NonMember_Denied: org-owned object, caller is not a
|
||||
// member — must be denied before the policy enforcer is consulted.
|
||||
func TestScopedDB_OrgOwner_NonMember_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true} // would allow if reached
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member org-owned object, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("enforcer must not be called when membership check fails")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user