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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .env
5
+ *.db
6
+ .pytest_cache/
7
+ .coverage
8
+ htmlcov/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .DS_Store
@@ -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