Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/dioptra/restapi/db/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# This Software (Dioptra) is being made available as a public service by the
# National Institute of Standards and Technology (NIST), an Agency of the United
# States Department of Commerce. This software was developed in part by employees of
# NIST and in part by NIST contractors. Copyright in portions of this software that
# were developed by NIST contractors has been licensed or assigned to NIST. Pursuant
# to Title 17 United States Code Section 105, works of NIST employees are not
# subject to copyright protection in the United States. However, NIST may hold
# international copyright in software created by its employees and domestic
# copyright (or licensing rights) in portions of software that were assigned or
# licensed to NIST. To the extent that NIST holds copyright in this software, it is
# being made available under the Creative Commons Attribution 4.0 International
# license (CC BY 4.0). The disclaimers of the CC BY 4.0 license apply to all parts
# of the software developed or licensed by NIST.
#
# ACCESS THE FULL CC BY 4.0 LICENSE HERE:
# https://creativecommons.org/licenses/by/4.0/legalcode
"""
"Checkers" of various kinds, including assert_* style functions which raise
exceptions, and non-assert style functions which just return information
without raising exceptions.
"""

import inspect
from typing import Callable, ParamSpec, TypeVar

from dioptra.restapi.db.repository import utils

Param = ParamSpec("Param")
RetType = TypeVar("RetType")


def permissioned_write(resource_arg: str):
def decorator(func: Callable[Param, RetType]) -> Callable[Param, RetType]:
def wrapper(self, *args, **kwargs) -> RetType:
callargs = inspect.getcallargs(func, self, *args, **kwargs)
resource = callargs[resource_arg]

resource = utils.get_resource(self.session, resource)
# TODO: replace with more generic check
utils.assert_can_delete_resource(self.session, self.user, resource)

return func(self, *args, **kwargs)

return wrapper

return decorator
9 changes: 8 additions & 1 deletion src/dioptra/restapi/db/repository/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
"""

from collections.abc import Iterable, Sequence
from typing import Any, Final, overload
from typing import Any, Final, cast, overload

from flask_login import current_user

import dioptra.restapi.db.repository.utils as utils
from dioptra.restapi.db.models import (
Expand All @@ -28,6 +30,7 @@
Group,
Resource,
Tag,
User,
)


Expand Down Expand Up @@ -315,6 +318,10 @@ def delete(self, experiment: Experiment | int) -> None:
Raises:
EntityDoesNotExistError: if the experiment does not exist
"""
user = cast(User, current_user)
resource = utils.get_resource(self.session, experiment)

utils.assert_can_delete_resource(self.session, user, resource)

utils.delete_resource(self.session, experiment)

Expand Down
12 changes: 12 additions & 0 deletions src/dioptra/restapi/db/repository/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ def delete(self, group: Group) -> None:
raise EntityDoesNotExistError("group", group_id=group.group_id)

if exists_result is ExistenceResult.EXISTS:
members_stmt = sa.select(GroupMember).where(
GroupMember.group_id == group.group_id
)
members = self.session.scalar(members_stmt)
self.session.delete(members)

managers_stmt = sa.select(GroupManager).where(
GroupManager.group_id == group.group_id
)
managers = self.session.scalar(managers_stmt)
self.session.delete(managers)

lock = GroupLock(GroupLockTypes.DELETE, group)
self.session.add(lock)

Expand Down
8 changes: 5 additions & 3 deletions src/dioptra/restapi/db/repository/queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
from typing import Any, Final, overload

import dioptra.restapi.db.repository.utils as utils
from dioptra.restapi.db.models import Group, Queue, Resource, Tag
from dioptra.restapi.db.models import Group, Queue, Resource, Tag, User
from dioptra.restapi.db.permissions import permissioned_write


class QueueRepository:
Expand All @@ -40,8 +41,9 @@ class QueueRepository:
"description": Queue.description,
}

def __init__(self, session: utils.CompatibleSession[utils.S]):
def __init__(self, session: utils.CompatibleSession[utils.S], user: User):
self.session = session
self.user = user

def create(self, queue: Queue) -> None:
"""
Expand Down Expand Up @@ -113,6 +115,7 @@ def create_snapshot(self, queue: Queue) -> None:

self.session.add(queue)

@permissioned_write(resource_arg="queue")
def delete(self, queue: Queue | int) -> None:
"""
Delete a queue. No-op if the queue is already deleted.
Expand All @@ -124,7 +127,6 @@ def delete(self, queue: Queue | int) -> None:
Raises:
EntityDoesNotExistError: if the queue does not exist
"""

utils.delete_resource(self.session, queue)

@overload
Expand Down
10 changes: 9 additions & 1 deletion src/dioptra/restapi/db/repository/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@
"""

from collections.abc import Iterable, Sequence, Set
from typing import Any, Final, overload
from typing import Any, Final, cast, overload

