Skip to content

Commit

Permalink
permissions: added role based generator & resolver
Browse files Browse the repository at this point in the history
  • Loading branch information
alejandromumo committed Jul 27, 2023
1 parent cbcece1 commit d05b3e5
Show file tree
Hide file tree
Showing 14 changed files with 85 additions and 84 deletions.
62 changes: 44 additions & 18 deletions invenio_users_resources/entity_resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@

from types import SimpleNamespace

from flask_principal import UserNeed
from flask_principal import RoleNeed, UserNeed
from invenio_access.permissions import system_process, system_user_id
from invenio_accounts.models import User
from invenio_accounts.models import Role, User
from invenio_records_resources.references.entity_resolvers import (
EntityProxy,
EntityResolver,
)
from sqlalchemy.exc import NoResultFound

from .permissions import moderation_action
from .proxies import current_users_service
from .services.groups.config import GroupsServiceConfig
from .services.schemas import SystemUserSchema, UserGhostSchema, UserSchema
from .services.users.config import UsersServiceConfig
from .permissions import user_moderation_action


class UserProxy(EntityProxy):
Expand Down Expand Up @@ -104,29 +105,54 @@ def _get_entity_proxy(self, ref_dict):
return UserProxy(self, ref_dict)


class UserModerationProxy(UserProxy):
"""Resolver proxy for a User Moderation entity."""
class GroupProxy(EntityProxy):
"""Resolver proxy for a Role entity."""

def get_needs(self, ctx=None):
"""Return user moderaction action need."""
# System process need is also valid for UserModeration actions
return [user_moderation_action, system_process]
def _resolve(self):
"""Resolve the User from the proxy's reference dict, or system_identity."""
# Resolves to role name, not id
role_id = self._parse_ref_dict_id()
try:
return Role.query.filter(
Role.name == role_id # TODO to be changed to role id
).one()
except NoResultFound:
return {}

def pick_resolved_fields(self, identity, resolved_dict):
"""Select which fields to return when resolving the reference."""
serialized_role = {}

return serialized_role

def get_needs(self, ctx=None):
"""Return needs based on the given roles."""
role_id = self._parse_ref_dict_id()
return [RoleNeed(role_id)]

class UserModerationResolver(UserResolver):
"""User moderation entity resolver.

The entity resolver enables Invenio-Requests to understand moderators as
receiver, as well as system process, enabling actions on requests.
"""
class GroupResolver(EntityResolver):
"""Group entity resolver."""

type_id = "user_moderation"
type_id = "group"
"""Type identifier for this resolver."""

def __init__(self):
"""Constructor."""
super().__init__(GroupsServiceConfig.service_id)

def matches_reference_dict(self, ref_dict):
"""Check if the reference dict references a role."""
return self._parse_ref_dict_type(ref_dict) == self.type_id

def _reference_entity(self, entity):
"""Create a reference dict for the given user."""
return {"user_moderation": str(entity.id)}
return {"group": str(entity.id)}

def matches_entity(self, entity):
"""Check if the entity is a Role."""
return isinstance(entity, Role)

def _get_entity_proxy(self, ref_dict):
"""Return a UserModerationProxy for the given reference dict."""
return UserModerationProxy(self, ref_dict)
"""Return a GroupProxy for the given reference dict."""
return GroupProxy(self, ref_dict)
7 changes: 2 additions & 5 deletions invenio_users_resources/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@
# it under the terms of the MIT License; see LICENSE file for more details.
"""Users resources generic needs and permissions."""

from flask_principal import RoleNeed
from invenio_access import action_factory
from invenio_access.permissions import Permission

user_moderation_action = action_factory("user-moderation")
user_moderation_permission = Permission(user_moderation_action)
MODERATION_ACTION_NAME = "administration-moderation"

user_moderator = RoleNeed("user-moderation")
moderation_action = action_factory(MODERATION_ACTION_NAME)
3 changes: 0 additions & 3 deletions invenio_users_resources/records/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def parse_user_data(user):
"preferences": dict(user.preferences or {}),
"profile": dict(user.user_profile or {}),
"blocked_at": user.blocked_at,
"suspended_at": user.suspended_at,
"verified_at": user.verified_at,
}

