austial-authz 0.2.0__py3-none-any.whl
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/__init__.py +41 -0
- austial/authz/authz_module.py +19 -0
- austial/authz/decorators.py +56 -0
- austial/authz/errors.py +19 -0
- austial/authz/permission_service.py +55 -0
- austial/authz/roles_guard.py +78 -0
- austial_authz-0.2.0.dist-info/METADATA +8 -0
- austial_authz-0.2.0.dist-info/RECORD +9 -0
- austial_authz-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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
|
austial/authz/errors.py
ADDED
|
@@ -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,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,9 @@
|
|
|
1
|
+
austial/authz/__init__.py,sha256=cdWh0ZEtg81C5HDJhabu2BhB1RvkWe5AWQ6VNASI6X0,1475
|
|
2
|
+
austial/authz/authz_module.py,sha256=Elgv2SI9dUiSoP10sUVWzvAuUO76Lj4thTayrfY11q0,637
|
|
3
|
+
austial/authz/decorators.py,sha256=G4G6oPT9r4sTgY3ESyG5yCj8t9SLooIOIrMgH0-Cm9g,1867
|
|
4
|
+
austial/authz/errors.py,sha256=SSt5U3CyQVpu47-CH_ya9FD3clzQqIMAVzVVRlzyx5A,711
|
|
5
|
+
austial/authz/permission_service.py,sha256=VqcqIALw4nfDYHxL8LYjXCWCTxcT_mm0GruW_FX5ers,1984
|
|
6
|
+
austial/authz/roles_guard.py,sha256=bXAqphlTlJIpKrabsTV7oWyVgEN9halOkmnR6VYxCIk,3430
|
|
7
|
+
austial_authz-0.2.0.dist-info/METADATA,sha256=pnM07TFWggIjCrG82FF1IDSP6bNp0iQPh0XMn5Wd-LA,302
|
|
8
|
+
austial_authz-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
9
|
+
austial_authz-0.2.0.dist-info/RECORD,,
|