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 }