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 f46d59f0..9d8f684d 100644 --- a/src/bemserver_core/authorization.py +++ b/src/bemserver_core/authorization.py @@ -1,16 +1,20 @@ """Authorization""" import functools +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 +from bemserver_core.exceptions import ( + BEMServerAuthorizationError, + BEMServerAuthorizationUndefinedActionError, +) 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) @@ -18,9 +22,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: @@ -35,66 +36,80 @@ 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) - +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 BEMServerAuthorizationUndefinedActionError( + f"Undefined action: {action}" + ) from exc + return rule(actor, item) + + def authorize(self, action: str, item: any) -> bool: + actor = get_current_user() + 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 = OsoProxy( - forbidden_error=BEMServerAuthorizationError, - not_found_error=BEMServerAuthorizationError, -) +auth_mgr: AuthorizationsManager = AuthorizationsManager() -class AuthMixin: - @classmethod - def register_class(cls, *args, **kwargs): - auth.register_class(cls, *args, **kwargs) +class AuthMgrMixin: @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) + 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.authorize(get_current_user(), "create", item) + auth_mgr.authorize("create", item) db.session.add(item) return item @@ -103,13 +118,33 @@ 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) + auth_mgr.authorize("read", item) return item def update(self, **kwargs): - auth.authorize(get_current_user(), "update", self) + auth_mgr.authorize("update", self) super().update(**kwargs) def delete(self): - auth.authorize(get_current_user(), "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/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/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/__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 95421d25..877d7577 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 +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) @@ -17,21 +19,29 @@ class Campaign(AuthMixin, Base): timezone = sqla.Column(sqla.String(40), nullable=False, default="UTC") @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "user_groups_by_campaigns": Relation( - kind="many", - other_type="UserGroupByCampaign", - my_field="id", - other_field="campaign_id", - ), - }, + def authorize_query(cls, actor, query): + return UserGroupByCampaign.authorize_query( + actor, + query.join(UserGroupByCampaign), + ) + + def authorize_read(self, actor): + 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(AuthMixin, Base): +class CampaignScope(AuthMgrMixin, Base): __tablename__ = "c_scopes" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -46,21 +56,29 @@ class CampaignScope(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "user_groups_by_campaign_scopes": Relation( - kind="many", - other_type="UserGroupByCampaignScope", - my_field="id", - other_field="campaign_scope_id", - ), - }, + def authorize_query(cls, actor, query): + return UserGroupByCampaignScope.authorize_query( + actor, + query.join(UserGroupByCampaignScope), + ) + + def authorize_read(self, actor): + 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(AuthMixin, Base): +class UserGroupByCampaign(AuthMgrMixin, Base): """UserGroup x Campaign associations""" __tablename__ = "u_groups_by_campaigns" @@ -84,21 +102,19 @@ class UserGroupByCampaign(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "user_group": Relation( - kind="one", - other_type="UserGroup", - my_field="user_group_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return UserGroup.authorize_query(actor, query.join(UserGroup)) + 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" @@ -122,18 +138,16 @@ class UserGroupByCampaignScope(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "user_group": Relation( - kind="one", - other_type="UserGroup", - my_field="user_group_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return UserGroup.authorize_query(actor, query.join(UserGroup)) + + 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(): diff --git a/src/bemserver_core/model/energy.py b/src/bemserver_core/model/energy.py index 08b3c6b7..2989c66b 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 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"),) @@ -63,21 +74,15 @@ class EnergyConsumptionTimeseriesBySite(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - - -class EnergyConsumptionTimeseriesByBuilding(AuthMixin, Base): + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + + +class EnergyConsumptionTimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ener_cons_ts_by_building" __table_args__ = (sqla.UniqueConstraint("building_id", "energy_id", "end_use_id"),) @@ -113,21 +118,15 @@ class EnergyConsumptionTimeseriesByBuilding(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - - -class EnergyProductionTimeseriesBySite(AuthMixin, Base): + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + + +class EnergyProductionTimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "ener_prod_ts_by_site" __table_args__ = (sqla.UniqueConstraint("site_id", "energy_id", "prod_tech_id"),) @@ -163,21 +162,15 @@ class EnergyProductionTimeseriesBySite(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) - - -class EnergyProductionTimeseriesByBuilding(AuthMixin, Base): + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + + +class EnergyProductionTimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ener_prod_ts_by_building" __table_args__ = ( sqla.UniqueConstraint("building_id", "energy_id", "prod_tech_id"), @@ -215,18 +208,12 @@ class EnergyProductionTimeseriesByBuilding(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, 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 a3d7b7c5..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 AuthMixin, Relation, auth +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 ( @@ -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) @@ -70,18 +73,24 @@ class Event(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "campaign_scope": Relation( - kind="one", - other_type="CampaignScope", - my_field="campaign_scope_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return CampaignScope.authorize_query(actor, query.join(CampaignScope)) + + def authorize_create(self, actor): + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) + + def authorize_read(self, actor): + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) + + def authorize_update(self, actor): + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) + + def authorize_delete(self, actor): + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) @classmethod def get( @@ -328,7 +337,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" @@ -353,27 +362,23 @@ class EventCategoryByUser(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + 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 -class TimeseriesByEvent(AuthMixin, Base): + 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(AuthMgrMixin, Base): __tablename__ = "ts_by_events" __table_args__ = (sqla.UniqueConstraint("event_id", "timeseries_id"),) @@ -402,28 +407,33 @@ def _before_flush(self): "Event and timeseries must be in same campaign scope" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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.campaign_scope.is_member(actor) -class EventBySite(AuthMixin, Base): + 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) + + +class EventBySite(AuthMgrMixin, Base): __tablename__ = "events_by_sites" __table_args__ = (sqla.UniqueConstraint("site_id", "event_id"),) @@ -450,28 +460,33 @@ def _before_flush(self): "Event and site must be in same campaign" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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.campaign_scope.is_member(actor) -class EventByBuilding(AuthMixin, Base): + 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) + + +class EventByBuilding(AuthMgrMixin, Base): __tablename__ = "events_by_buildings" __table_args__ = (sqla.UniqueConstraint("building_id", "event_id"),) @@ -498,28 +513,33 @@ def _before_flush(self): "Event and building must be in same campaign" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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)) -class EventByStorey(AuthMixin, Base): + 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) + + +class EventByStorey(AuthMgrMixin, Base): __tablename__ = "events_by_storeys" __table_args__ = (sqla.UniqueConstraint("storey_id", "event_id"),) @@ -546,28 +566,33 @@ def _before_flush(self): "Event and storey must be in same campaign" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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)) -class EventBySpace(AuthMixin, Base): + 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) + + +class EventBySpace(AuthMgrMixin, Base): __tablename__ = "events_by_spaces" __table_args__ = (sqla.UniqueConstraint("space_id", "event_id"),) @@ -594,28 +619,33 @@ def _before_flush(self): "Event and space must be in same campaign" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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.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) -class EventByZone(AuthMixin, Base): + def authorize_delete(self, actor): + return self.campaign_scope.is_member(actor) + + +class EventByZone(AuthMgrMixin, Base): __tablename__ = "events_by_zones" __table_args__ = (sqla.UniqueConstraint("zone_id", "event_id"),) @@ -642,26 +672,31 @@ def _before_flush(self): "Event and zone must be in same campaign" ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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 ( + 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.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) + def init_db_events_triggers(): """Create triggers to protect some columns from update. diff --git a/src/bemserver_core/model/notifications.py b/src/bemserver_core/model/notifications.py index b663fe85..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 AuthMixin, Relation, auth, get_current_user +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 @@ -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) @@ -32,24 +32,14 @@ class Notification(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + 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): @@ -74,7 +64,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 +104,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 +121,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 diff --git a/src/bemserver_core/model/sites.py b/src/bemserver_core/model/sites.py index 5781a95f..7fb600bf 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, auth_mgr 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 +from .users import User -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"),) @@ -122,27 +142,23 @@ class SitePropertyData(AuthMixin, Base): backref=sqla.orm.backref("site_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return Site.authorize_query(actor, query.join(Site)) -class BuildingPropertyData(AuthMixin, Base): + def authorize_read(self, actor): + campaign = ( + db.session.query(Campaign).join(Site).filter(Site.id == self.site_id).one() + ) + return campaign.is_member(actor) + + +class BuildingPropertyData(AuthMgrMixin, Base): __tablename__ = "building_prop_data" __table_args__ = (sqla.UniqueConstraint("building_id", "building_prop_id"),) @@ -166,27 +182,27 @@ class BuildingPropertyData(AuthMixin, Base): ), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return Building.authorize_query(actor, query.join(Building)) + + def authorize_read(self, actor): + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .filter(Building.id == self.building_id) + .one() + ) + return campaign.is_member(actor) -class StoreyPropertyData(AuthMixin, Base): + +class StoreyPropertyData(AuthMgrMixin, Base): __tablename__ = "storey_prop_data" __table_args__ = (sqla.UniqueConstraint("storey_id", "storey_prop_id"),) @@ -206,27 +222,27 @@ class StoreyPropertyData(AuthMixin, Base): backref=sqla.orm.backref("storey_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return Storey.authorize_query(actor, query.join(Storey)) + + def authorize_read(self, 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(AuthMixin, Base): +class SpacePropertyData(AuthMgrMixin, Base): __tablename__ = "space_prop_data" __table_args__ = (sqla.UniqueConstraint("space_id", "space_prop_id"),) @@ -246,27 +262,28 @@ class SpacePropertyData(AuthMixin, Base): backref=sqla.orm.backref("space_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: prop.structural_element_property.value_type.verify(self.value) - -class ZonePropertyData(AuthMixin, Base): + @classmethod + def authorize_query(cls, actor, query): + return Space.authorize_query(actor, query.join(Space)) + + def authorize_read(self, 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): __tablename__ = "zone_prop_data" __table_args__ = (sqla.UniqueConstraint("zone_id", "zone_prop_id"),) @@ -286,25 +303,21 @@ class ZonePropertyData(AuthMixin, Base): backref=sqla.orm.backref("zone_property_data", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: prop.structural_element_property.value_type.verify(self.value) + @classmethod + def authorize_query(cls, actor, query): + return Zone.authorize_query(actor, query.join(Zone)) + + def authorize_read(self, actor): + campaign = ( + db.session.query(Campaign).join(Zone).filter(Zone.id == self.zone_id) + ).one() + return campaign.is_member(actor) + PROPERTIES_MAPPING = { # Model class name: (lowercase name, property class, property data class) @@ -355,7 +368,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"),) @@ -372,21 +385,22 @@ class Site(AuthMixin, StructuralElementBase): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, + def authorize_query(cls, actor, query): + return Campaign.authorize_query(actor, query.join(Campaign)) + + def authorize_read(self, 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") +def authorize_get_weather_data(actor: User, site: Site) -> bool: + return False -class Building(AuthMixin, StructuralElementBase): +class Building(AuthMgrMixin, StructuralElementBase): __tablename__ = "buildings" __table_args__ = (sqla.UniqueConstraint("site_id", "name"),) @@ -412,21 +426,17 @@ def get(cls, *, campaign_id=None, **kwargs): return query @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "site": Relation( - kind="one", - other_type="Site", - my_field="site_id", - other_field="id", - ), - }, + def authorize_query(cls, actor, query): + return Site.authorize_query(actor, query.join(Site)) + + def authorize_read(self, actor): + campaign = ( + db.session.query(Campaign).join(Site).filter(Site.id == self.site_id).one() ) + return campaign.is_member(actor) -class Storey(AuthMixin, StructuralElementBase): +class Storey(AuthMgrMixin, StructuralElementBase): __tablename__ = "storeys" __table_args__ = (sqla.UniqueConstraint("building_id", "name"),) @@ -461,21 +471,21 @@ def get(cls, *, campaign_id=None, site_id=None, **kwargs): return query @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "building": Relation( - kind="one", - other_type="Building", - my_field="building_id", - other_field="id", - ), - }, + def authorize_query(cls, actor, query): + return Building.authorize_query(actor, query.join(Building)) + + def authorize_read(self, actor): + campaign = ( + db.session.query(Campaign) + .join(Site) + .join(Building) + .filter(Building.id == self.building_id) + .one() ) + return campaign.is_member(actor) -class Space(AuthMixin, StructuralElementBase): +class Space(AuthMgrMixin, StructuralElementBase): __tablename__ = "spaces" __table_args__ = (sqla.UniqueConstraint("storey_id", "name"),) @@ -521,21 +531,22 @@ def get(cls, *, campaign_id=None, site_id=None, building_id=None, **kwargs): return query @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "storey": Relation( - kind="one", - other_type="Storey", - my_field="storey_id", - other_field="id", - ), - }, + def authorize_query(cls, actor, query): + return Storey.authorize_query(actor, query.join(Storey)) + + def authorize_read(self, 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(AuthMixin, StructuralElementBase): +class Zone(AuthMgrMixin, StructuralElementBase): __tablename__ = "zones" __table_args__ = (sqla.UniqueConstraint("campaign_id", "name"),) @@ -550,18 +561,14 @@ class Zone(AuthMixin, StructuralElementBase): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, + def authorize_query(cls, actor, query): + return Campaign.authorize_query(actor, query.join(Campaign)) + + def authorize_read(self, 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 b0758da4..afe6d2c7 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 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) @@ -32,18 +34,12 @@ class TaskByCampaign(AuthMixin, Base): end_offset = sqla.Column(sqla.Integer, default=0, nullable=False) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "campaign": Relation( - kind="one", - other_type="Campaign", - my_field="campaign_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return Campaign.authorize_query(actor, query.join(Campaign)) + + def authorize_read(self, 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 013042f1..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 AuthMixin, Relation, auth +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 @@ -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"),) @@ -56,30 +62,18 @@ class Timeseries(AuthMixin, Base): backref=sqla.orm.backref("timeseries", cascade="all, delete-orphan"), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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) + @classmethod + def authorize_query(cls, actor, query): + return CampaignScope.authorize_query(actor, query.join(CampaignScope)) + + def authorize_read(self, actor): + campaign_scope = CampaignScope.get_by_id(self.campaign_scope_id) + return campaign_scope.is_member(actor) + @classmethod def get( cls, @@ -380,7 +374,19 @@ 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: + 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: + campaign_scope = CampaignScope.get_by_id(timeseries.campaign_scope_id) + return campaign_scope.is_member(actor) + + +class TimeseriesPropertyData(AuthMgrMixin, Base): """Timeseries property data""" __tablename__ = "ts_prop_data" @@ -398,27 +404,21 @@ class TimeseriesPropertyData(AuthMixin, Base): ), ) - @classmethod - def register_class(cls): - auth.register_class( - cls, - 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: 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): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return 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"),) @@ -440,21 +440,27 @@ class TimeseriesByDataState(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_create(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + def authorize_update(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) -class TimeseriesBySite(AuthMixin, Base): + def authorize_delete(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) + + +class TimeseriesBySite(AuthMgrMixin, Base): __tablename__ = "ts_by_sites" __table_args__ = (sqla.UniqueConstraint("site_id", "timeseries_id"),) @@ -472,27 +478,15 @@ class TimeseriesBySite(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) -class TimeseriesByBuilding(AuthMixin, Base): +class TimeseriesByBuilding(AuthMgrMixin, Base): __tablename__ = "ts_by_buildings" __table_args__ = (sqla.UniqueConstraint("building_id", "timeseries_id"),) @@ -514,27 +508,15 @@ class TimeseriesByBuilding(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) -class TimeseriesByStorey(AuthMixin, Base): +class TimeseriesByStorey(AuthMgrMixin, Base): __tablename__ = "ts_by_storeys" __table_args__ = (sqla.UniqueConstraint("storey_id", "timeseries_id"),) @@ -552,27 +534,15 @@ class TimeseriesByStorey(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) -class TimeseriesBySpace(AuthMixin, Base): +class TimeseriesBySpace(AuthMgrMixin, Base): __tablename__ = "ts_by_spaces" __table_args__ = (sqla.UniqueConstraint("space_id", "timeseries_id"),) @@ -590,27 +560,15 @@ class TimeseriesBySpace(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) -class TimeseriesByZone(AuthMixin, Base): +class TimeseriesByZone(AuthMgrMixin, Base): __tablename__ = "ts_by_zones" __table_args__ = (sqla.UniqueConstraint("zone_id", "timeseries_id"),) @@ -628,24 +586,12 @@ class TimeseriesByZone(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, 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/users.py b/src/bemserver_core/model/users.py index 5c8b2c1b..e48a24df 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, 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) @@ -21,20 +21,6 @@ class User(AuthMixin, Base): _is_admin = sqla.Column(sqla.Boolean(), nullable=False) _is_active = sqla.Column(sqla.Boolean(), nullable=False) - @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "users_by_user_groups": Relation( - kind="many", - other_type="UserByUserGroup", - my_field="id", - other_field="user_id", - ), - }, - ) - def __repr__(self): return ( f", " @@ -47,7 +33,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 +42,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,35 +60,49 @@ 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 -class UserGroup(AuthMixin, Base): + +@auth_mgr.add_rule("set_active") +def authorize_set_active(actor: User, user: User) -> bool: + return False + + +class UserGroup(AuthMgrMixin, Base): __tablename__ = "u_groups" id = sqla.Column(sqla.Integer, primary_key=True) name = sqla.Column(sqla.String(80), unique=True, nullable=False) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + def authorize_query(cls, actor, query): + return UserByUserGroup.authorize_query(actor, query.join(UserByUserGroup)) + def authorize_create(self, actor): + return False -class UserByUserGroup(AuthMixin, Base): + 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(AuthMgrMixin, Base): """UserGroup x User associations""" __tablename__ = "users_by_u_groups" @@ -122,21 +122,8 @@ class UserByUserGroup(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - 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", - ), - }, - ) + 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 diff --git a/src/bemserver_core/model/weather.py b/src/bemserver_core/model/weather.py index 17be7aba..adb9e52f 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 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"),) @@ -49,15 +51,9 @@ class WeatherTimeseriesBySite(AuthMixin, Base): ) @classmethod - def register_class(cls): - auth.register_class( - cls, - fields={ - "timeseries": Relation( - kind="one", - other_type="Timeseries", - my_field="timeseries_id", - other_field="id", - ), - }, - ) + def authorize_query(cls, actor, query): + return Timeseries.authorize_query(actor, query.join(Timeseries)) + + def authorize_read(self, actor): + timeseries = Timeseries.get_by_id(self.timeseries_id) + return timeseries.authorize_read(actor) 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() 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): diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 5835faf6..a3d6ca09 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -6,25 +6,42 @@ import sqlalchemy as sqla -from bemserver_core.authorization import AuthMixin +from bemserver_core.authorization import AuthMgrMixin, AuthorizationsManager from bemserver_core.database import Base, db +from bemserver_core.exceptions import BEMServerAuthorizationUndefinedActionError -class TestAuthMixin: +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: @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 +73,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) 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()