from flask_login import current_user

import dioptra.restapi.db.repository.utils as utils
from dioptra.restapi.db.models import (
Group,
PluginTaskParameterType,
Resource,
Tag,
User,
)


Expand Down Expand Up @@ -288,4 +291,9 @@ def delete(self, type_: PluginTaskParameterType | int) -> None:
Raises:
EntityDoesNotExistError: if the type does not exist
"""
user = cast(User, current_user)
resource = utils.get_resource(self.session, type_)

utils.assert_can_delete_resource(self.session, user, resource)

utils.delete_resource(self.session, type_)
84 changes: 84 additions & 0 deletions src/dioptra/restapi/db/repository/utils/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,8 @@ def assert_can_create_snapshot(
assert_resource_modifiable(session, snap)
assert_user_exists(session, snap.creator, DeletionPolicy.NOT_DELETED)
assert_user_in_group(session, snap.creator, snap.resource.owner)
assert_user_permission(session, snap.creator, snap.resource.owner, "read")
assert_user_permission(session, snap.creator, snap.resource.owner, "write")
assert_resource_children_exist(session, snap, DeletionPolicy.NOT_DELETED)
assert_resource_type(session, snap, resource_type)

Expand All @@ -960,6 +962,47 @@ def assert_can_create_snapshot(
# relationships?


def assert_can_delete_resource(
session: CompatibleSession[S], user: m.User, resource: m.Resource
):
"""
Check whether the given resource may be delete.
There may also be other resource-type-specific criteria; this function
tests general criteria common to all resource types.

Args:
session: An SQLAlchemy session
snap: A ResourceSnapshot object with the desired resource settings
resource_type: the resource_type value for the type of resource being
created. Used to verify type settings on the resource and resource
objects.

Raises:
EntityExistsError: if the resource already exists
EntityDoesNotExistError: if the resource or user does not exist, or if any child
resource does not exist
EntityDeletedError: if the resource is deleted, user is deleted, or if all child
resources exist but some are deleted
ReadOnlyLockError: if the resource exists and has a read-only lock
UserNotInGroupError: if the user is not a member of the group who owns the
resource
UserPermissionsError: if the user does not have permission to delete the resource
MismatchedResourceTypeError: if the resource or resource's type doesn't
match resource_type
"""
# NOTE: this behavior is different for repositories.
# non-repo returns EntityDoesNotExistError, repo returns Success
# If we enable this assert, repo will return EntityDeletedError
# assert_resource_exists(session, resource, DeletionPolicy.NOT_DELETED)
assert_resource_modifiable(session, resource)
assert_user_exists(session, user, DeletionPolicy.NOT_DELETED)
assert_user_in_group(session, user, resource.owner)
assert_user_permission(session, user, resource.owner, "read")
assert_user_permission(session, user, resource.owner, "write")
assert_resource_children_exist(session, resource, DeletionPolicy.NOT_DELETED)
assert_resource_type(session, resource, resource.resource_type)


def assert_exists(
deletion_policy: DeletionPolicy,
existence_result: ExistenceResult,
Expand Down Expand Up @@ -1168,6 +1211,46 @@ def assert_user_in_group(
raise e.UserNotInGroupError(user.user_id, group.group_id)


def assert_user_permission(
session: CompatibleSession[S], user: m.User, group: m.Group, permission: str
) -> None:
"""
Ensure the given user is a member of the given group. This function
assumes both already exist in the database. It also ignores the deletion
status of both. Existence/deletion status should be checked by the caller
first, if necessary.

Args:
session: An SQLAlchemy session
user: An existing user
group: An existing group
permission: The requested permission

Raises:
UserNotInGroupError: if the given user is not in the given group
UserPermissionsError: if the given user does not have the requested permission
"""

# Assume existence checks on user and group were already done, so they are
# known to exist.
membership = session.get(m.GroupMember, (user.user_id, group.group_id))

if not membership:
raise e.UserNotInGroupError(user.user_id, group.group_id)

if permission == "read" and not membership.read:
raise e.UserPermissionsError("read", group_id=group.group_id)

if permission == "write" and not membership.write:
raise e.UserPermissionsError("read", group_id=group.group_id)

if permission == "share_read" and not membership.write:
raise e.UserPermissionsError("share_read", group_id=group.group_id)

if permission == "share_write" and not membership.write:
raise e.UserPermissionsError("share_write", group_id=group.group_id)


def check_user_collision(session: CompatibleSession[S], user: m.User) -> None:
"""
Factored out check from user and group repositories. Their create methods
Expand Down Expand Up @@ -1296,6 +1379,7 @@ def assert_snapshot_name_available(
__all__ = [
"assert_can_create_resource",
"assert_can_create_snapshot",
"assert_can_delete_resource",
"assert_draft_does_not_exist",
"assert_draft_exists",
"assert_exists",
Expand Down
Loading
Loading