austial-authz 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- austial_authz-0.2.0/.gitignore +12 -0
- austial_authz-0.2.0/PKG-INFO +8 -0
- austial_authz-0.2.0/pyproject.toml +28 -0
- austial_authz-0.2.0/src/austial/authz/__init__.py +41 -0
- austial_authz-0.2.0/src/austial/authz/authz_module.py +19 -0
- austial_authz-0.2.0/src/austial/authz/decorators.py +56 -0
- austial_authz-0.2.0/src/austial/authz/errors.py +19 -0
- austial_authz-0.2.0/src/austial/authz/permission_service.py +55 -0
- austial_authz-0.2.0/src/austial/authz/roles_guard.py +78 -0
- austial_authz-0.2.0/test/authz_spec.py +188 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: austial-authz
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Role- and permission-based access control -- @Roles, @Permissions, RolesGuard, PermissionService, object-level authorisation.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: austial-auth
|
|
7
|
+
Requires-Dist: austial-common
|
|
8
|
+
Requires-Dist: austial-core
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "austial-authz"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Role- and permission-based access control -- @Roles, @Permissions, RolesGuard, PermissionService, object-level authorisation."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"austial-common",
|
|
8
|
+
"austial-core",
|
|
9
|
+
"austial-auth",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/austial"]
|
|
18
|
+
|
|
19
|
+
[tool.uv.sources]
|
|
20
|
+
austial-common = { workspace = true }
|
|
21
|
+
austial-core = { workspace = true }
|
|
22
|
+
austial-auth = { workspace = true }
|
|
23
|
+
|
|
24
|
+
[tool.hatch.version]
|
|
25
|
+
source = "vcs"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version.raw-options]
|
|
28
|
+
root = "../.."
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``austial.authz`` -- role- and permission-based access control.
|
|
2
|
+
|
|
3
|
+
Provides ``@Roles(*names)`` and ``@Permissions(*names)`` metadata decorators,
|
|
4
|
+
``RolesGuard`` (rejects requests whose authenticated principal lacks the required
|
|
5
|
+
roles/permissions), ``PermissionService`` (programmatic checks from services and
|
|
6
|
+
background jobs), and ``@AuthorizeObject(rule)`` (object-level authorisation --
|
|
7
|
+
inject the loaded object into the guard's context so the rule can inspect
|
|
8
|
+
ownership, tenant, status, etc. before the handler runs).
|
|
9
|
+
|
|
10
|
+
The framework enforces "does this principal have the claim?" -- product code
|
|
11
|
+
decides "what roles does this user have?" by populating the JWT's ``roles`` and
|
|
12
|
+
``permissions`` claims at login time.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from austial.authz.authz_module import AuthzModule
|
|
16
|
+
from austial.authz.decorators import (
|
|
17
|
+
AUTHORIZE_OBJECT_METADATA,
|
|
18
|
+
PERMISSIONS_METADATA,
|
|
19
|
+
ROLES_METADATA,
|
|
20
|
+
AuthorizeObject,
|
|
21
|
+
Permissions,
|
|
22
|
+
Roles,
|
|
23
|
+
)
|
|
24
|
+
from austial.authz.errors import InsufficientPermissionsError, InsufficientRolesError
|
|
25
|
+
from austial.authz.permission_service import ObjectAuthorizationRule, PermissionService
|
|
26
|
+
from austial.authz.roles_guard import RolesGuard
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AUTHORIZE_OBJECT_METADATA",
|
|
30
|
+
"AuthzModule",
|
|
31
|
+
"AuthorizeObject",
|
|
32
|
+
"InsufficientPermissionsError",
|
|
33
|
+
"InsufficientRolesError",
|
|
34
|
+
"ObjectAuthorizationRule",
|
|
35
|
+
"PERMISSIONS_METADATA",
|
|
36
|
+
"PermissionService",
|
|
37
|
+
"Permissions",
|
|
38
|
+
"ROLES_METADATA",
|
|
39
|
+
"Roles",
|
|
40
|
+
"RolesGuard",
|
|
41
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""``AuthzModule.for_root(...)`` -- provides ``RolesGuard`` and ``PermissionService``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from austial.authz.permission_service import PermissionService
|
|
6
|
+
from austial.authz.roles_guard import RolesGuard
|
|
7
|
+
from austial.common.decorators.modules import Module
|
|
8
|
+
from austial.core.dynamic_module import DynamicModule
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@Module()
|
|
12
|
+
class AuthzModule:
|
|
13
|
+
@staticmethod
|
|
14
|
+
def for_root(*, is_global: bool = True) -> DynamicModule:
|
|
15
|
+
return DynamicModule(
|
|
16
|
+
module=AuthzModule,
|
|
17
|
+
providers=[RolesGuard, PermissionService],
|
|
18
|
+
exports=[RolesGuard, PermissionService],
|
|
19
|
+
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``@Roles(...)``, ``@Permissions(...)``, ``@AuthorizeObject(rule)`` -- metadata
|
|
2
|
+
decorators the ``RolesGuard`` reads.
|
|
3
|
+
|
|
4
|
+
Semantics:
|
|
5
|
+
|
|
6
|
+
- ``@Roles(*names)`` -- caller must hold **any** of the listed roles.
|
|
7
|
+
- ``@Permissions(*names)`` -- caller must hold **all** of the listed permissions.
|
|
8
|
+
- ``@AuthorizeObject(rule)`` -- takes a callable ``(principal, request) -> bool``.
|
|
9
|
+
Runs after roles/permissions succeed, so it is the object-level gate.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from austial.core.metadata import get_metadata, set_metadata
|
|
18
|
+
|
|
19
|
+
ROLES_METADATA = "austial:authz:roles"
|
|
20
|
+
PERMISSIONS_METADATA = "austial:authz:permissions"
|
|
21
|
+
AUTHORIZE_OBJECT_METADATA = "austial:authz:authorize_object"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def Roles(*roles: str) -> Callable: # noqa: N802
|
|
25
|
+
"""Require the principal to hold ANY of the given roles."""
|
|
26
|
+
|
|
27
|
+
def decorator(target):
|
|
28
|
+
existing = tuple(get_metadata(ROLES_METADATA, target, ()))
|
|
29
|
+
set_metadata(ROLES_METADATA, existing + tuple(roles))(target)
|
|
30
|
+
return target
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def Permissions(*permissions: str) -> Callable: # noqa: N802
|
|
36
|
+
"""Require the principal to hold ALL of the given permissions."""
|
|
37
|
+
|
|
38
|
+
def decorator(target):
|
|
39
|
+
existing = tuple(get_metadata(PERMISSIONS_METADATA, target, ()))
|
|
40
|
+
set_metadata(PERMISSIONS_METADATA, existing + tuple(permissions))(target)
|
|
41
|
+
return target
|
|
42
|
+
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def AuthorizeObject(rule: Callable[[Any, Any], bool]) -> Callable: # noqa: N802
|
|
47
|
+
"""Attach an object-level authorisation callable to a handler.
|
|
48
|
+
|
|
49
|
+
``rule(principal, request) -> bool`` runs after role/permission checks pass.
|
|
50
|
+
Returning ``False`` (or raising) rejects the request."""
|
|
51
|
+
|
|
52
|
+
def decorator(target):
|
|
53
|
+
set_metadata(AUTHORIZE_OBJECT_METADATA, rule)(target)
|
|
54
|
+
return target
|
|
55
|
+
|
|
56
|
+
return decorator
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Authorisation-specific exceptions -- 403 with sub-types for observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from austial.common.exceptions import ForbiddenException
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InsufficientRolesError(ForbiddenException):
|
|
9
|
+
def __init__(self, required: tuple[str, ...], actual: tuple[str, ...]):
|
|
10
|
+
super().__init__(f"Missing required role(s): {list(required)}")
|
|
11
|
+
self.required = required
|
|
12
|
+
self.actual = actual
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InsufficientPermissionsError(ForbiddenException):
|
|
16
|
+
def __init__(self, required: tuple[str, ...], actual: tuple[str, ...]):
|
|
17
|
+
super().__init__(f"Missing required permission(s): {list(required)}")
|
|
18
|
+
self.required = required
|
|
19
|
+
self.actual = actual
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``PermissionService`` -- programmatic role/permission checks.
|
|
2
|
+
|
|
3
|
+
Use from services or background jobs where a decorator is inconvenient. Also
|
|
4
|
+
carries the type alias used by ``@AuthorizeObject``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from austial.common.decorators.injectable import Injectable
|
|
13
|
+
|
|
14
|
+
# ``(principal, request_or_context) -> bool``. Product code implements the rule.
|
|
15
|
+
ObjectAuthorizationRule = Callable[[Any, Any], bool]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
class PermissionService:
|
|
20
|
+
@staticmethod
|
|
21
|
+
def has_any_role(principal: Any, roles: tuple[str, ...]) -> bool:
|
|
22
|
+
"""True if ``principal.claims['roles']`` intersects ``roles``. Missing
|
|
23
|
+
claim => False."""
|
|
24
|
+
actual = _actual_roles(principal)
|
|
25
|
+
return any(r in actual for r in roles)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def has_all_permissions(principal: Any, permissions: tuple[str, ...]) -> bool:
|
|
29
|
+
"""True if every requested permission is in ``principal.claims['permissions']``."""
|
|
30
|
+
actual = _actual_permissions(principal)
|
|
31
|
+
return all(p in actual for p in permissions)
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def get_roles(principal: Any) -> tuple[str, ...]:
|
|
35
|
+
return _actual_roles(principal)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def get_permissions(principal: Any) -> tuple[str, ...]:
|
|
39
|
+
return _actual_permissions(principal)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _actual_roles(principal: Any) -> tuple[str, ...]:
|
|
43
|
+
if principal is None or not hasattr(principal, "claims"):
|
|
44
|
+
return ()
|
|
45
|
+
claims = principal.claims or {}
|
|
46
|
+
value = claims.get("roles", ()) if isinstance(claims, dict) else ()
|
|
47
|
+
return tuple(value) if isinstance(value, list | tuple) else ()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _actual_permissions(principal: Any) -> tuple[str, ...]:
|
|
51
|
+
if principal is None or not hasattr(principal, "claims"):
|
|
52
|
+
return ()
|
|
53
|
+
claims = principal.claims or {}
|
|
54
|
+
value = claims.get("permissions", ()) if isinstance(claims, dict) else ()
|
|
55
|
+
return tuple(value) if isinstance(value, list | tuple) else ()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``RolesGuard`` -- checks ``@Roles``, ``@Permissions``, then ``@AuthorizeObject``.
|
|
2
|
+
|
|
3
|
+
Order matters: cheap claim checks first, expensive object-level rule last.
|
|
4
|
+
Reads the authenticated principal from ``request.state.user`` (set by
|
|
5
|
+
``AuthGuard``). No principal + no metadata => allow (the route is public
|
|
6
|
+
w.r.t. authz) unless ``deny_undeclared`` is set. No principal + metadata => 403.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from austial.authz.decorators import (
|
|
14
|
+
AUTHORIZE_OBJECT_METADATA,
|
|
15
|
+
PERMISSIONS_METADATA,
|
|
16
|
+
ROLES_METADATA,
|
|
17
|
+
)
|
|
18
|
+
from austial.authz.errors import InsufficientPermissionsError, InsufficientRolesError
|
|
19
|
+
from austial.authz.permission_service import PermissionService
|
|
20
|
+
from austial.common.decorators.injectable import Injectable
|
|
21
|
+
from austial.common.exceptions import ForbiddenException
|
|
22
|
+
from austial.common.guards.base import CanActivate, ExecutionContext
|
|
23
|
+
from austial.core.metadata import get_metadata
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _merge(handler_meta: tuple[Any, ...], class_meta: tuple[Any, ...]) -> tuple[Any, ...]:
|
|
27
|
+
"""Union of class-level and handler-level metadata (both apply)."""
|
|
28
|
+
return tuple(dict.fromkeys(class_meta + handler_meta))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@Injectable()
|
|
32
|
+
class RolesGuard(CanActivate):
|
|
33
|
+
#: Deny-by-default. ``False`` (the default) lets a handler that declares no ``@Roles``,
|
|
34
|
+
#: ``@Permissions`` or ``@AuthorizeObject`` through, so a forgotten decorator is silently open to
|
|
35
|
+
#: every authenticated caller. A product that wants "every route must declare a policy" subclasses
|
|
36
|
+
#: and sets ``True``: an undeclared handler is then a 403 (the subclass lets ``@Public`` routes
|
|
37
|
+
#: through first).
|
|
38
|
+
deny_undeclared: bool = False
|
|
39
|
+
|
|
40
|
+
async def can_activate(self, context: ExecutionContext) -> bool:
|
|
41
|
+
handler = context.get_handler()
|
|
42
|
+
controller = context.get_class()
|
|
43
|
+
|
|
44
|
+
required_roles = _merge(
|
|
45
|
+
tuple(get_metadata(ROLES_METADATA, handler, ())),
|
|
46
|
+
tuple(get_metadata(ROLES_METADATA, controller, ())),
|
|
47
|
+
)
|
|
48
|
+
required_perms = _merge(
|
|
49
|
+
tuple(get_metadata(PERMISSIONS_METADATA, handler, ())),
|
|
50
|
+
tuple(get_metadata(PERMISSIONS_METADATA, controller, ())),
|
|
51
|
+
)
|
|
52
|
+
object_rule = get_metadata(AUTHORIZE_OBJECT_METADATA, handler, None) or get_metadata(
|
|
53
|
+
AUTHORIZE_OBJECT_METADATA, controller, None
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not required_roles and not required_perms and object_rule is None:
|
|
57
|
+
if self.deny_undeclared:
|
|
58
|
+
raise ForbiddenException("No access policy declared for this route")
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
request = context.get_request()
|
|
62
|
+
principal = getattr(request.state, "user", None)
|
|
63
|
+
if principal is None:
|
|
64
|
+
# metadata requires something, no principal => fail closed
|
|
65
|
+
raise ForbiddenException("Authentication required")
|
|
66
|
+
|
|
67
|
+
if required_roles and not PermissionService.has_any_role(principal, required_roles):
|
|
68
|
+
raise InsufficientRolesError(required_roles, PermissionService.get_roles(principal))
|
|
69
|
+
|
|
70
|
+
if required_perms and not PermissionService.has_all_permissions(principal, required_perms):
|
|
71
|
+
raise InsufficientPermissionsError(required_perms, PermissionService.get_permissions(principal))
|
|
72
|
+
|
|
73
|
+
if object_rule is not None:
|
|
74
|
+
ok = object_rule(principal, request)
|
|
75
|
+
if not ok:
|
|
76
|
+
raise ForbiddenException("Object authorisation denied")
|
|
77
|
+
|
|
78
|
+
return True
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""``austial.authz`` unit tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from austial.authz import (
|
|
10
|
+
AuthorizeObject,
|
|
11
|
+
InsufficientPermissionsError,
|
|
12
|
+
InsufficientRolesError,
|
|
13
|
+
Permissions,
|
|
14
|
+
PermissionService,
|
|
15
|
+
Roles,
|
|
16
|
+
RolesGuard,
|
|
17
|
+
)
|
|
18
|
+
from austial.common import ExecutionContext
|
|
19
|
+
from austial.common.exceptions import ForbiddenException
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _FakeCtx(ExecutionContext):
|
|
23
|
+
def __init__(self, request, controller_class, handler):
|
|
24
|
+
self._req, self._cls, self._h = request, controller_class, handler
|
|
25
|
+
|
|
26
|
+
def get_request(self):
|
|
27
|
+
return self._req
|
|
28
|
+
|
|
29
|
+
def get_class(self):
|
|
30
|
+
return self._cls
|
|
31
|
+
|
|
32
|
+
def get_handler(self):
|
|
33
|
+
return self._h
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _principal(roles=(), permissions=()):
|
|
37
|
+
return SimpleNamespace(claims={"roles": list(roles), "permissions": list(permissions)})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _request(principal):
|
|
41
|
+
return SimpleNamespace(state=SimpleNamespace(user=principal))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# PermissionService ---------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_has_any_role_true_when_one_matches():
|
|
48
|
+
p = _principal(roles=("ops", "compliance"))
|
|
49
|
+
assert PermissionService.has_any_role(p, ("compliance",)) is True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_has_any_role_false_on_disjoint():
|
|
53
|
+
p = _principal(roles=("ops",))
|
|
54
|
+
assert PermissionService.has_any_role(p, ("admin",)) is False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_has_all_permissions_requires_every_one():
|
|
58
|
+
p = _principal(permissions=("read:invoice", "write:invoice"))
|
|
59
|
+
assert PermissionService.has_all_permissions(p, ("read:invoice",)) is True
|
|
60
|
+
assert PermissionService.has_all_permissions(p, ("read:invoice", "write:invoice")) is True
|
|
61
|
+
assert PermissionService.has_all_permissions(p, ("read:invoice", "delete:invoice")) is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_has_any_role_false_for_missing_principal():
|
|
65
|
+
assert PermissionService.has_any_role(None, ("ops",)) is False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# RolesGuard ----------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_guard_allows_when_no_metadata_present():
|
|
73
|
+
guard = RolesGuard()
|
|
74
|
+
|
|
75
|
+
def handler():
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
ctx = _FakeCtx(_request(None), object, handler)
|
|
79
|
+
assert await guard.can_activate(ctx) is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_guard_rejects_without_principal_when_metadata_present():
|
|
84
|
+
guard = RolesGuard()
|
|
85
|
+
|
|
86
|
+
@Roles("ops")
|
|
87
|
+
def handler():
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
ctx = _FakeCtx(_request(None), object, handler)
|
|
91
|
+
with pytest.raises(ForbiddenException):
|
|
92
|
+
await guard.can_activate(ctx)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@pytest.mark.asyncio
|
|
96
|
+
async def test_guard_allows_role_match():
|
|
97
|
+
guard = RolesGuard()
|
|
98
|
+
|
|
99
|
+
@Roles("ops", "admin")
|
|
100
|
+
def handler():
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
ctx = _FakeCtx(_request(_principal(roles=("ops",))), object, handler)
|
|
104
|
+
assert await guard.can_activate(ctx) is True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_guard_rejects_role_mismatch():
|
|
109
|
+
guard = RolesGuard()
|
|
110
|
+
|
|
111
|
+
@Roles("admin")
|
|
112
|
+
def handler():
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
ctx = _FakeCtx(_request(_principal(roles=("ops",))), object, handler)
|
|
116
|
+
with pytest.raises(InsufficientRolesError):
|
|
117
|
+
await guard.can_activate(ctx)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_guard_rejects_missing_permission():
|
|
122
|
+
guard = RolesGuard()
|
|
123
|
+
|
|
124
|
+
@Permissions("delete:invoice")
|
|
125
|
+
def handler():
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
ctx = _FakeCtx(_request(_principal(permissions=("read:invoice",))), object, handler)
|
|
129
|
+
with pytest.raises(InsufficientPermissionsError):
|
|
130
|
+
await guard.can_activate(ctx)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.asyncio
|
|
134
|
+
async def test_guard_runs_object_rule_last():
|
|
135
|
+
guard = RolesGuard()
|
|
136
|
+
|
|
137
|
+
def rule(principal, request):
|
|
138
|
+
return principal.claims.get("subject") == "owner"
|
|
139
|
+
|
|
140
|
+
@Roles("ops")
|
|
141
|
+
@AuthorizeObject(rule)
|
|
142
|
+
def handler():
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
p_ok = _principal(roles=("ops",))
|
|
146
|
+
p_ok.claims["subject"] = "owner"
|
|
147
|
+
ctx_ok = _FakeCtx(_request(p_ok), object, handler)
|
|
148
|
+
assert await guard.can_activate(ctx_ok) is True
|
|
149
|
+
|
|
150
|
+
p_no = _principal(roles=("ops",))
|
|
151
|
+
ctx_no = _FakeCtx(_request(p_no), object, handler)
|
|
152
|
+
with pytest.raises(ForbiddenException):
|
|
153
|
+
await guard.can_activate(ctx_no)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_guard_merges_class_and_handler_metadata():
|
|
158
|
+
guard = RolesGuard()
|
|
159
|
+
|
|
160
|
+
@Roles("admin")
|
|
161
|
+
class Ctrl:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
@Roles("ops")
|
|
165
|
+
def handler():
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# principal with "ops" satisfies the union (any-of), not the intersection
|
|
169
|
+
ctx = _FakeCtx(_request(_principal(roles=("ops",))), Ctrl, handler)
|
|
170
|
+
assert await guard.can_activate(ctx) is True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@pytest.mark.asyncio
|
|
174
|
+
async def test_deny_undeclared_guard_rejects_handler_without_a_policy():
|
|
175
|
+
class StrictRolesGuard(RolesGuard):
|
|
176
|
+
deny_undeclared = True
|
|
177
|
+
|
|
178
|
+
def undeclared():
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
@Roles("ops")
|
|
182
|
+
def declared():
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
strict = StrictRolesGuard()
|
|
186
|
+
with pytest.raises(ForbiddenException):
|
|
187
|
+
await strict.can_activate(_FakeCtx(_request(_principal(roles=("ops",))), object, undeclared))
|
|
188
|
+
assert await strict.can_activate(_FakeCtx(_request(_principal(roles=("ops",))), object, declared)) is True
|