diff --git a/bento_lib/auth/bento_permissions_bundle.py b/bento_lib/auth/bento_permissions_bundle.py new file mode 100644 index 0000000..e69de29 diff --git a/bento_lib/auth/middleware/base.py b/bento_lib/auth/middleware/base.py index 717e7d0..ad5a7bd 100644 --- a/bento_lib/auth/middleware/base.py +++ b/bento_lib/auth/middleware/base.py @@ -3,7 +3,7 @@ import requests from abc import ABC, abstractmethod -from typing import Any, Callable +from typing import Any, Callable, Iterable from ..exceptions import BentoAuthException @@ -91,6 +91,46 @@ def authz_post( return res.json() + @staticmethod + def _evaluate_body(resources: Iterable[dict], permissions: Iterable[str]) -> dict: + return {"resources": tuple(resources), "permissions": tuple(permissions)} + + @staticmethod + def _matrix_tuple_cast(authz_result: list[list[bool]]) -> tuple[tuple[bool, ...]]: + return tuple(tuple(x) for x in authz_result) + + def evaluate( + self, + request: Any, + resources: Iterable[dict], + permissions: Iterable[str], + require_token: bool = False, + headers_getter: Callable[[Any], dict[str, str]] | None = None, + mark_authz_done: bool = False, + ) -> tuple[tuple[bool, ...]]: + if mark_authz_done: + self.mark_authz_done(request) + return self._matrix_tuple_cast( + self.authz_post( + request, + "/policy/evaluate", + self._evaluate_body(resources, permissions), + require_token=require_token, + headers_getter=headers_getter, + )["result"] + ) + + def evaluate_one( + self, + request: Any, + resource: dict, + permission: str, + require_token: bool = False, + headers_getter: Callable[[Any], dict[str, str]] | None = None, + mark_authz_done: bool = False, + ) -> bool: + return self.evaluate(request, (resource,), (permission,), require_token, headers_getter, mark_authz_done)[0][0] + async def async_authz_post( self, request: Any, @@ -111,6 +151,44 @@ async def async_authz_post( return await res.json() + async def async_evaluate( + self, + request: Any, + resources: Iterable[dict], + permissions: Iterable[str], + require_token: bool = False, + headers_getter: Callable[[Any], dict[str, str]] | None = None, + mark_authz_done: bool = False, + ) -> tuple[tuple[bool, ...]]: + if mark_authz_done: + self.mark_authz_done(request) + return self._matrix_tuple_cast( + ( + await self.async_authz_post( + request, + "/policy/evaluate", + self._evaluate_body(resources, permissions), + require_token=require_token, + headers_getter=headers_getter, + ) + )["result"] + ) + + async def async_evaluate_one( + self, + request: Any, + resource: dict, + permission: str, + require_token: bool = False, + headers_getter: Callable[[Any], dict[str, str]] | None = None, + mark_authz_done: bool = False, + ) -> bool: + return ( + await self.async_evaluate( + request, (resource,), (permission,), require_token, headers_getter, mark_authz_done + ) + )[0][0] + @staticmethod @abstractmethod def mark_authz_done(request: Any): # pragma: no cover @@ -124,26 +202,24 @@ def check_authz_evaluate( require_token: bool = True, set_authz_flag: bool = False, headers_getter: Callable[[Any], dict[str, str]] | None = None, - ): + ) -> None: if not self.enabled: return - res = self.authz_post( + res = self.evaluate( request, - "/policy/evaluate", - body={"requested_resource": resource, "required_permissions": list(permissions)}, + [resource], + list(permissions), require_token=require_token, headers_getter=headers_getter, - ) + mark_authz_done=set_authz_flag, + )[0] - if not res.get("result"): + if not all(res): # We early-return with the flag set - we're returning Forbidden, # and we've determined authz, so we can just set the flag. raise BentoAuthException("Forbidden", status_code=403) # Actually forbidden by authz service - if set_authz_flag: - self.mark_authz_done(request) - async def async_check_authz_evaluate( self, request: Any, @@ -156,18 +232,18 @@ async def async_check_authz_evaluate( if not self.enabled: return - res = await self.async_authz_post( - request, - "/policy/evaluate", - body={"requested_resource": resource, "required_permissions": list(permissions)}, - require_token=require_token, - headers_getter=headers_getter, - ) - - if not res.get("result"): + res = ( + await self.async_evaluate( + request, + [resource], + list(permissions), + require_token=require_token, + headers_getter=headers_getter, + mark_authz_done=set_authz_flag, + ) + )[0] + + if not all(res): # We early-return with the flag set - we're returning Forbidden, # and we've determined authz, so we can just set the flag. raise BentoAuthException("Forbidden", status_code=403) # Actually forbidden by authz service - - if set_authz_flag: - self.mark_authz_done(request) diff --git a/bento_lib/auth/permissions.py b/bento_lib/auth/permissions.py new file mode 100644 index 0000000..98999be --- /dev/null +++ b/bento_lib/auth/permissions.py @@ -0,0 +1,80 @@ +from collections.abc import Iterator +from typing import NewType + +PermissionVerb = NewType("PermissionVerb", str) +PermissionNoun = NewType("PermissionNoun", str) + +PermissionTuple = NewType("PermissionTuple", tuple[PermissionVerb, PermissionNoun]) + + +RESOURCE_SPEC = { + "key": "instance", + "anyOf": [ + { + "key": "project", + "anyOf": [{"key": "dataset"}], + }, + {"key": "data_type"}, + ], +} + + +class PermissionsDefinitionError(Exception): + pass + + +def permission_tuple_to_str(pt: PermissionTuple) -> str: + return f"{pt[0]}:{pt[1]}" + + +def _possible_keysets_for_resource_spec_rec(rs: dict, base_set: frozenset[str]) -> Iterator[frozenset[str]]: + pass # TODO + + +def possible_keysets_for_resource_spec(rs: dict) -> Iterator[frozenset[str]]: + pass # TODO + + +class PermissionsBundle: + def __init__(self, resource_spec: dict): + self._verbs: set[PermissionVerb] = set() + self._nouns: set[PermissionNoun] = set() + + self._permissions: set[PermissionTuple] = set() + self._valid_keysets_by_permission: dict[PermissionTuple, tuple[frozenset[str], ...]] = {} + + # TODO: validate + self._resource_spec: dict = resource_spec + + def new_verb(self, verb: str) -> PermissionVerb: + v = PermissionVerb(verb) + if v in self._verbs: + raise PermissionsDefinitionError(f"Verb {v} already exists") + self._verbs.add(v) + return v + + def new_noun(self, noun: str) -> PermissionNoun: + n = PermissionNoun(noun) + if n in self._nouns: + raise PermissionsDefinitionError(f"Noun {n} already exists") + self._nouns.add(n) + return n + + def new_permission( + self, verb: PermissionVerb, noun: PermissionNoun, valid_keysets: tuple[frozenset[str], ...] + ) -> PermissionTuple: + pt = PermissionTuple((verb, noun)) + + if pt in self._permissions: + raise PermissionsDefinitionError(f"Permission {permission_tuple_to_str(pt)} already exists") + + # TODO: validate valid_keysets + self._valid_keysets_by_permission[pt] = valid_keysets + + return pt + + def permissions(self) -> frozenset[PermissionTuple]: + return frozenset(self._permissions) + + def permissions_strs(self) -> frozenset[str]: + return frozenset(map(permission_tuple_to_str, self._permissions))