From 2c66ad0bee1c34090976d3ff57bdbed8bd8e5de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Thu, 29 Jan 2026 17:02:16 +0100 Subject: [PATCH 01/20] Rework authorizations: users and campaigns --- src/bemserver_core/authorization.py | 127 ++++++++++++++++++++++++++ src/bemserver_core/model/campaigns.py | 96 ++++++++++++++++--- src/bemserver_core/model/users.py | 66 ++++++++++--- 3 files changed, 261 insertions(+), 28 deletions(-) diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index f46d59f0..82d12de6 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -1,6 +1,8 @@ """Authorization""" import functools +import typing +import warnings from contextvars import ContextVar from pathlib import Path @@ -11,6 +13,9 @@ from bemserver_core.exceptions import BEMServerAuthorizationError from bemserver_core.utils import make_context_var_manager +if typing.TYPE_CHECKING: + from bemserver_core.model import User + CURRENT_USER = ContextVar("current_user", default=None) OPEN_BAR = ContextVar("open_bar", default=False) @@ -113,3 +118,125 @@ def update(self, **kwargs): def delete(self): auth.authorize(get_current_user(), "delete", self) super().delete() + + +class AuthUndefinedActionError(Exception): + """""" + + +class AuthorizationsManager: + def __init__(self) -> None: + self._rules: dict = {} + + def add_rule(self, action: str) -> typing.Callable: + def decorator(func: typing.Callable): + if action in self._rules: + warnings.warn( + f"Redefining authorization rule for {action}", + RuntimeWarning, + stacklevel=1, + ) + self._rules[action] = func + return func + + return decorator + + def eval_rule(self, action: str, actor: "User", item: any): + try: + rule = self._rules[action] + except KeyError as exc: + raise AuthUndefinedActionError(f"Undefined action: {action}") from exc + return rule(actor, item) + + def authorize(self, action: str, item: any) -> bool: + actor = get_current_user() + db.session().enable_relationship_loading(item) + if not ( + OPEN_BAR.get() or actor.is_admin or self.eval_rule(action, actor, item) + ): + raise BEMServerAuthorizationError + + def authorize_query(self, model_cls, query): + actor = get_current_user() + if not (OPEN_BAR.get() or actor.is_admin): + query = model_cls.authorize_query(actor, query) + return query + + +auth_mgr: AuthorizationsManager = AuthorizationsManager() + + +class AuthMgrMixin: + # TODO: remove + @classmethod + def register_class(cls, *args, **kwargs): + auth.register_class(cls, *args, **kwargs) + + @classmethod + def _query(cls, **kwargs): + query = db.session.query(cls) + query = auth_mgr.authorize_query(cls, query) + for key, val in kwargs.items(): + query = query.filter(getattr(cls, key) == val) + return query + + @classmethod + def authorize_query(cls, actor: "User", query): + """Override in model class to add custom rules""" + return query + + def authorize_create(self, actor): + return False + + def authorize_read(self, actor): + return False + + def authorize_update(self, actor): + return False + + def authorize_delete(self, actor): + return False + + @classmethod + def new(cls, **kwargs): + # Override Base.new to avoid adding to the session if auth failed + item = cls(**kwargs) + auth_mgr.authorize("create", item) + db.session.add(item) + return item + + @classmethod + def get_by_id(cls, item_id, **kwargs): + item = super().get_by_id(item_id) + if item is None: + return None + auth_mgr.authorize("read", item) + return item + + def update(self, **kwargs): + auth_mgr.authorize("update", self) + super().update(**kwargs) + + def delete(self): + auth_mgr.authorize("delete", self) + super().delete() + + +@auth_mgr.add_rule("create") +def authorize_create(actor, item): + return item.authorize_create(actor) + + +@auth_mgr.add_rule("read") +def authorize_read(actor, item): + return item.authorize_read(actor) + + +@auth_mgr.add_rule("update") +def authorize_update(actor, item): + return item.authorize_update(actor) + + +@auth_mgr.add_rule("delete") +def authorize_delete(actor, item): + return item.authorize_delete(actor) diff --git a/src/bemserver_core/model/campaigns.py b/src/bemserver_core/model/campaigns.py index 95421d25..263c3ac6 100644 --- a/src/bemserver_core/model/campaigns.py +++ b/src/bemserver_core/model/campaigns.py @@ -2,11 +2,13 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth -from bemserver_core.database import Base, make_columns_read_only +from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.database import Base, db, make_columns_read_only +from .users import UserByUserGroup, UserGroup -class Campaign(AuthMixin, Base): + +class Campaign(AuthMgrMixin, Base): __tablename__ = "campaigns" id = sqla.Column(sqla.Integer, primary_key=True) @@ -18,8 +20,7 @@ class Campaign(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user_groups_by_campaigns": Relation( kind="many", @@ -30,8 +31,27 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(UserGroupByCampaign.campaign_id == self.id) + .exists() + ).scalar() + -class CampaignScope(AuthMixin, Base): +class CampaignScope(AuthMgrMixin, Base): __tablename__ = "c_scopes" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -47,8 +67,7 @@ class CampaignScope(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user_groups_by_campaign_scopes": Relation( kind="many", @@ -59,8 +78,27 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(UserGroupByCampaignScope) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(UserGroupByCampaignScope) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(UserGroupByCampaignScope.campaign_scope_id == self.id) + .exists() + ).scalar() + -class UserGroupByCampaign(AuthMixin, Base): +class UserGroupByCampaign(AuthMgrMixin, Base): """UserGroup x Campaign associations""" __tablename__ = "u_groups_by_campaigns" @@ -85,8 +123,7 @@ class UserGroupByCampaign(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user_group": Relation( kind="one", @@ -97,8 +134,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(UserByUserGroup.user_group_id == self.user_group_id) + .exists() + ).scalar() + -class UserGroupByCampaignScope(AuthMixin, Base): +class UserGroupByCampaignScope(AuthMgrMixin, Base): """UserGroup x CampaignScope associations""" __tablename__ = "u_groups_by_c_scopes" @@ -123,8 +176,7 @@ class UserGroupByCampaignScope(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user_group": Relation( kind="one", @@ -135,6 +187,22 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(UserByUserGroup.user_group_id == self.user_group_id) + .exists() + ).scalar() + def init_db_campaigns_triggers(): """Create triggers to protect some columns from update. diff --git a/src/bemserver_core/model/users.py b/src/bemserver_core/model/users.py index 5c8b2c1b..2a43df53 100644 --- a/src/bemserver_core/model/users.py +++ b/src/bemserver_core/model/users.py @@ -5,13 +5,13 @@ import argon2 -from bemserver_core.authorization import AuthMixin, Relation, auth, get_current_user -from bemserver_core.database import Base +from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr +from bemserver_core.database import Base, db ph = argon2.PasswordHasher() -class User(AuthMixin, Base): +class User(AuthMgrMixin, Base): __tablename__ = "users" id = sqla.Column(sqla.Integer, primary_key=True) @@ -23,8 +23,7 @@ class User(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "users_by_user_groups": Relation( kind="many", @@ -47,7 +46,7 @@ def is_admin(self): @is_admin.setter def is_admin(self, is_admin): - auth.authorize(get_current_user(), "set_admin", self) + auth_mgr.authorize("set_admin", self) self._is_admin = is_admin @hybrid_property @@ -56,11 +55,11 @@ def is_active(self): @is_active.setter def is_active(self, is_active): - auth.authorize(get_current_user(), "set_active", self) + auth_mgr.authorize("set_active", self) self._is_active = is_active def set_password(self, password: str) -> None: - auth.authorize(get_current_user(), "update", self) + auth_mgr.authorize("update", self) self.password = ph.hash(password) def check_password(self, password: str) -> bool: @@ -74,8 +73,28 @@ def check_password(self, password: str) -> bool: self.password = ph.hash(password) return True + @classmethod + def authorize_query(cls, actor, query): + return query.filter(cls.id == actor.id) + + def authorize_read(self, actor): + return actor.id == self.id + + def authorize_update(self, actor): + return actor.id == self.id + + +@auth_mgr.add_rule("set_admin") +def authorize_set_admin(actor: User, user: User) -> bool: + return False + + +@auth_mgr.add_rule("set_active") +def authorize_set_active(actor: User, user: User) -> bool: + return False + -class UserGroup(AuthMixin, Base): +class UserGroup(AuthMgrMixin, Base): __tablename__ = "u_groups" id = sqla.Column(sqla.Integer, primary_key=True) @@ -83,8 +102,7 @@ class UserGroup(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user_groups_by_campaigns": Relation( kind="many", @@ -101,8 +119,22 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return query.join(UserByUserGroup).filter(UserByUserGroup.user_id == actor.id) + + def authorize_create(self, actor): + return False + + def authorize_read(self, actor): + return db.session.query( + db.session.query(UserByUserGroup) + .filter_by(user_id=actor.id, user_group_id=self.id) + .exists() + ).scalar() + -class UserByUserGroup(AuthMixin, Base): +class UserByUserGroup(AuthMgrMixin, Base): """UserGroup x User associations""" __tablename__ = "users_by_u_groups" @@ -123,8 +155,7 @@ class UserByUserGroup(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user": Relation( kind="one", @@ -140,3 +171,10 @@ def register_class(cls): ), }, ) + + @classmethod + def authorize_query(cls, actor, query): + return query.filter(UserByUserGroup.user_id == actor.id) + + def authorize_read(self, actor): + return actor.id == self.user_id From f01b4011f705a4e271beae8ea6ee7196bdde21fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Fri, 30 Jan 2026 16:48:53 +0100 Subject: [PATCH 02/20] Rework authorizations: sites --- src/bemserver_core/model/sites.py | 330 ++++++++++++++++++++++++++---- 1 file changed, 292 insertions(+), 38 deletions(-) diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 5781a95f..166d7f07 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -2,13 +2,15 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation from bemserver_core.common import PropertyType from bemserver_core.database import Base, db, make_columns_read_only -from bemserver_core.model import Campaign +from .campaigns import Campaign, UserGroupByCampaign +from .users import UserByUserGroup, UserGroup -class StructuralElementProperty(AuthMixin, Base): + +class StructuralElementProperty(AuthMgrMixin, Base): __tablename__ = "struct_elem_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -21,8 +23,11 @@ class StructuralElementProperty(AuthMixin, Base): ) unit_symbol = sqla.Column(sqla.String(20)) + def authorize_read(self, actor): + return True + -class SiteProperty(AuthMixin, Base): +class SiteProperty(AuthMgrMixin, Base): __tablename__ = "site_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -37,8 +42,11 @@ class SiteProperty(AuthMixin, Base): backref=sqla.orm.backref("site_properties", cascade="all, delete-orphan"), ) + def authorize_read(self, actor): + return True + -class BuildingProperty(AuthMixin, Base): +class BuildingProperty(AuthMgrMixin, Base): __tablename__ = "building_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -53,8 +61,11 @@ class BuildingProperty(AuthMixin, Base): backref=sqla.orm.backref("building_properties", cascade="all, delete-orphan"), ) + def authorize_read(self, actor): + return True -class StoreyProperty(AuthMixin, Base): + +class StoreyProperty(AuthMgrMixin, Base): __tablename__ = "storey_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -69,8 +80,11 @@ class StoreyProperty(AuthMixin, Base): backref=sqla.orm.backref("storey_properties", cascade="all, delete-orphan"), ) + def authorize_read(self, actor): + return True + -class SpaceProperty(AuthMixin, Base): +class SpaceProperty(AuthMgrMixin, Base): __tablename__ = "space_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -85,8 +99,11 @@ class SpaceProperty(AuthMixin, Base): backref=sqla.orm.backref("space_properties", cascade="all, delete-orphan"), ) + def authorize_read(self, actor): + return True -class ZoneProperty(AuthMixin, Base): + +class ZoneProperty(AuthMgrMixin, Base): __tablename__ = "zone_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -101,8 +118,11 @@ class ZoneProperty(AuthMixin, Base): backref=sqla.orm.backref("zone_properties", cascade="all, delete-orphan"), ) + def authorize_read(self, actor): + return True + -class SitePropertyData(AuthMixin, Base): +class SitePropertyData(AuthMgrMixin, Base): __tablename__ = "site_prop_data" __table_args__ = (sqla.UniqueConstraint("site_id", "site_prop_id"),) @@ -124,8 +144,7 @@ class SitePropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "site": Relation( kind="one", @@ -141,8 +160,31 @@ def _before_flush(self): if (prop := SiteProperty.get_by_id(self.site_property_id)) is not None: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Site.id == self.site_id) + .exists() + ).scalar() -class BuildingPropertyData(AuthMixin, Base): + +class BuildingPropertyData(AuthMgrMixin, Base): __tablename__ = "building_prop_data" __table_args__ = (sqla.UniqueConstraint("building_id", "building_prop_id"),) @@ -168,8 +210,7 @@ class BuildingPropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "building": Relation( kind="one", @@ -185,8 +226,33 @@ def _before_flush(self): if (prop := BuildingProperty.get_by_id(self.building_property_id)) is not None: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) -class StoreyPropertyData(AuthMixin, Base): + def authorize_read(self, actor): + return db.session.query( + db.session.query(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Building.id == self.building_id) + .exists() + ).scalar() + + +class StoreyPropertyData(AuthMgrMixin, Base): __tablename__ = "storey_prop_data" __table_args__ = (sqla.UniqueConstraint("storey_id", "storey_prop_id"),) @@ -208,8 +274,7 @@ class StoreyPropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "storey": Relation( kind="one", @@ -225,8 +290,35 @@ def _before_flush(self): if (prop := StoreyProperty.get_by_id(self.storey_property_id)) is not None: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) -class SpacePropertyData(AuthMixin, Base): + def authorize_read(self, actor): + return db.session.query( + db.session.query(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Storey.id == self.storey_id) + .exists() + ).scalar() + + +class SpacePropertyData(AuthMgrMixin, Base): __tablename__ = "space_prop_data" __table_args__ = (sqla.UniqueConstraint("space_id", "space_prop_id"),) @@ -248,8 +340,7 @@ class SpacePropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "space": Relation( kind="one", @@ -265,8 +356,37 @@ def _before_flush(self): if (prop := SpaceProperty.get_by_id(self.space_property_id)) is not None: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Space) + .join(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) -class ZonePropertyData(AuthMixin, Base): + def authorize_read(self, actor): + return db.session.query( + db.session.query(Space) + .join(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Space.id == self.space_id) + .exists() + ).scalar() + + +class ZonePropertyData(AuthMgrMixin, Base): __tablename__ = "zone_prop_data" __table_args__ = (sqla.UniqueConstraint("zone_id", "zone_prop_id"),) @@ -288,8 +408,7 @@ class ZonePropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "zone": Relation( kind="one", @@ -305,6 +424,29 @@ def _before_flush(self): if (prop := ZoneProperty.get_by_id(self.zone_property_id)) is not None: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Zone) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(Zone) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Zone.id == self.zone_id) + .exists() + ).scalar() + PROPERTIES_MAPPING = { # Model class name: (lowercase name, property class, property data class) @@ -355,7 +497,7 @@ def get_property_value(self, property_name): return se_property.value_type.value(prop_data.value) -class Site(AuthMixin, StructuralElementBase): +class Site(AuthMgrMixin, StructuralElementBase): __tablename__ = "sites" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -373,8 +515,7 @@ class Site(AuthMixin, StructuralElementBase): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "campaign": Relation( kind="one", @@ -385,8 +526,29 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Campaign.id == self.campaign_id) + .exists() + ).scalar() -class Building(AuthMixin, StructuralElementBase): + +class Building(AuthMgrMixin, StructuralElementBase): __tablename__ = "buildings" __table_args__ = (sqla.UniqueConstraint("site_id", "name"),) @@ -413,8 +575,7 @@ def get(cls, *, campaign_id=None, **kwargs): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "site": Relation( kind="one", @@ -425,8 +586,31 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Site.id == self.site_id) + .exists() + ).scalar() -class Storey(AuthMixin, StructuralElementBase): + +class Storey(AuthMgrMixin, StructuralElementBase): __tablename__ = "storeys" __table_args__ = (sqla.UniqueConstraint("building_id", "name"),) @@ -462,8 +646,7 @@ def get(cls, *, campaign_id=None, site_id=None, **kwargs): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "building": Relation( kind="one", @@ -474,8 +657,33 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) -class Space(AuthMixin, StructuralElementBase): + def authorize_read(self, actor): + return db.session.query( + db.session.query(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Building.id == self.building_id) + .exists() + ).scalar() + + +class Space(AuthMgrMixin, StructuralElementBase): __tablename__ = "spaces" __table_args__ = (sqla.UniqueConstraint("storey_id", "name"),) @@ -522,8 +730,7 @@ def get(cls, *, campaign_id=None, site_id=None, building_id=None, **kwargs): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "storey": Relation( kind="one", @@ -534,8 +741,35 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) -class Zone(AuthMixin, StructuralElementBase): + def authorize_read(self, actor): + return db.session.query( + db.session.query(Storey) + .join(Building) + .join(Site) + .join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Storey.id == self.storey_id) + .exists() + ).scalar() + + +class Zone(AuthMgrMixin, StructuralElementBase): __tablename__ = "zones" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -551,8 +785,7 @@ class Zone(AuthMixin, StructuralElementBase): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "campaign": Relation( kind="one", @@ -563,6 +796,27 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return ( + query.join(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + ) + + def authorize_read(self, actor): + return db.session.query( + db.session.query(Campaign) + .join(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == actor.id) + .filter(Campaign.id == self.campaign_id) + .exists() + ).scalar() + def init_db_structural_elements_triggers(): """Create triggers to protect some columns from update. From 5e5a8186e365de15dd60bc6ac310ba297618f3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Fri, 30 Jan 2026 17:23:15 +0100 Subject: [PATCH 03/20] Rework authorizations: DRY --- src/bemserver_core/model/campaigns.py | 28 +++----- src/bemserver_core/model/sites.py | 97 +++------------------------ src/bemserver_core/model/users.py | 2 +- 3 files changed, 19 insertions(+), 108 deletions(-) diff --git a/src/bemserver_core/model/campaigns.py b/src/bemserver_core/model/campaigns.py index 263c3ac6..b6b264ec 100644 --- a/src/bemserver_core/model/campaigns.py +++ b/src/bemserver_core/model/campaigns.py @@ -33,11 +33,9 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) + return UserGroupByCampaign.authorize_query( + actor, + query.join(UserGroupByCampaign), ) def authorize_read(self, actor): @@ -80,11 +78,9 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(UserGroupByCampaignScope) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) + return UserGroupByCampaignScope.authorize_query( + actor, + query.join(UserGroupByCampaignScope), ) def authorize_read(self, actor): @@ -136,11 +132,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return UserGroup.authorize_query(actor, query.join(UserGroup)) def authorize_read(self, actor): return db.session.query( @@ -189,11 +181,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return UserGroup.authorize_query(actor, query.join(UserGroup)) def authorize_read(self, actor): return db.session.query( diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 166d7f07..1691c7fa 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -162,14 +162,7 @@ def _before_flush(self): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): return db.session.query( @@ -228,15 +221,7 @@ def _before_flush(self): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): return db.session.query( @@ -292,16 +277,7 @@ def _before_flush(self): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): return db.session.query( @@ -358,17 +334,7 @@ def _before_flush(self): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Space) - .join(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Space.authorize_query(actor, query.join(Space)) def authorize_read(self, actor): return db.session.query( @@ -426,14 +392,7 @@ def _before_flush(self): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Zone) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Zone.authorize_query(actor, query.join(Zone)) def authorize_read(self, actor): return db.session.query( @@ -528,13 +487,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): return db.session.query( @@ -588,14 +541,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): return db.session.query( @@ -659,15 +605,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): return db.session.query( @@ -743,16 +681,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): return db.session.query( @@ -798,13 +727,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return ( - query.join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - ) + return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): return db.session.query( diff --git a/src/bemserver_core/model/users.py b/src/bemserver_core/model/users.py index 2a43df53..8e916946 100644 --- a/src/bemserver_core/model/users.py +++ b/src/bemserver_core/model/users.py @@ -121,7 +121,7 @@ def register_class(cls): @classmethod def authorize_query(cls, actor, query): - return query.join(UserByUserGroup).filter(UserByUserGroup.user_id == actor.id) + return UserByUserGroup.authorize_query(actor, query.join(UserByUserGroup)) def authorize_create(self, actor): return False From a4d96d3e4e9a293a493dbba99186e5f7fe3460d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Fri, 30 Jan 2026 17:38:17 +0100 Subject: [PATCH 04/20] Rework authorizations: Campaign/Scope.is_member --- src/bemserver_core/model/campaigns.py | 42 +++++---- src/bemserver_core/model/sites.py | 120 +++----------------------- 2 files changed, 37 insertions(+), 125 deletions(-) diff --git a/src/bemserver_core/model/campaigns.py b/src/bemserver_core/model/campaigns.py index b6b264ec..4eca9920 100644 --- a/src/bemserver_core/model/campaigns.py +++ b/src/bemserver_core/model/campaigns.py @@ -39,14 +39,19 @@ def authorize_query(cls, actor, query): ) def authorize_read(self, actor): - return db.session.query( - db.session.query(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(UserGroupByCampaign.campaign_id == self.id) - .exists() - ).scalar() + return self.is_member(actor) + + def is_member(self, user): + return bool( + db.session.query( + db.session.query(UserGroupByCampaign) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == user.id) + .filter(UserGroupByCampaign.campaign_id == self.id) + .exists() + ).scalar() + ) class CampaignScope(AuthMgrMixin, Base): @@ -84,14 +89,19 @@ def authorize_query(cls, actor, query): ) def authorize_read(self, actor): - return db.session.query( - db.session.query(UserGroupByCampaignScope) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(UserGroupByCampaignScope.campaign_scope_id == self.id) - .exists() - ).scalar() + return self.is_member(actor) + + def is_member(self, user): + return bool( + db.session.query( + db.session.query(UserGroupByCampaignScope) + .join(UserGroup) + .join(UserByUserGroup) + .filter(UserByUserGroup.user_id == user.id) + .filter(UserGroupByCampaignScope.campaign_scope_id == self.id) + .exists() + ).scalar() + ) class UserGroupByCampaign(AuthMgrMixin, Base): diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 1691c7fa..00a5aa56 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -6,8 +6,7 @@ from bemserver_core.common import PropertyType from bemserver_core.database import Base, db, make_columns_read_only -from .campaigns import Campaign, UserGroupByCampaign -from .users import UserByUserGroup, UserGroup +from .campaigns import Campaign class StructuralElementProperty(AuthMgrMixin, Base): @@ -165,16 +164,7 @@ def authorize_query(cls, actor, query): return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Site.id == self.site_id) - .exists() - ).scalar() + return self.site.campaign.is_member(actor) class BuildingPropertyData(AuthMgrMixin, Base): @@ -224,17 +214,7 @@ def authorize_query(cls, actor, query): return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Building.id == self.building_id) - .exists() - ).scalar() + return self.building.site.campaign.is_member(actor) class StoreyPropertyData(AuthMgrMixin, Base): @@ -280,18 +260,7 @@ def authorize_query(cls, actor, query): return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Storey.id == self.storey_id) - .exists() - ).scalar() + return self.storey.building.site.campaign.is_member(actor) class SpacePropertyData(AuthMgrMixin, Base): @@ -337,19 +306,7 @@ def authorize_query(cls, actor, query): return Space.authorize_query(actor, query.join(Space)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Space) - .join(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Space.id == self.space_id) - .exists() - ).scalar() + return self.space.storey.building.site.campaign.is_member(actor) class ZonePropertyData(AuthMgrMixin, Base): @@ -395,16 +352,7 @@ def authorize_query(cls, actor, query): return Zone.authorize_query(actor, query.join(Zone)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Zone) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Zone.id == self.zone_id) - .exists() - ).scalar() + return self.zone.campaign.is_member(actor) PROPERTIES_MAPPING = { @@ -490,15 +438,7 @@ def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Campaign.id == self.campaign_id) - .exists() - ).scalar() + return self.campaign.is_member(actor) class Building(AuthMgrMixin, StructuralElementBase): @@ -544,16 +484,7 @@ def authorize_query(cls, actor, query): return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Site.id == self.site_id) - .exists() - ).scalar() + return self.site.campaign.is_member(actor) class Storey(AuthMgrMixin, StructuralElementBase): @@ -608,17 +539,7 @@ def authorize_query(cls, actor, query): return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Building.id == self.building_id) - .exists() - ).scalar() + return self.building.site.campaign.is_member(actor) class Space(AuthMgrMixin, StructuralElementBase): @@ -684,18 +605,7 @@ def authorize_query(cls, actor, query): return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Storey) - .join(Building) - .join(Site) - .join(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Storey.id == self.storey_id) - .exists() - ).scalar() + return self.storey.building.site.campaign.is_member(actor) class Zone(AuthMgrMixin, StructuralElementBase): @@ -730,15 +640,7 @@ def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): - return db.session.query( - db.session.query(Campaign) - .join(UserGroupByCampaign) - .join(UserGroup) - .join(UserByUserGroup) - .filter(UserByUserGroup.user_id == actor.id) - .filter(Campaign.id == self.campaign_id) - .exists() - ).scalar() + return self.campaign.is_member(actor) def init_db_structural_elements_triggers(): From 8e8ea803bcbd87155ddb6ef9dbad6ee04773dff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Mon, 2 Feb 2026 10:45:55 +0100 Subject: [PATCH 05/20] Rework authorizations: timeseries --- src/bemserver_core/model/timeseries.py | 127 +++++++++++++++++++------ 1 file changed, 100 insertions(+), 27 deletions(-) diff --git a/src/bemserver_core/model/timeseries.py b/src/bemserver_core/model/timeseries.py index 013042f1..4d65377a 100644 --- a/src/bemserver_core/model/timeseries.py +++ b/src/bemserver_core/model/timeseries.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr from bemserver_core.common import PropertyType, ureg from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.exceptions import TimeseriesNotFoundError @@ -16,7 +16,7 @@ from bemserver_core.model.users import User, UserByUserGroup, UserGroup -class TimeseriesProperty(AuthMixin, Base): +class TimeseriesProperty(AuthMgrMixin, Base): __tablename__ = "ts_props" id = sqla.Column(sqla.Integer, primary_key=True) @@ -29,15 +29,21 @@ class TimeseriesProperty(AuthMixin, Base): ) unit_symbol = sqla.Column(sqla.String(20)) + def authorize_read(self, actor): + return True -class TimeseriesDataState(AuthMixin, Base): + +class TimeseriesDataState(AuthMgrMixin, Base): __tablename__ = "ts_data_states" id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) + def authorize_read(self, actor): + return True + -class Timeseries(AuthMixin, Base): +class Timeseries(AuthMgrMixin, Base): __tablename__ = "timeseries" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -58,8 +64,7 @@ class Timeseries(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "campaign": Relation( kind="one", @@ -80,6 +85,13 @@ def _before_flush(self): if self.unit_symbol is not None: ureg.validate_unit(self.unit_symbol) + @classmethod + def authorize_query(cls, actor, query): + return CampaignScope.authorize_query(actor, query.join(CampaignScope)) + + def authorize_read(self, actor): + return self.campaign_scope.is_member(actor) + @classmethod def get( cls, @@ -380,7 +392,17 @@ def get_property_for_many_timeseries(cls, timeseries, prop_name): return dict(list(db.session.execute(stmt))) -class TimeseriesPropertyData(AuthMixin, Base): +@auth_mgr.add_rule("read_ts_data") +def authorize_read_ts_data(actor: User, timeseries: Timeseries) -> bool: + return False + + +@auth_mgr.add_rule("write_ts_data") +def authorize_write_ts_data(actor: User, timeseries: Timeseries) -> bool: + return False + + +class TimeseriesPropertyData(AuthMgrMixin, Base): """Timeseries property data""" __tablename__ = "ts_prop_data" @@ -400,8 +422,7 @@ class TimeseriesPropertyData(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -417,8 +438,15 @@ def _before_flush(self): if (prop := TimeseriesProperty.get_by_id(self.property_id)) is not None: prop.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) -class TimeseriesByDataState(AuthMixin, Base): + +class TimeseriesByDataState(AuthMgrMixin, Base): __tablename__ = "ts_by_data_states" __table_args__ = (sqla.UniqueConstraint("timeseries_id", "data_state_id"),) @@ -441,8 +469,7 @@ class TimeseriesByDataState(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -453,8 +480,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_create(self, actor): + return self.timeseries.authorize_read(actor) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + + def authorize_update(self, actor): + return self.timeseries.authorize_read(actor) + + def authorize_delete(self, actor): + return self.timeseries.authorize_read(actor) -class TimeseriesBySite(AuthMixin, Base): + +class TimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "ts_by_sites" __table_args__ = (sqla.UniqueConstraint("site_id", "timeseries_id"),) @@ -473,8 +516,7 @@ class TimeseriesBySite(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -491,8 +533,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + -class TimeseriesByBuilding(AuthMixin, Base): +class TimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ts_by_buildings" __table_args__ = (sqla.UniqueConstraint("building_id", "timeseries_id"),) @@ -515,8 +564,7 @@ class TimeseriesByBuilding(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -533,8 +581,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) -class TimeseriesByStorey(AuthMixin, Base): + +class TimeseriesByStorey(AuthMgrMixin, Base): __tablename__ = "ts_by_storeys" __table_args__ = (sqla.UniqueConstraint("storey_id", "timeseries_id"),) @@ -553,8 +608,7 @@ class TimeseriesByStorey(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -571,8 +625,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + -class TimeseriesBySpace(AuthMixin, Base): +class TimeseriesBySpace(AuthMgrMixin, Base): __tablename__ = "ts_by_spaces" __table_args__ = (sqla.UniqueConstraint("space_id", "timeseries_id"),) @@ -591,8 +652,7 @@ class TimeseriesBySpace(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -609,8 +669,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) -class TimeseriesByZone(AuthMixin, Base): + +class TimeseriesByZone(AuthMgrMixin, Base): __tablename__ = "ts_by_zones" __table_args__ = (sqla.UniqueConstraint("zone_id", "timeseries_id"),) @@ -629,8 +696,7 @@ class TimeseriesByZone(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -647,6 +713,13 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + def init_db_timeseries_triggers(): """Create triggers to protect some columns from update. From 9fe10cd2c952756824a77276bdc9e8b1b8fa36b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Mon, 2 Feb 2026 17:39:19 +0100 Subject: [PATCH 06/20] Rework authorizations expire if persistent --- src/bemserver_core/authorization.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index 82d12de6..4a4ca56b 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -6,6 +6,8 @@ from contextvars import ContextVar from pathlib import Path +import sqlalchemy as sqla + from oso import Oso, OsoError, Relation # noqa from polar.data.adapter.sqlalchemy_adapter import SqlAlchemyAdapter @@ -151,6 +153,8 @@ def eval_rule(self, action: str, actor: "User", item: any): def authorize(self, action: str, item: any) -> bool: actor = get_current_user() db.session().enable_relationship_loading(item) + if sqla.inspect(item).persistent: + db.session().expire(item) if not ( OPEN_BAR.get() or actor.is_admin or self.eval_rule(action, actor, item) ): From 4c09ebcdcf4bf474ecdbd5ef28e8246269615181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 18:41:28 +0200 Subject: [PATCH 07/20] Rework authorizations: energy --- src/bemserver_core/model/energy.py | 67 +++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/src/bemserver_core/model/energy.py b/src/bemserver_core/model/energy.py index 08b3c6b7..e7ec9a32 100644 --- a/src/bemserver_core/model/energy.py +++ b/src/bemserver_core/model/energy.py @@ -2,32 +2,43 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation from bemserver_core.database import Base, db +from .timeseries import Timeseries -class Energy(AuthMixin, Base): + +class Energy(AuthMgrMixin, Base): __tablename__ = "energies" id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) + def authorize_read(self, actor): + return True + -class EnergyEndUse(AuthMixin, Base): +class EnergyEndUse(AuthMgrMixin, Base): __tablename__ = "ener_end_uses" id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) + def authorize_read(self, actor): + return True -class EnergyProductionTechnology(AuthMixin, Base): + +class EnergyProductionTechnology(AuthMgrMixin, Base): __tablename__ = "ener_prod_techs" id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) + def authorize_read(self, actor): + return True + -class EnergyConsumptionTimeseriesBySite(AuthMixin, Base): +class EnergyConsumptionTimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "ener_cons_ts_by_site" __table_args__ = (sqla.UniqueConstraint("site_id", "energy_id", "end_use_id"),) @@ -64,8 +75,7 @@ class EnergyConsumptionTimeseriesBySite(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -76,8 +86,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) -class EnergyConsumptionTimeseriesByBuilding(AuthMixin, Base): + +class EnergyConsumptionTimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ener_cons_ts_by_building" __table_args__ = (sqla.UniqueConstraint("building_id", "energy_id", "end_use_id"),) @@ -114,8 +131,7 @@ class EnergyConsumptionTimeseriesByBuilding(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -126,8 +142,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + -class EnergyProductionTimeseriesBySite(AuthMixin, Base): +class EnergyProductionTimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "ener_prod_ts_by_site" __table_args__ = (sqla.UniqueConstraint("site_id", "energy_id", "prod_tech_id"),) @@ -164,8 +187,7 @@ class EnergyProductionTimeseriesBySite(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -176,8 +198,15 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) -class EnergyProductionTimeseriesByBuilding(AuthMixin, Base): + +class EnergyProductionTimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ener_prod_ts_by_building" __table_args__ = ( sqla.UniqueConstraint("building_id", "energy_id", "prod_tech_id"), @@ -216,8 +245,7 @@ class EnergyProductionTimeseriesByBuilding(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -228,6 +256,13 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) + def init_db_energy(): """Create default energy energys and end uses From c7c5043810fb37cd356eb15c52fbc7844385170d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:02:12 +0200 Subject: [PATCH 08/20] Rework authorizations: events --- src/bemserver_core/model/events.py | 175 ++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 26 deletions(-) diff --git a/src/bemserver_core/model/events.py b/src/bemserver_core/model/events.py index a3d7b7c5..e65d6eec 100644 --- a/src/bemserver_core/model/events.py +++ b/src/bemserver_core/model/events.py @@ -6,7 +6,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation from bemserver_core.celery import BEMServerCoreSystemTask, celery, logger from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.exceptions import ( @@ -42,15 +42,18 @@ def __lt__(self, other): DEFAULT_NOTIFICATION_EVENT_LEVEL = EventLevelEnum.WARNING -class EventCategory(AuthMixin, Base): +class EventCategory(AuthMgrMixin, Base): __tablename__ = "event_categs" id = sqla.Column(sqla.Integer, primary_key=True, autoincrement=True, nullable=False) name = sqla.Column(sqla.String(80), unique=True, nullable=False) description = sqla.Column(sqla.String(250)) + def authorize_read(self, actor): + return True -class Event(AuthMixin, Base): + +class Event(AuthMgrMixin, Base): __tablename__ = "events" id = sqla.Column(sqla.Integer, primary_key=True, autoincrement=True, nullable=False) @@ -71,8 +74,7 @@ class Event(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "campaign_scope": Relation( kind="one", @@ -83,6 +85,22 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return CampaignScope.authorize_query(actor, query.join(CampaignScope)) + + def authorize_create(self, actor): + return self.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.campaign_scope.is_member(actor) + @classmethod def get( cls, @@ -328,7 +346,7 @@ def notify(event_id, timestamp): db.session.commit() -class EventCategoryByUser(AuthMixin, Base): +class EventCategoryByUser(AuthMgrMixin, Base): """EventCategory x User associations""" __tablename__ = "event_categs_by_users" @@ -354,8 +372,7 @@ class EventCategoryByUser(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user": Relation( kind="one", @@ -372,8 +389,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return query.filter(EventCategoryByUser.user_id == actor.id) + + def authorize_create(self, actor): + return actor.id == self.user_id + + def authorize_read(self, actor): + return actor.id == self.user_id + + def authorize_update(self, actor): + return actor.id == self.user_id + + def authorize_delete(self, actor): + return actor.id == self.user_id + -class TimeseriesByEvent(AuthMixin, Base): +class TimeseriesByEvent(AuthMgrMixin, Base): __tablename__ = "ts_by_events" __table_args__ = (sqla.UniqueConstraint("event_id", "timeseries_id"),) @@ -404,8 +437,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -422,8 +454,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + -class EventBySite(AuthMixin, Base): +class EventBySite(AuthMgrMixin, Base): __tablename__ = "events_by_sites" __table_args__ = (sqla.UniqueConstraint("site_id", "event_id"),) @@ -452,8 +500,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "event": Relation( kind="one", @@ -470,8 +517,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + -class EventByBuilding(AuthMixin, Base): +class EventByBuilding(AuthMgrMixin, Base): __tablename__ = "events_by_buildings" __table_args__ = (sqla.UniqueConstraint("building_id", "event_id"),) @@ -500,8 +563,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "event": Relation( kind="one", @@ -518,8 +580,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + -class EventByStorey(AuthMixin, Base): +class EventByStorey(AuthMgrMixin, Base): __tablename__ = "events_by_storeys" __table_args__ = (sqla.UniqueConstraint("storey_id", "event_id"),) @@ -548,8 +626,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "event": Relation( kind="one", @@ -566,8 +643,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + -class EventBySpace(AuthMixin, Base): +class EventBySpace(AuthMgrMixin, Base): __tablename__ = "events_by_spaces" __table_args__ = (sqla.UniqueConstraint("space_id", "event_id"),) @@ -596,8 +689,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "event": Relation( kind="one", @@ -614,8 +706,24 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + -class EventByZone(AuthMixin, Base): +class EventByZone(AuthMgrMixin, Base): __tablename__ = "events_by_zones" __table_args__ = (sqla.UniqueConstraint("zone_id", "event_id"),) @@ -644,8 +752,7 @@ def _before_flush(self): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "event": Relation( kind="one", @@ -662,6 +769,22 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Event.authorize_query(actor, query.join(Event)) + + def authorize_create(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_read(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_update(self, actor): + return self.event.campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + return self.event.campaign_scope.is_member(actor) + def init_db_events_triggers(): """Create triggers to protect some columns from update. From 72bb4af99a45a856753cfecab3494e3e30e828fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:07:32 +0200 Subject: [PATCH 09/20] Rework authorizations: weather --- src/bemserver_core/model/weather.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bemserver_core/model/weather.py b/src/bemserver_core/model/weather.py index 17be7aba..cd198c6d 100644 --- a/src/bemserver_core/model/weather.py +++ b/src/bemserver_core/model/weather.py @@ -4,9 +4,11 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation from bemserver_core.database import Base +from .timeseries import Timeseries + class WeatherParameterEnum(enum.Enum): """Weather parameter enum""" @@ -25,7 +27,7 @@ class WeatherParameterEnum(enum.Enum): TOTAL_PRECIPITATION = "total precipitation" -class WeatherTimeseriesBySite(AuthMixin, Base): +class WeatherTimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "weather_ts_by_site" __table_args__ = (sqla.UniqueConstraint("site_id", "parameter", "forecast"),) @@ -50,8 +52,7 @@ class WeatherTimeseriesBySite(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "timeseries": Relation( kind="one", @@ -61,3 +62,10 @@ def register_class(cls): ), }, ) + + @classmethod + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + return self.timeseries.authorize_read(actor) From 7ef4852cf0f5625e3c6f8417c266e9bf4c1de6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:10:39 +0200 Subject: [PATCH 10/20] Rework authorizations: tasks --- src/bemserver_core/model/tasks.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bemserver_core/model/tasks.py b/src/bemserver_core/model/tasks.py index b0758da4..75dce558 100644 --- a/src/bemserver_core/model/tasks.py +++ b/src/bemserver_core/model/tasks.py @@ -6,13 +6,15 @@ import sqlalchemy as sqla from sqlalchemy.dialects.postgresql import JSONB -from bemserver_core.authorization import AuthMixin, Relation, auth +from bemserver_core.authorization import AuthMgrMixin, Relation from bemserver_core.celery import logger from bemserver_core.database import Base from bemserver_core.time_utils import PeriodEnum, make_date_offset +from .campaigns import Campaign -class TaskByCampaign(AuthMixin, Base): + +class TaskByCampaign(AuthMgrMixin, Base): __tablename__ = "tasks_by_campaigns" id = sqla.Column(sqla.Integer, primary_key=True) @@ -33,8 +35,7 @@ class TaskByCampaign(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "campaign": Relation( kind="one", @@ -45,6 +46,13 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return Campaign.authorize_query(actor, query.join(Campaign)) + + def authorize_read(self, actor): + return self.campaign.is_member(actor) + def make_interval(self): datetime = dt.datetime.now(tz=ZoneInfo(self.campaign.timezone)) logger.debug("datetime: %s", datetime) From b622c3b04f83bd2e3151de41ca23fdd93dd906fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:46:57 +0200 Subject: [PATCH 11/20] Revert "Rework authorizations expire if persistent" This reverts commit d11440d4ed852564f7dbeb28ad2ed2574f0f0218. --- src/bemserver_core/authorization.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index 4a4ca56b..82d12de6 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -6,8 +6,6 @@ from contextvars import ContextVar from pathlib import Path -import sqlalchemy as sqla - from oso import Oso, OsoError, Relation # noqa from polar.data.adapter.sqlalchemy_adapter import SqlAlchemyAdapter @@ -153,8 +151,6 @@ def eval_rule(self, action: str, actor: "User", item: any): def authorize(self, action: str, item: any) -> bool: actor = get_current_user() db.session().enable_relationship_loading(item) - if sqla.inspect(item).persistent: - db.session().expire(item) if not ( OPEN_BAR.get() or actor.is_admin or self.eval_rule(action, actor, item) ): From a9abd0aaae850b5ddfffcb89a8c9df15fe00aa77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:48:47 +0200 Subject: [PATCH 12/20] Rework authorizations: notifications --- src/bemserver_core/model/notifications.py | 31 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/bemserver_core/model/notifications.py b/src/bemserver_core/model/notifications.py index b663fe85..9f835735 100644 --- a/src/bemserver_core/model/notifications.py +++ b/src/bemserver_core/model/notifications.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin, Relation, auth, get_current_user +from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr from bemserver_core.celery import BEMServerCoreSystemTask, celery, logger from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.email import ems @@ -14,7 +14,7 @@ from bemserver_core.model.users import User -class Notification(AuthMixin, Base): +class Notification(AuthMgrMixin, Base): __tablename__ = "notifs" id = sqla.Column(sqla.Integer, primary_key=True, autoincrement=True, nullable=False) @@ -33,8 +33,7 @@ class Notification(AuthMixin, Base): @classmethod def register_class(cls): - auth.register_class( - cls, + super().register_class( fields={ "user": Relation( kind="one", @@ -51,6 +50,16 @@ def register_class(cls): }, ) + @classmethod + def authorize_query(cls, actor, query): + return query.filter(Notification.user_id == actor.id) + + def authorize_read(self, actor): + return actor.id == self.user_id + + def authorize_update(self, actor): + return actor.id == self.user_id + @classmethod def get(cls, campaign_id=None, **kwargs): query = super().get(**kwargs) @@ -74,7 +83,7 @@ def get_count_by_campaign(cls, user_id, read=None): Defaults to None, which means count all notifications. """ user = User.get_by_id(user_id) - auth.authorize(get_current_user(), "count_notifications", user) + auth_mgr.authorize("count_notifications", user) stmt = ( sqla.select( @@ -114,7 +123,7 @@ def mark_all_as_read(cls, user_id, campaign_id=None): Defaults to None, which means all campaigns. """ user = User.get_by_id(user_id) - auth.authorize(get_current_user(), "mark_notifications", user) + auth_mgr.authorize("mark_notifications", user) stmt = sqla.update(cls).values(read=True).where(cls.user_id == user_id) @@ -131,6 +140,16 @@ def mark_all_as_read(cls, user_id, campaign_id=None): db.session.execute(stmt) +@auth_mgr.add_rule("count_notifications") +def authorize_count_notifications(actor: User, user: User) -> bool: + return actor.id == user.id + + +@auth_mgr.add_rule("mark_notifications") +def authorize_mark_notifications(actor: User, user: User) -> bool: + return actor.id == user.id + + @sqla.event.listens_for(Notification, "after_insert") def notification_after_insert(_mapper, _connection, target): """Callback executed on insert event From cbd34804cf8f5910d3033dd422282da075ad7743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:54:42 +0200 Subject: [PATCH 13/20] Fix rework auth timeseries --- .../input_output/timeseries_data_io.py | 16 ++++++++-------- src/bemserver_core/model/timeseries.py | 6 ++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/bemserver_core/input_output/timeseries_data_io.py b/src/bemserver_core/input_output/timeseries_data_io.py index 5e45e0c1..bea6278e 100644 --- a/src/bemserver_core/input_output/timeseries_data_io.py +++ b/src/bemserver_core/input_output/timeseries_data_io.py @@ -11,7 +11,7 @@ import numpy as np import pandas as pd -from bemserver_core.authorization import auth, get_current_user +from bemserver_core.authorization import auth_mgr from bemserver_core.common import ureg from bemserver_core.database import db from bemserver_core.exceptions import ( @@ -74,7 +74,7 @@ def get_last( """ # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "read_data", ts) + auth_mgr.authorize("read_ts_data", ts) params = { "timeseries_ids": [ts.id for ts in timeseries], @@ -146,7 +146,7 @@ def get_timeseries_stats( """ # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "read_data", ts) + auth_mgr.authorize("read_ts_data", ts) params = { "timeseries_ids": [ts.id for ts in timeseries], @@ -276,7 +276,7 @@ def set_timeseries_data( # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "write_data", ts) + auth_mgr.authorize("write_ts_data", ts) if convert_from: cls._convert_from( @@ -355,7 +355,7 @@ def get_timeseries_data( """ # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "read_data", ts) + auth_mgr.authorize("read_ts_data", ts) # Get timeseries data stmt = ( @@ -455,7 +455,7 @@ def get_timeseries_buckets_data( # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "read_data", ts) + auth_mgr.authorize("read_ts_data", ts) fill_value = 0 if aggregation == "count" else np.nan dtype = int if aggregation == "count" else float @@ -574,7 +574,7 @@ def get_timeseries_aggregate_data( Returns a dataframe. """ for ts in timeseries: - auth.authorize(get_current_user(), "read_data", ts) + auth_mgr.authorize("read_ts_data", ts) if agg == "avg": agg_func = sqla.func.avg(TimeseriesData.value) @@ -633,7 +633,7 @@ def delete(cls, start_dt, end_dt, timeseries, data_state): """ # Check permissions for ts in timeseries: - auth.authorize(get_current_user(), "write_data", ts) + auth_mgr.authorize("write_ts_data", ts) # Delete timeseries data ( diff --git a/src/bemserver_core/model/timeseries.py b/src/bemserver_core/model/timeseries.py index 4d65377a..34b6fd1f 100644 --- a/src/bemserver_core/model/timeseries.py +++ b/src/bemserver_core/model/timeseries.py @@ -394,12 +394,14 @@ def get_property_for_many_timeseries(cls, timeseries, prop_name): @auth_mgr.add_rule("read_ts_data") def authorize_read_ts_data(actor: User, timeseries: Timeseries) -> bool: - return False + campaign_scope = CampaignScope.get_by_id(timeseries.campaign_scope_id) + return campaign_scope.is_member(actor) @auth_mgr.add_rule("write_ts_data") def authorize_write_ts_data(actor: User, timeseries: Timeseries) -> bool: - return False + campaign_scope = CampaignScope.get_by_id(timeseries.campaign_scope_id) + return campaign_scope.is_member(actor) class TimeseriesPropertyData(AuthMgrMixin, Base): From bdb651a8f076e5302816276bcd718a395c30ce70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 19:59:25 +0200 Subject: [PATCH 14/20] Fix rework auth sites --- src/bemserver_core/model/sites.py | 8 +++++++- src/bemserver_core/process/weather.py | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 00a5aa56..5adff6bc 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -2,11 +2,12 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr from bemserver_core.common import PropertyType from bemserver_core.database import Base, db, make_columns_read_only from .campaigns import Campaign +from .users import User class StructuralElementProperty(AuthMgrMixin, Base): @@ -441,6 +442,11 @@ def authorize_read(self, actor): return self.campaign.is_member(actor) +@auth_mgr.add_rule("get_weather_data") +def authorize_get_weather_data(actor: User, site: Site) -> bool: + return False + + class Building(AuthMgrMixin, StructuralElementBase): __tablename__ = "buildings" __table_args__ = (sqla.UniqueConstraint("site_id", "name"),) diff --git a/src/bemserver_core/process/weather.py b/src/bemserver_core/process/weather.py index 8fda5c02..66971f47 100644 --- a/src/bemserver_core/process/weather.py +++ b/src/bemserver_core/process/weather.py @@ -8,7 +8,7 @@ import requests from requests.exceptions import RequestException -from bemserver_core.authorization import auth, get_current_user +from bemserver_core.authorization import auth_mgr from bemserver_core.exceptions import ( BEMServerCoreSettingsError, BEMServerCoreWeatherAPIAuthenticationError, @@ -157,7 +157,7 @@ def get_weather_data_for_site(self, site, start_dt, end_dt, forecast=False): :param datetime end_dt: Time interval exclusive upper bound (tz-aware) :param bool forecast: Whether or not the data is past data or forecast """ - auth.authorize(get_current_user(), "get_weather_data", site) + auth_mgr.authorize("get_weather_data", site) ds_clean = TimeseriesDataState.get(name="Clean").first() From 60deb5399c019c2330e54f1d83a6c16d958c665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 20:01:42 +0200 Subject: [PATCH 15/20] Rework authorizations: fix Celery test --- tests/test_celery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_celery.py b/tests/test_celery.py index 16d859b6..af06a298 100644 --- a/tests/test_celery.py +++ b/tests/test_celery.py @@ -4,7 +4,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import OPEN_BAR, auth, get_current_user +from bemserver_core.authorization import OPEN_BAR, auth_mgr from bemserver_core.celery import BEMServerCoreCelery, BEMServerCoreSystemTask from bemserver_core.database import db from bemserver_core.model import User @@ -22,7 +22,7 @@ def test_celery_base_task_open_bar(self): @celery.task(base=BEMServerCoreSystemTask) def dummy_task(): assert OPEN_BAR.get() - auth.authorized_query(get_current_user(), "read", User) + auth_mgr.authorize_query("read", User) result = dummy_task.apply() From 38ffa04802af8c24861f27c51eafbfca51643caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Tue, 31 Mar 2026 20:04:37 +0200 Subject: [PATCH 16/20] Rework authorizations: AuthMgrMixin tests --- tests/test_authorization.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 5835faf6..cdceb00f 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -6,25 +6,27 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.database import Base, db -class TestAuthMixin: +class TestAuthMgrMixin: @pytest.mark.usefixtures("database") - def test_auth_mixin_sort(self): - """Check AuthMixin doesn't break database sort feature""" + def test_auth_mgr_mixin_sort(self): + """Check AuthMgrMixin doesn't break database sort feature""" - class TestAuthMixinSort(Base, AuthMixin): - __tablename__ = "test_auth_mixin_sort" + class TestAuthMgrMixinSort(Base, AuthMgrMixin): + __tablename__ = "test_auth_mgr_mixin_sort" id = sqla.Column(sqla.Integer, primary_key=True) title = sqla.Column(sqla.String()) severity = sqla.Column( - sqla.Enum("Low", "High", "Critical", name="test_auth_mixin_severity") + sqla.Enum( + "Low", "High", "Critical", name="test_auth_mgr_mixin_severity" + ) ) - Test = TestAuthMixinSort + Test = TestAuthMgrMixinSort Test.__table__.create(bind=db.engine) @@ -56,17 +58,17 @@ class TestAuthMixinSort(Base, AuthMixin): assert ret == [mes_5, mes_2, mes_3, mes_6, mes_4, mes_1] @pytest.mark.usefixtures("database") - def test_auth_mixin_min_max(self): - """Check AuthMixin doesn't break database min/max feature""" + def test_auth_mgr_mixin_min_max(self): + """Check AuthMgrMixin doesn't break database min/max feature""" - class TestAuthMixinMinMax(Base, AuthMixin): - __tablename__ = "test_auth_mixin_min_max" + class TestAuthMgrMixinMinMax(Base, AuthMgrMixin): + __tablename__ = "test_auth_mgr_mixin_min_max" id = sqla.Column(sqla.Integer, primary_key=True) note = sqla.Column(sqla.Float()) date = sqla.Column(sqla.DateTime(timezone=True)) - Test = TestAuthMixinMinMax + Test = TestAuthMgrMixinMinMax Test.__table__.create(bind=db.engine) From fe8259f6756020872be09a85d0e134ea6b07c26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Thu, 16 Apr 2026 15:13:46 +0200 Subject: [PATCH 17/20] Rework authorizations: don't use relations in auth methods --- src/bemserver_core/authorization.py | 1 - src/bemserver_core/model/energy.py | 12 ++- src/bemserver_core/model/events.py | 114 +++++++++++++++++++------ src/bemserver_core/model/sites.py | 72 +++++++++++++--- src/bemserver_core/model/tasks.py | 3 +- src/bemserver_core/model/timeseries.py | 33 ++++--- src/bemserver_core/model/weather.py | 3 +- 7 files changed, 182 insertions(+), 56 deletions(-) diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index 82d12de6..4e89043b 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -150,7 +150,6 @@ def eval_rule(self, action: str, actor: "User", item: any): def authorize(self, action: str, item: any) -> bool: actor = get_current_user() - db.session().enable_relationship_loading(item) if not ( OPEN_BAR.get() or actor.is_admin or self.eval_rule(action, actor, item) ): diff --git a/src/bemserver_core/model/energy.py b/src/bemserver_core/model/energy.py index e7ec9a32..6d8ead3a 100644 --- a/src/bemserver_core/model/energy.py +++ b/src/bemserver_core/model/energy.py @@ -91,7 +91,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class EnergyConsumptionTimeseriesByBuilding(AuthMgrMixin, Base): @@ -147,7 +148,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class EnergyProductionTimeseriesBySite(AuthMgrMixin, Base): @@ -203,7 +205,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class EnergyProductionTimeseriesByBuilding(AuthMgrMixin, Base): @@ -261,7 +264,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) def init_db_energy(): diff --git a/src/bemserver_core/model/events.py b/src/bemserver_core/model/events.py index e65d6eec..8c0bc109 100644 --- a/src/bemserver_core/model/events.py +++ b/src/bemserver_core/model/events.py @@ -90,16 +90,20 @@ def authorize_query(cls, actor, query): return CampaignScope.authorize_query(actor, query.join(CampaignScope)) def authorize_create(self, actor): - return self.campaign_scope.is_member(actor) + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.campaign_scope.is_member(actor) + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.campaign_scope.is_member(actor) + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.campaign_scope.is_member(actor) + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) @classmethod def get( @@ -454,21 +458,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) class EventBySite(AuthMgrMixin, Base): @@ -517,21 +530,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) class EventByBuilding(AuthMgrMixin, Base): @@ -580,21 +602,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) class EventByStorey(AuthMgrMixin, Base): @@ -643,21 +674,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) class EventBySpace(AuthMgrMixin, Base): @@ -706,21 +746,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) class EventByZone(AuthMgrMixin, Base): @@ -769,21 +818,30 @@ def register_class(cls): }, ) + @property + def campaign_scope(self): + return ( + db.session.query(CampaignScope) + .join(Event) + .filter(Event.id == self.event_id) + .one() + ) + @classmethod def authorize_query(cls, actor, query): return Event.authorize_query(actor, query.join(Event)) def authorize_create(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_read(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_update(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def authorize_delete(self, actor): - return self.event.campaign_scope.is_member(actor) + return self.campaign_scope.is_member(actor) def init_db_events_triggers(): diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 5adff6bc..b0659ef0 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -165,7 +165,10 @@ def authorize_query(cls, actor, query): return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): - return self.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign).join(Site).filter(Site.id == self.site_id).one() + ) + return campaign.is_member(actor) class BuildingPropertyData(AuthMgrMixin, Base): @@ -215,7 +218,14 @@ def authorize_query(cls, actor, query): return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): - return self.building.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .filter(Building.id == self.building_id) + .one() + ) + return campaign.is_member(actor) class StoreyPropertyData(AuthMgrMixin, Base): @@ -261,7 +271,14 @@ def authorize_query(cls, actor, query): return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): - return self.storey.building.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .join(Storey) + .filter(Storey.id == self.storey_id) + ).one() + return campaign.is_member(actor) class SpacePropertyData(AuthMgrMixin, Base): @@ -307,7 +324,15 @@ def authorize_query(cls, actor, query): return Space.authorize_query(actor, query.join(Space)) def authorize_read(self, actor): - return self.space.storey.building.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .join(Storey) + .join(Space) + .filter(Space.id == self.space_id) + ).one() + return campaign.is_member(actor) class ZonePropertyData(AuthMgrMixin, Base): @@ -353,7 +378,10 @@ def authorize_query(cls, actor, query): return Zone.authorize_query(actor, query.join(Zone)) def authorize_read(self, actor): - return self.zone.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign).join(Zone).filter(Zone.id == self.zone_id) + ).one() + return campaign.is_member(actor) PROPERTIES_MAPPING = { @@ -439,7 +467,10 @@ def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): - return self.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign).filter(Campaign.id == self.campaign_id).one() + ) + return campaign.is_member(actor) @auth_mgr.add_rule("get_weather_data") @@ -490,7 +521,10 @@ def authorize_query(cls, actor, query): return Site.authorize_query(actor, query.join(Site)) def authorize_read(self, actor): - return self.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign).join(Site).filter(Site.id == self.site_id).one() + ) + return campaign.is_member(actor) class Storey(AuthMgrMixin, StructuralElementBase): @@ -545,7 +579,14 @@ def authorize_query(cls, actor, query): return Building.authorize_query(actor, query.join(Building)) def authorize_read(self, actor): - return self.building.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .filter(Building.id == self.building_id) + .one() + ) + return campaign.is_member(actor) class Space(AuthMgrMixin, StructuralElementBase): @@ -611,7 +652,15 @@ def authorize_query(cls, actor, query): return Storey.authorize_query(actor, query.join(Storey)) def authorize_read(self, actor): - return self.storey.building.site.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .join(Storey) + .filter(Storey.id == self.storey_id) + .one() + ) + return campaign.is_member(actor) class Zone(AuthMgrMixin, StructuralElementBase): @@ -646,7 +695,10 @@ def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): - return self.campaign.is_member(actor) + campaign = ( + db.session.query(Campaign).filter(Campaign.id == self.campaign_id).one() + ) + return campaign.is_member(actor) def init_db_structural_elements_triggers(): diff --git a/src/bemserver_core/model/tasks.py b/src/bemserver_core/model/tasks.py index 75dce558..ddf7162c 100644 --- a/src/bemserver_core/model/tasks.py +++ b/src/bemserver_core/model/tasks.py @@ -51,7 +51,8 @@ def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) def authorize_read(self, actor): - return self.campaign.is_member(actor) + campaign = Campaign.get_by_id(self.campaign_id) + return campaign.is_member(actor) def make_interval(self): datetime = dt.datetime.now(tz=ZoneInfo(self.campaign.timezone)) diff --git a/src/bemserver_core/model/timeseries.py b/src/bemserver_core/model/timeseries.py index 34b6fd1f..19d12e51 100644 --- a/src/bemserver_core/model/timeseries.py +++ b/src/bemserver_core/model/timeseries.py @@ -90,7 +90,8 @@ def authorize_query(cls, actor, query): return CampaignScope.authorize_query(actor, query.join(CampaignScope)) def authorize_read(self, actor): - return self.campaign_scope.is_member(actor) + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) @classmethod def get( @@ -445,7 +446,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesByDataState(AuthMgrMixin, Base): @@ -487,16 +489,20 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_create(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) def authorize_update(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) def authorize_delete(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesBySite(AuthMgrMixin, Base): @@ -540,7 +546,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesByBuilding(AuthMgrMixin, Base): @@ -588,7 +595,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesByStorey(AuthMgrMixin, Base): @@ -632,7 +640,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesBySpace(AuthMgrMixin, Base): @@ -676,7 +685,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) class TimeseriesByZone(AuthMgrMixin, Base): @@ -720,7 +730,8 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) def init_db_timeseries_triggers(): diff --git a/src/bemserver_core/model/weather.py b/src/bemserver_core/model/weather.py index cd198c6d..516c0013 100644 --- a/src/bemserver_core/model/weather.py +++ b/src/bemserver_core/model/weather.py @@ -68,4 +68,5 @@ def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) def authorize_read(self, actor): - return self.timeseries.authorize_read(actor) + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) From a3e9e78f23b2dbe075f380e5a21066d0c17d52fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Thu, 16 Apr 2026 15:31:41 +0200 Subject: [PATCH 18/20] Get rid of Oso --- pyproject.toml | 1 - requirements/install-min.in | 1 - requirements/install-min.txt | 6 +- requirements/install.txt | 6 +- src/bemserver_core/__init__.py | 14 - src/bemserver_core/authorization.polar | 18 - src/bemserver_core/authorization.py | 92 ---- src/bemserver_core/model/__init__.py | 5 - src/bemserver_core/model/authorization.polar | 540 ------------------- src/bemserver_core/model/campaigns.py | 54 +- src/bemserver_core/model/energy.py | 54 +- src/bemserver_core/model/events.py | 148 +---- src/bemserver_core/model/notifications.py | 21 +- src/bemserver_core/model/sites.py | 132 +---- src/bemserver_core/model/tasks.py | 15 +- src/bemserver_core/model/timeseries.py | 142 +---- src/bemserver_core/model/users.py | 53 +- src/bemserver_core/model/weather.py | 15 +- 18 files changed, 11 insertions(+), 1306 deletions(-) delete mode 100644 src/bemserver_core/authorization.polar delete mode 100644 src/bemserver_core/model/authorization.polar diff --git a/pyproject.toml b/pyproject.toml index 256a7e1b..d269a9db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "pandas>=2.2,<3.0", "pint>=0.23.0", "argon2_cffi>=23.1.0", - "oso>=0.27.3,<0.28", "alembic>=1.8.0,<2.0", "click>=8.1.3,<9.0", "celery>=5.3.1,<6.0", diff --git a/requirements/install-min.in b/requirements/install-min.in index 55d49678..b0c4f936 100644 --- a/requirements/install-min.in +++ b/requirements/install-min.in @@ -2,7 +2,6 @@ psycopg==3.1.10 sqlalchemy==2.0.8 pandas==2.2 argon2_cffi==23.1.0 -oso==0.27.3 alembic==1.8.0 click==8.1.3 celery==5.3.1 diff --git a/requirements/install-min.txt b/requirements/install-min.txt index d4a5bbfd..eb9aa53b 100644 --- a/requirements/install-min.txt +++ b/requirements/install-min.txt @@ -21,9 +21,7 @@ celery==5.3.1 certifi==2024.12.14 # via requests cffi==1.17.1 - # via - # argon2-cffi-bindings - # oso + # via argon2-cffi-bindings charset-normalizer==3.4.1 # via requests click==8.1.3 @@ -53,8 +51,6 @@ markupsafe==3.0.2 # via mako numpy==1.26.4 # via pandas -oso==0.27.3 - # via -r requirements/install-min.in packaging==24.2 # via redis pandas==2.2.0 diff --git a/requirements/install.txt b/requirements/install.txt index a25ff84b..11292211 100644 --- a/requirements/install.txt +++ b/requirements/install.txt @@ -21,9 +21,7 @@ celery==5.4.0 certifi==2024.12.14 # via requests cffi==1.17.1 - # via - # argon2-cffi-bindings - # oso + # via argon2-cffi-bindings charset-normalizer==3.4.1 # via requests click==8.1.8 @@ -55,8 +53,6 @@ markupsafe==3.0.2 # via mako numpy==2.0.2 # via pandas -oso==0.27.3 - # via bemserver-core (pyproject.toml) pandas==2.2.3 # via bemserver-core (pyproject.toml) pint==0.24.4 diff --git a/src/bemserver_core/__init__.py b/src/bemserver_core/__init__.py index e548c85c..9c03dc42 100644 --- a/src/bemserver_core/__init__.py +++ b/src/bemserver_core/__init__.py @@ -5,11 +5,9 @@ import pandas as pd from bemserver_core import ( - authorization, common, database, input_output, # noqa - model, plugins, settings, tasks, # noqa @@ -26,12 +24,6 @@ class BEMServerCore: def __init__(self): - self.auth_model_classes = model.AUTH_MODEL_CLASSES - self.auth_polar_files = [ - authorization.AUTH_POLAR_FILE, - model.AUTH_POLAR_FILE, - ] - # Load config self.config = settings.DEFAULT_CONFIG.copy() file_path = os.environ.get("BEMSERVER_CORE_SETTINGS_FILE") @@ -48,12 +40,6 @@ def __init__(self): # Set db URL database.db.set_db_url(self.config["SQLALCHEMY_DATABASE_URI"]) - # Init auth - authorization.auth.init_authorization( - self.auth_model_classes, - self.auth_polar_files, - ) - # Load unit definition files for file_path in self.config["UNIT_DEFINITION_FILES"]: common.ureg.load_definitions(file_path) diff --git a/src/bemserver_core/authorization.polar b/src/bemserver_core/authorization.polar deleted file mode 100644 index 0865e4cb..00000000 --- a/src/bemserver_core/authorization.polar +++ /dev/null @@ -1,18 +0,0 @@ -# General rule -allow(actor, action, resource) if has_permission(actor, action, resource); - -# Open bar mode -allow(_actor, _action, _resource) if OpenBarPolarClass.get(); - -# Admin can do anything -allow(user: User, _action, _resource) if user.is_admin = true; - -# User has role "user" on anything -resource Base { - roles = ["user"]; -} - -has_role(_: User, "user", _: Base); - - -actor User {} diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index 4e89043b..9df774da 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -4,10 +4,6 @@ import typing import warnings from contextvars import ContextVar -from pathlib import Path - -from oso import Oso, OsoError, Relation # noqa -from polar.data.adapter.sqlalchemy_adapter import SqlAlchemyAdapter from bemserver_core.database import db from bemserver_core.exceptions import BEMServerAuthorizationError @@ -23,9 +19,6 @@ OpenBar = functools.partial(make_context_var_manager(OPEN_BAR), True) -AUTH_POLAR_FILE = Path(__file__).parent / "authorization.polar" - - def get_current_user(): current_user = CURRENT_USER.get() if current_user is None or not current_user.is_active: @@ -40,86 +33,6 @@ def get(): return OPEN_BAR.get() -class OsoProxy: - """Oso proxy class - - Provides lazy loading of classes and authorization rules - """ - - def __init__(self, *args, **kwargs): - self.oso = None - self.oso_args = args - self.oso_kwargs = kwargs - - def __getattr__(self, attr): - return getattr(self.oso, attr) - - def init_authorization(self, model_classes, polar_files): - """Register model classes and load rules - - Must be done after model classes are imported - """ - self.oso = Oso(*self.oso_args, **self.oso_kwargs) - self.set_data_filtering_adapter(SqlAlchemyAdapter(db.session)) - - # Register classes - self.register_class(OpenBarPolarClass) - AuthMixin.register_class(name="Base") - for cls in model_classes: - cls.register_class() - - # Load authorization policy - self.load_files(polar_files) - - -auth = OsoProxy( - forbidden_error=BEMServerAuthorizationError, - not_found_error=BEMServerAuthorizationError, -) - - -class AuthMixin: - @classmethod - def register_class(cls, *args, **kwargs): - auth.register_class(cls, *args, **kwargs) - - @classmethod - def _query(cls, **kwargs): - user = get_current_user() - # TODO: Workaround for https://github.com/osohq/oso/issues/1536 - if OPEN_BAR.get() or user.is_admin: - query = db.session.query(cls) - else: - query = auth.authorized_query(user, "read", cls) - for key, val in kwargs.items(): - query = query.filter(getattr(cls, key) == val) - return query - - @classmethod - def new(cls, **kwargs): - # Override Base.new to avoid adding to the session if auth failed - item = cls(**kwargs) - auth.authorize(get_current_user(), "create", item) - db.session.add(item) - return item - - @classmethod - def get_by_id(cls, item_id, **kwargs): - item = super().get_by_id(item_id) - if item is None: - return None - auth.authorize(get_current_user(), "read", item) - return item - - def update(self, **kwargs): - auth.authorize(get_current_user(), "update", self) - super().update(**kwargs) - - def delete(self): - auth.authorize(get_current_user(), "delete", self) - super().delete() - - class AuthUndefinedActionError(Exception): """""" @@ -166,11 +79,6 @@ def authorize_query(self, model_cls, query): class AuthMgrMixin: - # TODO: remove - @classmethod - def register_class(cls, *args, **kwargs): - auth.register_class(cls, *args, **kwargs) - @classmethod def _query(cls, **kwargs): query = db.session.query(cls) diff --git a/src/bemserver_core/model/__init__.py b/src/bemserver_core/model/__init__.py index e759440d..26dfdcb3 100644 --- a/src/bemserver_core/model/__init__.py +++ b/src/bemserver_core/model/__init__.py @@ -1,7 +1,5 @@ """Model""" -from pathlib import Path - from .campaigns import ( Campaign, CampaignScope, @@ -181,6 +179,3 @@ EnergyProductionTimeseriesByBuilding, WeatherTimeseriesBySite, ] - - -AUTH_POLAR_FILE = Path(__file__).parent / "authorization.polar" diff --git a/src/bemserver_core/model/authorization.polar b/src/bemserver_core/model/authorization.polar deleted file mode 100644 index 3074407c..00000000 --- a/src/bemserver_core/model/authorization.polar +++ /dev/null @@ -1,540 +0,0 @@ -resource User { - permissions = [ - "create", "read", "update", "delete", - "set_admin", "set_active", - "count_notifications", "mark_notifications", - ]; - roles = ["self"]; - - "read" if "self"; - "update" if "self"; - "count_notifications" if "self"; - "mark_notifications" if "self"; -} - -has_role(_user: User{id: id}, "self", _user: User{id: id}); - - -resource UserGroup { - permissions = ["create", "read", "update", "delete"]; - roles = ["member"]; - - "read" if "member"; -} - -has_role(user: User, "member", ug: UserGroup) if - ubug in user.users_by_user_groups and - ubug.user_group = ug; - - -resource UserByUserGroup { - permissions = ["create", "read", "update", "delete"]; - roles = ["owner"]; - - "read" if "owner"; -} - -has_role(user: User, "owner", ubug: UserByUserGroup) if - user = ubug.user; - - -resource Campaign { - permissions = ["create", "read", "update", "delete"]; - roles = ["member"]; - - "read" if "member"; -} - -has_role(user: User, "member", campaign: Campaign) if - ugbc in campaign.user_groups_by_campaigns and - ubug in user.users_by_user_groups and - ubug.user_group = ugbc.user_group; - - -resource UserGroupByCampaign { - permissions = ["create", "read", "update", "delete"]; - roles = ["owner"]; - - "read" if "owner"; -} - -has_role(user: User, "owner", ugbc: UserGroupByCampaign) if - has_role(user, "member", ugbc.user_group); - - -resource CampaignScope { - permissions = ["create", "read", "update", "delete"]; - roles = ["member"]; - - "read" if "member"; -} - -has_role(user: User, "member", cs: CampaignScope) if - ugbcs in cs.user_groups_by_campaign_scopes and - ubug in user.users_by_user_groups and - ubug.user_group = ugbcs.user_group; - - -resource UserGroupByCampaignScope { - permissions = ["create", "read", "update", "delete"]; - roles = ["owner"]; - - "read" if "owner"; -} - -has_role(user: User, "owner", ugbcs: UserGroupByCampaignScope) if - has_role(user, "member", ugbcs.user_group); - - -resource TimeseriesProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource TimeseriesDataState{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource Timeseries { - permissions = ["create", "read", "update", "delete", "read_data", "write_data"]; - relations = { - campaign_scope: CampaignScope - }; - - "read" if "member" on "campaign_scope"; - "read_data" if "member" on "campaign_scope"; - "write_data" if "member" on "campaign_scope"; -} - -has_relation(cs: CampaignScope, "campaign_scope", ts: Timeseries) if - cs = ts.campaign_scope; - - -resource TimeseriesPropertyData { - permissions = ["create", "read", "update", "delete"]; - relations = { - timeseries: Timeseries - }; - - "read" if "read" on "timeseries"; -} - -has_relation(ts: Timeseries, "timeseries", tspd: TimeseriesPropertyData) if - ts = tspd.timeseries; - - -resource TimeseriesByDataState { - permissions = ["create", "read", "update", "delete"]; - relations = { - timeseries: Timeseries - }; - - "create" if "read" on "timeseries"; - "read" if "read" on "timeseries"; - "update" if "read" on "timeseries"; - "delete" if "read" on "timeseries"; -} - -has_relation(ts: Timeseries, "timeseries", tsbds: TimeseriesByDataState) if - ts = tsbds.timeseries; - - -resource EventCategory{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource Event { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - campaign_scope: CampaignScope - }; - - "reader" if "member" on "campaign_scope"; - "writer" if "member" on "campaign_scope"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(cs: CampaignScope, "campaign_scope", event: Event) if - cs = event.campaign_scope; - - -resource EventCategoryByUser { - permissions = ["create", "read", "update", "delete"]; - roles = ["owner"]; - - "create" if "owner"; - "read" if "owner"; - "update" if "owner"; - "delete" if "owner"; -} -has_role(user: User, "owner", ecbu: EventCategoryByUser ) if - user = ecbu.user; - - -# Application code ensures event and timeseries are in the same campaign scope. -# So we only check event. -# Checking event and timeseries would trigger an Oso error: -# "Type `CampaignScope` occurs more than once as the target of a relation" -resource TimeseriesByEvent { - permissions = ["create", "read", "update", "delete"]; - relations = { - event: Event - }; - - "read" if "read" on "event"; - "create" if "writer" on "event"; - "update" if "writer" on "event"; - "delete" if "writer" on "event"; -} -has_relation(event: Event, "event", tbs: TimeseriesByEvent) if - event = tbs.event; - - -# Application code ensures event and site are in the same campaign scope. -# So we only check event. -resource EventBySite { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - event: Event - }; - - "reader" if "reader" on "event"; - "writer" if "writer" on "event"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(event: Event, "event", ebs: EventBySite) if - event = ebs.event; - - -# Application code ensures event and building are in the same campaign scope. -# So we only check event. -resource EventByBuilding { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - event: Event - }; - - "reader" if "reader" on "event"; - "writer" if "writer" on "event"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(event: Event, "event", ebb: EventByBuilding) if - event = ebb.event; - - -# Application code ensures event and storey are in the same campaign scope. -# So we only check event. -resource EventByStorey { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - event: Event - }; - - "reader" if "reader" on "event"; - "writer" if "writer" on "event"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(event: Event, "event", ebs: EventByStorey) if - event = ebs.event; - - -# Application code ensures event and space are in the same campaign scope. -# So we only check event. -resource EventBySpace { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - event: Event - }; - - "reader" if "reader" on "event"; - "writer" if "writer" on "event"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(event: Event, "event", ebs: EventBySpace) if - event = ebs.event; - - -# Application code ensures event and zone are in the same campaign scope. -# So we only check event. -resource EventByZone { - permissions = ["create", "read", "update", "delete"]; - roles = ["reader", "writer"]; - relations = { - event: Event - }; - - "reader" if "reader" on "event"; - "writer" if "writer" on "event"; - - "read" if "reader"; - "create" if "writer"; - "update" if "writer"; - "delete" if "writer"; -} -has_relation(event: Event, "event", ebz: EventByZone) if - event = ebz.event; - - -resource Notification{ - permissions = ["create", "read", "update", "delete"]; - roles = ["owner"]; - - "read" if "owner"; - "update" if "owner"; -} -has_role(user: User, "owner", notif: Notification) if - user = notif.user; - - -resource StructuralElementProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource SiteProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource BuildingProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource StoreyProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource SpaceProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource ZoneProperty{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - - -resource Site { - permissions = ["create", "read", "update", "delete", "get_weather_data"]; -} -has_permission(user: User, "read", site:Site) if - has_role(user, "member", site.campaign); - -resource Building { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", building:Building) if - has_permission(user, "read", building.site); - -resource Storey { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", storey:Storey) if - has_permission(user, "read", storey.building); - -resource Space{ - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", space:Space) if - has_permission(user, "read", space.storey); - -resource Zone { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", zone:Zone) if - has_role(user, "member", zone.campaign); - - -# TODO: Oso issue: checking both site and timeseries involves user x group -# twice and triggers an error in Oso when building the query: -# "Type `UserGroup` occurs more than once as the target of a relation" -# Fortunately, in our case, checking only timeseries is enough because users -# having access to the timeseries "should" be part of the campaign and -# therefore should have access to the site. - -resource TimeseriesBySite { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", tbs:TimeseriesBySite) if - #has_permission(user, "read", tbs.site) and - has_permission(user, "read", tbs.timeseries); - -resource TimeseriesByBuilding { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", tbb:TimeseriesByBuilding) if - #has_permission(user, "read", tbb.building) and - has_permission(user, "read", tbb.timeseries); - -resource TimeseriesByStorey { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", tbs:TimeseriesByStorey) if - #has_permission(user, "read", tbs.storey) and - has_permission(user, "read", tbs.timeseries); - -resource TimeseriesBySpace { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", tbs:TimeseriesBySpace) if - #has_permission(user, "read", tbs.space) and - has_permission(user, "read", tbs.timeseries); - -resource TimeseriesByZone { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", tbz:TimeseriesByZone) if - #has_permission(user, "read", tbz.zone) and - has_permission(user, "read", tbz.timeseries); - - -resource SitePropertyData { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", spd:SitePropertyData) if - has_permission(user, "read", spd.site); - -resource BuildingPropertyData { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", bpd:BuildingPropertyData) if - has_permission(user, "read", bpd.building); - -resource StoreyPropertyData { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", spd:StoreyPropertyData) if - has_permission(user, "read", spd.storey); - -resource SpacePropertyData { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", spd:SpacePropertyData) if - has_permission(user, "read", spd.space); - -resource ZonePropertyData { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", zpd:ZonePropertyData) if - has_permission(user, "read", zpd.zone); - - -resource Energy{ - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - -resource EnergyEndUse { - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - -resource EnergyProductionTechnology { - permissions = ["create", "read", "update", "delete"]; - roles = ["user"]; - - "read" if "user"; -} - -resource EnergyConsumptionTimeseriesBySite { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", ecbs: EnergyConsumptionTimeseriesBySite) if - has_permission(user, "read_data", ecbs.timeseries); - -resource EnergyConsumptionTimeseriesByBuilding { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", ecbb: EnergyConsumptionTimeseriesByBuilding) if - has_permission(user, "read_data", ecbb.timeseries); - -resource EnergyProductionTimeseriesBySite { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", epbs: EnergyProductionTimeseriesBySite) if - has_permission(user, "read_data", epbs.timeseries); - -resource EnergyProductionTimeseriesByBuilding { - permissions = ["create", "read", "update", "delete"]; -} -has_permission(user: User, "read", epbb: EnergyProductionTimeseriesByBuilding) if - has_permission(user, "read_data", epbb.timeseries); - - -resource WeatherTimeseriesBySite { - permissions = ["create", "read", "update", "delete"]; - relations = { - timeseries: Timeseries - }; - - "read" if "read" on "timeseries"; -} -has_relation(ts: Timeseries, "timeseries", wtsbd: WeatherTimeseriesBySite) if - ts = wtsbd.timeseries; - - -resource TaskByCampaign { - permissions = ["create", "read", "update", "delete"]; -} - -has_permission(user: User, "read", tbc: TaskByCampaign ) if - has_role(user, "member", tbc.campaign); diff --git a/src/bemserver_core/model/campaigns.py b/src/bemserver_core/model/campaigns.py index 4eca9920..877d7577 100644 --- a/src/bemserver_core/model/campaigns.py +++ b/src/bemserver_core/model/campaigns.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.database import Base, db, make_columns_read_only from .users import UserByUserGroup, UserGroup @@ -18,19 +18,6 @@ class Campaign(AuthMgrMixin, Base): end_time = sqla.Column(sqla.DateTime(timezone=True)) timezone = sqla.Column(sqla.String(40), nullable=False, default="UTC") - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user_groups_by_campaigns": Relation( - kind="many", - other_type="UserGroupByCampaign", - my_field="id", - other_field="campaign_id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return UserGroupByCampaign.authorize_query( @@ -68,19 +55,6 @@ class CampaignScope(AuthMgrMixin, Base): backref=sqla.orm.backref("campaign_scopes", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user_groups_by_campaign_scopes": Relation( - kind="many", - other_type="UserGroupByCampaignScope", - my_field="id", - other_field="campaign_scope_id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return UserGroupByCampaignScope.authorize_query( @@ -127,19 +101,6 @@ class UserGroupByCampaign(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user_group": Relation( - kind="one", - other_type="UserGroup", - my_field="user_group_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return UserGroup.authorize_query(actor, query.join(UserGroup)) @@ -176,19 +137,6 @@ class UserGroupByCampaignScope(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user_group": Relation( - kind="one", - other_type="UserGroup", - my_field="user_group_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return UserGroup.authorize_query(actor, query.join(UserGroup)) diff --git a/src/bemserver_core/model/energy.py b/src/bemserver_core/model/energy.py index 6d8ead3a..2989c66b 100644 --- a/src/bemserver_core/model/energy.py +++ b/src/bemserver_core/model/energy.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.database import Base, db from .timeseries import Timeseries @@ -73,19 +73,6 @@ class EnergyConsumptionTimeseriesBySite(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -130,19 +117,6 @@ class EnergyConsumptionTimeseriesByBuilding(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -187,19 +161,6 @@ class EnergyProductionTimeseriesBySite(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -246,19 +207,6 @@ class EnergyProductionTimeseriesByBuilding(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) diff --git a/src/bemserver_core/model/events.py b/src/bemserver_core/model/events.py index 8c0bc109..99e22353 100644 --- a/src/bemserver_core/model/events.py +++ b/src/bemserver_core/model/events.py @@ -6,7 +6,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.celery import BEMServerCoreSystemTask, celery, logger from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.exceptions import ( @@ -72,19 +72,6 @@ class Event(AuthMgrMixin, Base): "CampaignScope", backref=sqla.orm.backref("sites", cascade="all, delete-orphan") ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "campaign_scope": Relation( - kind="one", - other_type="CampaignScope", - my_field="campaign_scope_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return CampaignScope.authorize_query(actor, query.join(CampaignScope)) @@ -374,25 +361,6 @@ class EventCategoryByUser(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user": Relation( - kind="one", - other_type="User", - my_field="user_id", - other_field="id", - ), - "event_category": Relation( - kind="one", - other_type="EventCategory", - my_field="category_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return query.filter(EventCategoryByUser.user_id == actor.id) @@ -439,25 +407,6 @@ def _before_flush(self): "Event and timeseries must be in same campaign scope" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( @@ -511,25 +460,6 @@ def _before_flush(self): "Event and site must be in same campaign" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - "site": Relation( - kind="one", - other_type="Site", - my_field="site_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( @@ -583,25 +513,6 @@ def _before_flush(self): "Event and building must be in same campaign" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - "building": Relation( - kind="one", - other_type="Building", - my_field="building_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( @@ -655,25 +566,6 @@ def _before_flush(self): "Event and storey must be in same campaign" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - "storey": Relation( - kind="one", - other_type="Storey", - my_field="storey_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( @@ -727,25 +619,6 @@ def _before_flush(self): "Event and space must be in same campaign" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - "space": Relation( - kind="one", - other_type="Space", - my_field="space_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( @@ -799,25 +672,6 @@ def _before_flush(self): "Event and zone must be in same campaign" ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - "zone": Relation( - kind="one", - other_type="Zone", - my_field="zone_id", - other_field="id", - ), - }, - ) - @property def campaign_scope(self): return ( diff --git a/src/bemserver_core/model/notifications.py b/src/bemserver_core/model/notifications.py index 9f835735..9c849db0 100644 --- a/src/bemserver_core/model/notifications.py +++ b/src/bemserver_core/model/notifications.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr +from bemserver_core.authorization import AuthMgrMixin, auth_mgr from bemserver_core.celery import BEMServerCoreSystemTask, celery, logger from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.email import ems @@ -31,25 +31,6 @@ class Notification(AuthMgrMixin, Base): "Event", backref=sqla.orm.backref("notifs", cascade="all, delete-orphan") ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user": Relation( - kind="one", - other_type="User", - my_field="user_id", - other_field="id", - ), - "event": Relation( - kind="one", - other_type="Event", - my_field="event_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return query.filter(Notification.user_id == actor.id) diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index b0659ef0..7fb600bf 100644 --- a/src/bemserver_core/model/sites.py +++ b/src/bemserver_core/model/sites.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr +from bemserver_core.authorization import AuthMgrMixin, auth_mgr from bemserver_core.common import PropertyType from bemserver_core.database import Base, db, make_columns_read_only @@ -142,19 +142,6 @@ class SitePropertyData(AuthMgrMixin, Base): backref=sqla.orm.backref("site_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "site": Relation( - kind="one", - other_type="Site", - my_field="site_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := SiteProperty.get_by_id(self.site_property_id)) is not None: @@ -195,19 +182,6 @@ class BuildingPropertyData(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "building": Relation( - kind="one", - other_type="Building", - my_field="building_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := BuildingProperty.get_by_id(self.building_property_id)) is not None: @@ -248,19 +222,6 @@ class StoreyPropertyData(AuthMgrMixin, Base): backref=sqla.orm.backref("storey_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "storey": Relation( - kind="one", - other_type="Storey", - my_field="storey_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := StoreyProperty.get_by_id(self.storey_property_id)) is not None: @@ -301,19 +262,6 @@ class SpacePropertyData(AuthMgrMixin, Base): backref=sqla.orm.backref("space_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "space": Relation( - kind="one", - other_type="Space", - my_field="space_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := SpaceProperty.get_by_id(self.space_property_id)) is not None: @@ -355,19 +303,6 @@ class ZonePropertyData(AuthMgrMixin, Base): backref=sqla.orm.backref("zone_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "zone": Relation( - kind="one", - other_type="Zone", - my_field="zone_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := ZoneProperty.get_by_id(self.zone_property_id)) is not None: @@ -449,19 +384,6 @@ class Site(AuthMgrMixin, StructuralElementBase): "Campaign", backref=sqla.orm.backref("sites", cascade="all, delete-orphan") ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) @@ -503,19 +425,6 @@ def get(cls, *, campaign_id=None, **kwargs): ) return query - @classmethod - def register_class(cls): - super().register_class( - fields={ - "site": Relation( - kind="one", - other_type="Site", - my_field="site_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Site.authorize_query(actor, query.join(Site)) @@ -561,19 +470,6 @@ def get(cls, *, campaign_id=None, site_id=None, **kwargs): ) return query - @classmethod - def register_class(cls): - super().register_class( - fields={ - "building": Relation( - kind="one", - other_type="Building", - my_field="building_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Building.authorize_query(actor, query.join(Building)) @@ -634,19 +530,6 @@ def get(cls, *, campaign_id=None, site_id=None, building_id=None, **kwargs): ) return query - @classmethod - def register_class(cls): - super().register_class( - fields={ - "storey": Relation( - kind="one", - other_type="Storey", - my_field="storey_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Storey.authorize_query(actor, query.join(Storey)) @@ -677,19 +560,6 @@ class Zone(AuthMgrMixin, StructuralElementBase): "Campaign", backref=sqla.orm.backref("zones", cascade="all, delete-orphan") ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) diff --git a/src/bemserver_core/model/tasks.py b/src/bemserver_core/model/tasks.py index ddf7162c..afe6d2c7 100644 --- a/src/bemserver_core/model/tasks.py +++ b/src/bemserver_core/model/tasks.py @@ -6,7 +6,7 @@ import sqlalchemy as sqla from sqlalchemy.dialects.postgresql import JSONB -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.celery import logger from bemserver_core.database import Base from bemserver_core.time_utils import PeriodEnum, make_date_offset @@ -33,19 +33,6 @@ class TaskByCampaign(AuthMgrMixin, Base): start_offset = sqla.Column(sqla.Integer, default=-1, nullable=False) end_offset = sqla.Column(sqla.Integer, default=0, nullable=False) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Campaign.authorize_query(actor, query.join(Campaign)) diff --git a/src/bemserver_core/model/timeseries.py b/src/bemserver_core/model/timeseries.py index 19d12e51..ec59b1b7 100644 --- a/src/bemserver_core/model/timeseries.py +++ b/src/bemserver_core/model/timeseries.py @@ -2,7 +2,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr +from bemserver_core.authorization import AuthMgrMixin, auth_mgr from bemserver_core.common import PropertyType, ureg from bemserver_core.database import Base, db, make_columns_read_only from bemserver_core.exceptions import TimeseriesNotFoundError @@ -62,25 +62,6 @@ class Timeseries(AuthMgrMixin, Base): backref=sqla.orm.backref("timeseries", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - "campaign_scope": Relation( - kind="one", - other_type="CampaignScope", - my_field="campaign_scope_id", - other_field="id", - ), - }, - ) - def _before_flush(self): if self.unit_symbol is not None: ureg.validate_unit(self.unit_symbol) @@ -423,19 +404,6 @@ class TimeseriesPropertyData(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - def _before_flush(self): # Get property type and try to parse value to ensure its type validity. if (prop := TimeseriesProperty.get_by_id(self.property_id)) is not None: @@ -471,19 +439,6 @@ class TimeseriesByDataState(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -522,25 +477,6 @@ class TimeseriesBySite(AuthMgrMixin, Base): backref=sqla.orm.backref("timeseries_by_sites", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "site": Relation( - kind="one", - other_type="Site", - my_field="site_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -571,25 +507,6 @@ class TimeseriesByBuilding(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "building": Relation( - kind="one", - other_type="Building", - my_field="building_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -616,25 +533,6 @@ class TimeseriesByStorey(AuthMgrMixin, Base): backref=sqla.orm.backref("timeseries_by_storeys", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "storey": Relation( - kind="one", - other_type="Storey", - my_field="storey_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -661,25 +559,6 @@ class TimeseriesBySpace(AuthMgrMixin, Base): backref=sqla.orm.backref("timeseries_by_spaces", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "space": Relation( - kind="one", - other_type="Space", - my_field="space_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) @@ -706,25 +585,6 @@ class TimeseriesByZone(AuthMgrMixin, Base): backref=sqla.orm.backref("timeseries_by_zones", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - "zone": Relation( - kind="one", - other_type="Zone", - my_field="zone_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) diff --git a/src/bemserver_core/model/users.py b/src/bemserver_core/model/users.py index 8e916946..e48a24df 100644 --- a/src/bemserver_core/model/users.py +++ b/src/bemserver_core/model/users.py @@ -5,7 +5,7 @@ import argon2 -from bemserver_core.authorization import AuthMgrMixin, Relation, auth_mgr +from bemserver_core.authorization import AuthMgrMixin, auth_mgr from bemserver_core.database import Base, db ph = argon2.PasswordHasher() @@ -21,19 +21,6 @@ class User(AuthMgrMixin, Base): _is_admin = sqla.Column(sqla.Boolean(), nullable=False) _is_active = sqla.Column(sqla.Boolean(), nullable=False) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "users_by_user_groups": Relation( - kind="many", - other_type="UserByUserGroup", - my_field="id", - other_field="user_id", - ), - }, - ) - def __repr__(self): return ( f", " @@ -100,25 +87,6 @@ class UserGroup(AuthMgrMixin, Base): id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user_groups_by_campaigns": Relation( - kind="many", - other_type="UserGroupByCampaign", - my_field="id", - other_field="user_group_id", - ), - "users_by_user_groups": Relation( - kind="many", - other_type="UserByUserGroup", - my_field="id", - other_field="user_group_id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return UserByUserGroup.authorize_query(actor, query.join(UserByUserGroup)) @@ -153,25 +121,6 @@ class UserByUserGroup(AuthMgrMixin, Base): backref=sqla.orm.backref("users_by_user_groups", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "user": Relation( - kind="one", - other_type="User", - my_field="user_id", - other_field="id", - ), - "user_group": Relation( - kind="one", - other_type="UserGroup", - my_field="user_group_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return query.filter(UserByUserGroup.user_id == actor.id) diff --git a/src/bemserver_core/model/weather.py b/src/bemserver_core/model/weather.py index 516c0013..adb9e52f 100644 --- a/src/bemserver_core/model/weather.py +++ b/src/bemserver_core/model/weather.py @@ -4,7 +4,7 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin, Relation +from bemserver_core.authorization import AuthMgrMixin from bemserver_core.database import Base from .timeseries import Timeseries @@ -50,19 +50,6 @@ class WeatherTimeseriesBySite(AuthMgrMixin, Base): ), ) - @classmethod - def register_class(cls): - super().register_class( - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - @classmethod def authorize_query(cls, actor, query): return Timeseries.authorize_query(actor, query.join(Timeseries)) From ce810f7445d11de869f81d3a6c64b4179331d72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Thu, 16 Apr 2026 15:47:22 +0200 Subject: [PATCH 19/20] Define BEMServerAuthorizationUndefinedActionError --- src/bemserver_core/authorization.py | 13 +++++++------ src/bemserver_core/exceptions.py | 4 ++++ tests/test_authorization.py | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/bemserver_core/authorization.py b/src/bemserver_core/authorization.py index 9df774da..9d8f684d 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -6,7 +6,10 @@ from contextvars import ContextVar from bemserver_core.database import db -from bemserver_core.exceptions import BEMServerAuthorizationError +from bemserver_core.exceptions import ( + BEMServerAuthorizationError, + BEMServerAuthorizationUndefinedActionError, +) from bemserver_core.utils import make_context_var_manager if typing.TYPE_CHECKING: @@ -33,10 +36,6 @@ def get(): return OPEN_BAR.get() -class AuthUndefinedActionError(Exception): - """""" - - class AuthorizationsManager: def __init__(self) -> None: self._rules: dict = {} @@ -58,7 +57,9 @@ def eval_rule(self, action: str, actor: "User", item: any): try: rule = self._rules[action] except KeyError as exc: - raise AuthUndefinedActionError(f"Undefined action: {action}") from exc + raise BEMServerAuthorizationUndefinedActionError( + f"Undefined action: {action}" + ) from exc return rule(actor, item) def authorize(self, action: str, item: any) -> bool: diff --git a/src/bemserver_core/exceptions.py b/src/bemserver_core/exceptions.py index d4180a61..2675643f 100644 --- a/src/bemserver_core/exceptions.py +++ b/src/bemserver_core/exceptions.py @@ -89,6 +89,10 @@ class BEMServerAuthorizationError(BEMServerCoreIOError): """Operation not autorized to current user""" +class BEMServerAuthorizationUndefinedActionError(BEMServerCoreIOError): + """Action undefined""" + + class PropertyTypeInvalidError(BEMServerCoreError): """Invalid property value type: cast error""" diff --git a/tests/test_authorization.py b/tests/test_authorization.py index cdceb00f..a3d6ca09 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -6,8 +6,23 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMgrMixin +from bemserver_core.authorization import AuthMgrMixin, AuthorizationsManager from bemserver_core.database import Base, db +from bemserver_core.exceptions import BEMServerAuthorizationUndefinedActionError + + +class TestAuthorizationsManager: + def test_auth_mgr_eval_rule(self): + auth_mgr = AuthorizationsManager() + + @auth_mgr.add_rule("test") + def test(actor, item): + return True + + assert auth_mgr.eval_rule("test", None, None) is True + + with pytest.raises(BEMServerAuthorizationUndefinedActionError): + auth_mgr.eval_rule("dummy", None, None) class TestAuthMgrMixin: From 292087c48832060a4afc7a62dd9f4acf26672bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Lafr=C3=A9choux?= Date: Fri, 17 Apr 2026 10:01:13 +0200 Subject: [PATCH 20/20] Tests: add missing tests for site/building/..._property_data auth --- tests/model/test_sites.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/model/test_sites.py b/tests/model/test_sites.py index 22fcbaa5..bcc607ba 100644 --- a/tests/model/test_sites.py +++ b/tests/model/test_sites.py @@ -1171,7 +1171,7 @@ def test_site_property_data_authorizations_as_user( with CurrentUser(user_1): SitePropertyData.get_by_id(spd_2.id) - + assert set(SitePropertyData.get()) == set(site_property_data[1:]) with pytest.raises(BEMServerAuthorizationError): SitePropertyData.get_by_id(spd_1.id) with pytest.raises(BEMServerAuthorizationError): @@ -1359,7 +1359,7 @@ def test_building_property_data_authorizations_as_user( with CurrentUser(user_1): BuildingPropertyData.get_by_id(bpd_2.id) - + assert set(BuildingPropertyData.get()) == set(building_property_data[1:]) with pytest.raises(BEMServerAuthorizationError): BuildingPropertyData.get_by_id(bpd_1.id) with pytest.raises(BEMServerAuthorizationError): @@ -1553,7 +1553,7 @@ def test_storey_property_data_authorizations_as_user( with CurrentUser(user_1): StoreyPropertyData.get_by_id(spd_2.id) - + assert set(StoreyPropertyData.get()) == set(storey_property_data[1:]) with pytest.raises(BEMServerAuthorizationError): StoreyPropertyData.get_by_id(spd_1.id) with pytest.raises(BEMServerAuthorizationError): @@ -1745,7 +1745,7 @@ def test_space_property_data_authorizations_as_user( with CurrentUser(user_1): SpacePropertyData.get_by_id(spd_2.id) - + assert set(SpacePropertyData.get()) == set(space_property_data[1:]) with pytest.raises(BEMServerAuthorizationError): SpacePropertyData.get_by_id(spd_1.id) with pytest.raises(BEMServerAuthorizationError): @@ -1935,7 +1935,7 @@ def test_zone_property_data_authorizations_as_user( with CurrentUser(user_1): ZonePropertyData.get_by_id(zpd_2.id) - + assert set(ZonePropertyData.get()) == set(zone_property_data[1:]) with pytest.raises(BEMServerAuthorizationError): ZonePropertyData.get_by_id(zpd_1.id) with pytest.raises(BEMServerAuthorizationError):