Skip to content

Commit

Permalink
[resotocore][feat] Add user roles and permission handling (#1755)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Lukas Lösche <lukas@some.engineering>
  • Loading branch information
aquamatthias and lloesche committed Jul 27, 2023
1 parent f28a45b commit 72a8c0a
Show file tree
Hide file tree
Showing 15 changed files with 489 additions and 380 deletions.
4 changes: 2 additions & 2 deletions resotocore/.pylintrc
Expand Up @@ -71,15 +71,15 @@ disable=
too-many-statements,
invalid-overridden-method,
unsubscriptable-object,
duplicate-code, # pylint also checks duplicated import statements - does not make any sense
too-many-boolean-expressions,
no-member,
unsupported-binary-operation,
too-many-branches,
use-dict-literal,
too-many-return-statements,
not-an-iterable,
too-many-instance-attributes
too-many-instance-attributes,
too-many-ancestors


[REPORTS]
Expand Down
3 changes: 2 additions & 1 deletion resotocore/resotocore/cli/cli.py
Expand Up @@ -78,6 +78,7 @@
from resotocore.query.query_parser import aggregate_parameter_parser, sort_args_p, limit_parser_direct
from resotocore.service import Service
from resotocore.types import JsonElement
from resotocore.user.model import Permission
from resotocore.util import group_by
from resotolib.parse_util import make_parser, pipe_p, semicolon_p

Expand Down Expand Up @@ -209,7 +210,7 @@ def help_command() -> Stream:

return stream.just(result)

return CLISource.single(help_command)
return CLISource.single(help_command, required_permissions={Permission.read})


CLIArg = Tuple[CLICommand, Optional[str]]
Expand Down
286 changes: 190 additions & 96 deletions resotocore/resotocore/cli/command.py

Large diffs are not rendered by default.

33 changes: 27 additions & 6 deletions resotocore/resotocore/cli/model.py
Expand Up @@ -37,6 +37,7 @@
from resotocore.core_config import AliasTemplateConfig, AliasTemplateParameterConfig
from resotocore.error import CLIParseError
from resotocore.ids import GraphName
from resotocore.user.model import Permission, AuthorizedUser
from resotocore.query.model import Query, variable_to_absolute, PathRoot
from resotocore.query.template_expander import render_template
from resotocore.types import Json, JsonElement
Expand Down Expand Up @@ -108,11 +109,16 @@ class CLIContext:
commands: List[ExecutableCommand] = field(factory=list)
console_renderer: Optional[ConsoleRenderer] = None
source: Optional[str] = None # who is calling
user: Optional[AuthorizedUser] = None

@property
def graph_name(self) -> GraphName:
return GraphName(self.env["graph"])

@property
def user_permissions(self) -> Set[Permission]:
return self.user.permissions if self.user else set()

def variable_in_section(self, variable: str) -> str:
# if there is no entity provider, always assume the root section
section = (
Expand Down Expand Up @@ -208,11 +214,16 @@ class CLIFileRequirement(CLICommandRequirement):

class CLIAction(ABC):
def __init__(
self, produces: MediaType, requires: Optional[List[CLICommandRequirement]], envelope: Optional[Dict[str, str]]
self,
produces: MediaType,
requires: Optional[List[CLICommandRequirement]],
envelope: Optional[Dict[str, str]],
required_permissions: Optional[Set[Permission]] = None,
) -> None:
self.produces = produces
self.required = requires or []
self.envelope: Dict[str, str] = envelope or {}
self.required_permissions = required_permissions or set()

@staticmethod
def make_stream(in_stream: JsGen) -> Stream:
Expand All @@ -226,8 +237,9 @@ def __init__(
produces: MediaType = MediaType.Json,
requires: Optional[List[CLICommandRequirement]] = None,
envelope: Optional[Dict[str, str]] = None,
required_permissions: Optional[Set[Permission]] = None,
) -> None:
super().__init__(produces, requires, envelope)
super().__init__(produces, requires, envelope, required_permissions)
self._fn = fn

async def source(self) -> Tuple[Optional[int], Stream]:
Expand All @@ -241,8 +253,9 @@ def no_count(
produces: MediaType = MediaType.Json,
requires: Optional[List[CLICommandRequirement]] = None,
envelope: Optional[Dict[str, str]] = None,
required_permissions: Optional[Set[Permission]] = None,
) -> CLISource:
return CLISource.with_count(fn, None, produces, requires, envelope)
return CLISource.with_count(fn, None, produces, requires, envelope, required_permissions)

@staticmethod
def with_count(
Expand All @@ -251,22 +264,24 @@ def with_count(
produces: MediaType = MediaType.Json,
requires: Optional[List[CLICommandRequirement]] = None,
envelope: Optional[Dict[str, str]] = None,
required_permissions: Optional[Set[Permission]] = None,
) -> CLISource:
async def combine() -> Tuple[Optional[int], JsGen]:
res = fn()
gen = await res if iscoroutine(res) else res
return count, gen

return CLISource(combine, produces, requires, envelope)
return CLISource(combine, produces, requires, envelope, required_permissions)

@staticmethod
def single(
fn: Callable[[], Union[JsGen, Awaitable[JsGen]]],
produces: MediaType = MediaType.Json,
requires: Optional[List[CLICommandRequirement]] = None,
envelope: Optional[Dict[str, str]] = None,
required_permissions: Optional[Set[Permission]] = None,
) -> CLISource:
return CLISource.with_count(fn, 1, produces, requires, envelope)
return CLISource.with_count(fn, 1, produces, requires, envelope, required_permissions)

@staticmethod
def empty() -> CLISource:
Expand All @@ -280,8 +295,9 @@ def __init__(
produces: MediaType = MediaType.Json,
requires: Optional[List[CLICommandRequirement]] = None,
envelope: Optional[Dict[str, str]] = None,
required_permissions: Optional[Set[Permission]] = None,
) -> None:
super().__init__(produces, requires, envelope)
super().__init__(produces, requires, envelope, required_permissions)
self._fn = fn

async def flow(self, in_stream: JsGen) -> Stream:
Expand Down Expand Up @@ -674,6 +690,11 @@ def produces(self) -> MediaType:
# the last command in the chain defines the resulting media type
return self.executable_commands[-1].action.produces if self.executable_commands else MediaType.Json

def is_allowed_to_execute(self) -> bool:
if self.ctx.user is None:
return False
return all(self.ctx.user.has_permission(cmd.action.required_permissions) for cmd in self.executable_commands)

async def execute(self) -> Tuple[Optional[int], Stream]:
if self.executable_commands:
source_action = cast(CLISource, self.executable_commands[0].action)
Expand Down
14 changes: 14 additions & 0 deletions resotocore/resotocore/error.py
@@ -1,3 +1,7 @@
from typing import Set, Any

from aiohttp import web

from resotocore.ids import GraphName


Expand Down Expand Up @@ -96,3 +100,13 @@ class RestartService(SystemExit):
def __init__(self, reason: str) -> None:
super().__init__(f"RestartService due to: {reason}")
self.reason = reason


class NotEnoughPermissions(web.HTTPForbidden):
def __init__(self, user_permissions: Set[Any], required_permissions: Set[Any]) -> None:
super().__init__(
text=f"Not allowed to perform this operation. "
f"Missing permission: {', '.join(a.name for a in (required_permissions-user_permissions))}"
)
self.user_permissions = user_permissions
self.required_permissions = required_permissions
5 changes: 1 addition & 4 deletions resotocore/resotocore/user/__init__.py
Expand Up @@ -13,14 +13,11 @@
UsersConfigId = ConfigId("resoto.users")


ValidRoles = {"admin", "readwrite", "readonly"}


@define
class ResotoUser:
kind: ClassVar[str] = "resoto_user"
fullname: str = field(metadata={"description": "The full name of the user."})
password_hash: str = field(metadata={"description": "The sha256 hash of the user's password."})
password_hash: str = field(metadata={"description": "The hash of the user's password."})
roles: Set[str] = field(factory=set, metadata={"description": "The roles of the user."})


Expand Down
65 changes: 65 additions & 0 deletions resotocore/resotocore/user/model.py
@@ -0,0 +1,65 @@
from __future__ import annotations
from enum import Enum
from typing import Set, Dict, Any

from attr import define


class Permission(Enum):
read = "read" # can read all resource data
write = "write" # can change all resource data
admin = "admin" # can change configuration


@define
class Role:
name: str
permissions: Set[Permission]

def has_permission(self, permission: Permission) -> bool:
return permission in self.permissions


PredefineRoles = {
r.name: r
for r in [
Role("admin", {Permission.admin, Permission.read, Permission.write}),
Role("readwrite", {Permission.read, Permission.write}),
Role("readonly", {Permission.read}),
Role("service", {Permission.admin, Permission.read, Permission.write}),
]
}
AllowedRoleNames = set(PredefineRoles.keys()) - {"service"}


@define
class AuthorizedUser:
email: str
roles: Set[str]
permissions: Set[Permission]
is_user: bool

def has_permission(self, required_permissions: Set[Permission]) -> bool:
return required_permissions.issubset(self.permissions)

@staticmethod
def from_jwt(jwt: Dict[str, Any]) -> AuthorizedUser:
def permissions_for_role(role_name: str) -> Set[Permission]:
if role_name not in PredefineRoles:
return set()
return PredefineRoles[role_name].permissions

if "email" in jwt: # This is a user token
email = jwt["email"]
roles = set(jwt["roles"].split(","))
is_user = True
else: # This is a service token
email = "service@resoto.com"
roles = {"service"}
is_user = False
return AuthorizedUser(
email=email,
roles=roles,
permissions={perm for rn in roles for perm in permissions_for_role(rn)},
is_user=is_user,
)

0 comments on commit 72a8c0a

Please sign in to comment.