Expand Down Expand Up @@ -105,8 +104,6 @@ class UserAggregate(Record):

blocked_at = DictField("blocked_at")

suspended_at = DictField("suspended_at")

verified_at = DictField("verified_at")

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@
"blocked_at": {
"type": "date"
},
"suspended_at": {
"type": "date"
},
"verified_at": {
"type": "date"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@
"blocked_at": {
"type": "date"
},
"suspended_at": {
"type": "date"
},
"verified_at": {
"type": "date"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@
"blocked_at": {
"type": "date"
},
"suspended_at": {
"type": "date"
},
"verified_at": {
"type": "date"
},
Expand Down
9 changes: 0 additions & 9 deletions invenio_users_resources/records/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,6 @@ def blocked_at(self):
def blocked_at(self, value):
self.model_obj.blocked_at = value

@property
def suspended_at(self):
"""Date when the user was suspended, if any."""
return self.model_obj.suspended_at

@suspended_at.setter
def suspended_at(self, value):
self.model_obj.suspended_at = value

@property
def verified_at(self):
"""Date when the user was verified, if any."""
Expand Down
31 changes: 20 additions & 11 deletions invenio_users_resources/services/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
from functools import reduce
from itertools import chain

from flask_principal import RoleNeed
from invenio_records_permissions.generators import Generator, UserNeed
from invenio_search.engine import dsl

from invenio_users_resources.permissions import user_moderation_action, user_moderator
from invenio_users_resources.permissions import moderation_action


class IfPublic(Generator):
Expand Down Expand Up @@ -112,21 +113,29 @@ def query_filter(self, identity=None, **kwargs):
return []


class UserModeration(Generator):
"""Allows user-management."""
class AdminAction(Generator):
"""Allows admin-enabled actions."""

def __init__(self):
def __init__(self, action):
"""Constructor."""
self.action = action
super().__init__()

def needs(self, **kwargs):
"""Enabling Needs."""
return [user_moderation_action]
return [self.action]

def query_filter(self, identity=None, **kwargs):
"""Filters for current identity as system process."""
if user_moderator in identity.provides:
# TODO we might want to have an umbrella "user_moderation" for some fields
return dsl.Q("match_all")
else:
return []
"""Filters for current identity."""
for need in identity.provides:
if need.method == "role" and need.value == self.action.value:
return dsl.Q("match_all")
return []


class UserModeration(AdminAction):
"""Admin action generator for user moderation."""

def __init__(self):
"""Constructor."""
super().__init__(moderation_action)
8 changes: 3 additions & 5 deletions invenio_users_resources/services/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,17 @@ class UsersPermissionPolicy(BasePermissionPolicy):

can_create = [SystemProcess()]
can_read = [UserModeration(), IfPublicUser([AnyUser()], [Self()]), SystemProcess()]
can_search = [UserModeration(), AuthenticatedUser(), SystemProcess()]
can_search = [AuthenticatedUser(), SystemProcess()]
can_update = [SystemProcess()]
can_delete = [SystemProcess()]

can_read_email = [IfPublicEmail([AnyUser()], [Self()]), SystemProcess()]
can_read_details = [Self(), SystemProcess()]

# Moderation permissions
can_block = [UserModeration(), SystemProcess()]
can_approve = [UserModeration(), SystemProcess()]
can_suspend = [UserModeration(), SystemProcess()]
can_manage = [UserModeration(), SystemProcess()]
can_search_all = [UserModeration(), SystemProcess()]
can_read_moderation_details = [UserModeration(), SystemProcess()]
can_read_system_details = [UserModeration(), SystemProcess()]


class GroupsPermissionPolicy(BasePermissionPolicy):
Expand Down
6 changes: 2 additions & 4 deletions invenio_users_resources/services/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ class UserSchema(BaseRecordSchema, FieldPermissionsMixin):
"active": "read_details",
"confirmed": "read_details",
"preferences": "read_details",
"blocked_at": "read_moderation_details",
"suspended_at": "read_moderation_details",
"verified_at": "read_moderation_details",
"blocked_at": "read_system_details",
"verified_at": "read_system_details",
}

# NOTE: API should only deliver users that are active & confirmed
Expand All @@ -68,7 +67,6 @@ class UserSchema(BaseRecordSchema, FieldPermissionsMixin):
preferences = fields.Nested(UserPreferencesSchema)

blocked_at = ISODateString()
suspended_at = ISODateString()
verified_at = ISODateString()

def is_self(self, obj):
Expand Down
13 changes: 5 additions & 8 deletions invenio_users_resources/services/users/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,11 @@ def block(self, identity, id_, uow=None):
# return 403 even on empty resource due to security implications
raise PermissionDeniedError()

self.require_permission(identity, "block", record=user)
self.require_permission(identity, "manage", record=user)

user.model.active = False
user.model.blocked_at = datetime.now()
user.model.verified_at = None
user.model.suspended_at = None

user.commit()

Expand All @@ -156,32 +155,30 @@ def approve(self, identity, id_, uow=None):
# return 403 even on empty resource due to security implications
raise PermissionDeniedError()

self.require_permission(identity, "approve", record=user)
self.require_permission(identity, "manage", record=user)

user.model.active = True
user.model.blocked_at = None
user.model.verified_at = datetime.now()
user.model.suspended_at = None

user.commit()

uow.register(RecordIndexOp(user, indexer=self.indexer, index_refresh=True))
return True

@unit_of_work()
def suspend(self, identity, id_, uow=None):
"""Approves an user."""
def deactivate(self, identity, id_, uow=None):
"""Deactivates an user."""
user = UserAggregate.get_record(id_)
if user is None:
# return 403 even on empty resource due to security implications
raise PermissionDeniedError()

self.require_permission(identity, "suspend", record=user)
self.require_permission(identity, "manage", record=user)

user.model.active = False
user.model.blocked_at = None
user.model.verified_at = None
user.model.suspended_at = datetime.now()

user.commit()

Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ invenio_search.mappings =
invenio_i18n.translations =
messages = invenio_users_resources
invenio_access.actions =
user_moderation_action = invenio_users_resources.permissions:user_moderation_action
moderation_action = invenio_users_resources.permissions:moderation_action

invenio_administration.views =
invenio_users_resources_users_list = invenio_users_resources.administration.views.users:UsersListView
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from invenio_app.factory import create_api
from marshmallow import fields

from invenio_users_resources.permissions import user_moderation_action
from invenio_users_resources.permissions import moderation_action
from invenio_users_resources.proxies import (
current_groups_service,
current_users_service,
Expand Down Expand Up @@ -108,13 +108,13 @@ def anon_identity():
@pytest.fixture(scope="module")
def user_moderator(UserFixture, app, database, users):
"""Admin user for requests."""
action_name = user_moderation_action.value
action_name = moderation_action.value
moderator = users["user_moderator"]

role = Role(name=action_name)
database.session.add(role)

action_role = ActionRoles.create(action=user_moderation_action, role=role)
action_role = ActionRoles.create(action=moderation_action, role=role)
database.session.add(action_role)

moderator.user.roles.append(role)
Expand Down
13 changes: 5 additions & 8 deletions tests/services/test_service_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ def test_search_permissions(app, db, user_service, user_moderator, user_res):
user_res.identity, q=f"username:{user_res._user.username}"
)
assert search.total > 0
# TODO user should see moderation fields such as 'verified_at'?

# User can't search for non-confirmed users
with pytest.raises(PermissionDeniedError):
Expand Down Expand Up @@ -208,18 +207,16 @@ def test_approve(user_service, user_res, user_moderator):
assert "verified_at" in ur.data


def test_suspend(user_service, user_res, user_moderator):
"""Test suspension of an user."""
def test_deactivate(user_service, user_res, user_moderator):
"""Test deactivation of an user."""
with pytest.raises(PermissionDeniedError):
user_service.block(user_res.identity, user_res.id)

suspended = user_service.suspend(user_moderator.identity, user_res.id)
assert suspended
deactivated = user_service.deactivate(user_moderator.identity, user_res.id)
assert deactivated

ur = user_service.read(user_res.identity, user_res.id)
# User can't see when it was suspended
assert not "suspended_at" in ur.data
# But can see it's not active
# User can see it's not active
assert ur.data["active"] is False

# Moderator can still search for the user
Expand Down

0 comments on commit d05b3e5

Please sign in to comment.