Files
sxchnge/backend/pkg/auth/dbimpscopedcrud.go
T

263 lines
10 KiB
Go

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
}