pkg-auth 3.0.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.
- pkg_auth/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Authorization (ACL) exceptions.
|
|
2
|
+
|
|
3
|
+
These are deliberately a *separate* hierarchy from
|
|
4
|
+
``pkg_auth.authentication.domain.exceptions``: authentication failures
|
|
5
|
+
are 401, authorization failures are 403/404. Integration layers map
|
|
6
|
+
each to the right HTTP status.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthorizationError(Exception):
|
|
12
|
+
"""Base for all authorization (ACL) failures."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotAMember(AuthorizationError):
|
|
16
|
+
"""The user has no membership in the requested organization."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MissingPermission(AuthorizationError):
|
|
20
|
+
"""The user's role in the organization does not include the required permission."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnknownOrganization(AuthorizationError):
|
|
24
|
+
"""The referenced organization does not exist."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnknownUser(AuthorizationError):
|
|
28
|
+
"""The referenced user does not exist."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserNotProvisioned(AuthorizationError):
|
|
32
|
+
"""JWT-authenticated user has no row in the local ACL ``users`` table.
|
|
33
|
+
|
|
34
|
+
Raised by :class:`ResolveUserFromJwtUseCase` in Mode B (consuming)
|
|
35
|
+
services: the caller is a valid Keycloak principal, but the
|
|
36
|
+
source-of-truth service (Mode A) hasn't mirrored them into the
|
|
37
|
+
shared ACL yet. Integration layers map this to HTTP 403.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnknownRole(AuthorizationError):
|
|
42
|
+
"""The referenced role does not exist."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnknownPermission(AuthorizationError):
|
|
46
|
+
"""A referenced permission key is not registered in the catalog."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PermissionVisibilityConflict(AuthorizationError):
|
|
50
|
+
"""A role tried to use a permission whose visibility forbids it.
|
|
51
|
+
|
|
52
|
+
Raised when a platform-org role references a ``tenant_only`` permission,
|
|
53
|
+
or a normal-org role references a ``platform_only`` permission.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UnknownService(AuthorizationError):
|
|
58
|
+
"""The referenced service is not registered in the ``services`` table."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ServiceNotSaaSAvailable(AuthorizationError):
|
|
62
|
+
"""A platform admin tried to enable a service the vendor has not marked
|
|
63
|
+
``saas_available``. Integration layers map this to HTTP 403.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ServiceNotEnabled(AuthorizationError):
|
|
68
|
+
"""The organization does not have the required service enabled."""
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Authorization domain ports (Protocol-based repositories).
|
|
2
|
+
|
|
3
|
+
These Protocols are the contract between the application layer and the
|
|
4
|
+
adapter layer. Adapters live under ``pkg_auth.authorization.adapters``
|
|
5
|
+
(SQLAlchemy, Django ORM, cache decorator). The application layer imports
|
|
6
|
+
only from this module; it must not import any concrete adapter.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Iterable, Literal, Protocol, Sequence
|
|
11
|
+
|
|
12
|
+
from .entities import (
|
|
13
|
+
AuthContext,
|
|
14
|
+
Membership,
|
|
15
|
+
Organization,
|
|
16
|
+
OrganizationService,
|
|
17
|
+
Permission,
|
|
18
|
+
Role,
|
|
19
|
+
Service,
|
|
20
|
+
User,
|
|
21
|
+
)
|
|
22
|
+
from .value_objects import (
|
|
23
|
+
OrgId,
|
|
24
|
+
PermissionKey,
|
|
25
|
+
RoleId,
|
|
26
|
+
RoleName,
|
|
27
|
+
ServiceName,
|
|
28
|
+
UserId,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..application.use_cases.register_permission_catalog import (
|
|
33
|
+
CatalogEntry,
|
|
34
|
+
)
|
|
35
|
+
from ..application.use_cases.sync_service_catalog import ServiceSpec
|
|
36
|
+
|
|
37
|
+
# Role-builder visibility filter:
|
|
38
|
+
# - "platform" → platform_only ∪ shared (what a platform-org role may use)
|
|
39
|
+
# - "tenant"/"org" → shared ∪ tenant_only (what a normal-org role may use)
|
|
40
|
+
# - "all" → no filter
|
|
41
|
+
# "org" is kept as a backward-compatible alias for "tenant".
|
|
42
|
+
PermissionScope = Literal["org", "tenant", "platform", "all"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UserRepository(Protocol):
|
|
46
|
+
"""Read/write access to the ``users`` table.
|
|
47
|
+
|
|
48
|
+
The package lazily upserts users on first JWT sight via
|
|
49
|
+
:meth:`upsert_from_identity`; explicit creation is not exposed.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
async def get_by_id(self, user_id: UserId) -> User | None: ...
|
|
53
|
+
async def get_by_keycloak_sub(self, sub: str) -> User | None: ...
|
|
54
|
+
async def upsert_from_identity(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
sub: str,
|
|
58
|
+
email: str,
|
|
59
|
+
full_name: str | None,
|
|
60
|
+
) -> User: ...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OrganizationRepository(Protocol):
|
|
64
|
+
"""Read/write access to the ``organizations`` table."""
|
|
65
|
+
|
|
66
|
+
async def get(self, org_id: OrgId) -> Organization | None: ...
|
|
67
|
+
async def get_by_slug(self, slug: str) -> Organization | None: ...
|
|
68
|
+
async def create(self, *, slug: str, name: str) -> Organization: ...
|
|
69
|
+
async def update(
|
|
70
|
+
self, org_id: OrgId, *, name: str | None
|
|
71
|
+
) -> Organization: ...
|
|
72
|
+
async def delete(self, org_id: OrgId) -> None: ...
|
|
73
|
+
async def list_for_user(self, user_id: UserId) -> list[Organization]: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RoleRepository(Protocol):
|
|
77
|
+
"""Read/write access to the ``roles`` table (and the ``role_permissions`` join)."""
|
|
78
|
+
|
|
79
|
+
async def get(self, role_id: RoleId) -> Role | None: ...
|
|
80
|
+
async def get_by_name(
|
|
81
|
+
self, org_id: OrgId | None, name: RoleName
|
|
82
|
+
) -> Role | None: ...
|
|
83
|
+
async def create(
|
|
84
|
+
self,
|
|
85
|
+
*,
|
|
86
|
+
org_id: OrgId | None,
|
|
87
|
+
name: RoleName,
|
|
88
|
+
description: str | None,
|
|
89
|
+
permission_keys: Sequence[PermissionKey],
|
|
90
|
+
) -> Role: ...
|
|
91
|
+
async def update(
|
|
92
|
+
self,
|
|
93
|
+
role_id: RoleId,
|
|
94
|
+
*,
|
|
95
|
+
name: RoleName | None,
|
|
96
|
+
description: str | None,
|
|
97
|
+
permission_keys: Sequence[PermissionKey] | None,
|
|
98
|
+
) -> Role: ...
|
|
99
|
+
async def delete(self, role_id: RoleId) -> None: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MembershipRepository(Protocol):
|
|
103
|
+
"""Read/write access to the ``memberships`` table.
|
|
104
|
+
|
|
105
|
+
The hot path is :meth:`load_auth_context`, which the FastAPI / Django /
|
|
106
|
+
Strawberry deps call on every protected request. SQLAlchemy / Django
|
|
107
|
+
implementations should do this in one query (joined with the role and
|
|
108
|
+
its permissions) so the call site never round-trips for individual
|
|
109
|
+
perms.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
async def get(
|
|
113
|
+
self, user_id: UserId, org_id: OrgId
|
|
114
|
+
) -> Membership | None: ...
|
|
115
|
+
async def upsert(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
user_id: UserId,
|
|
119
|
+
org_id: OrgId,
|
|
120
|
+
role_id: RoleId,
|
|
121
|
+
status: str,
|
|
122
|
+
) -> Membership: ...
|
|
123
|
+
async def delete(self, user_id: UserId, org_id: OrgId) -> None: ...
|
|
124
|
+
async def load_auth_context(
|
|
125
|
+
self, user_id: UserId, org_id: OrgId
|
|
126
|
+
) -> AuthContext | None: ...
|
|
127
|
+
async def list_for_user(self, user_id: UserId) -> list[Membership]: ...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PermissionCatalogRepository(Protocol):
|
|
131
|
+
"""Read/write access to the ``permissions`` table (the global perm catalog).
|
|
132
|
+
|
|
133
|
+
The ``scope`` argument on the list methods filters by each row's
|
|
134
|
+
``visibility``:
|
|
135
|
+
|
|
136
|
+
- ``"platform"`` → platform_only ∪ shared
|
|
137
|
+
- ``"tenant"``/``"org"`` → shared ∪ tenant_only
|
|
138
|
+
- ``"all"`` → no filter (default)
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
async def register_many(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
service_name: str,
|
|
145
|
+
entries: Sequence["CatalogEntry"],
|
|
146
|
+
) -> None: ...
|
|
147
|
+
async def list_all(
|
|
148
|
+
self, *, scope: PermissionScope = "all"
|
|
149
|
+
) -> list[Permission]: ...
|
|
150
|
+
async def list_for_service(
|
|
151
|
+
self, service_name: str, *, scope: PermissionScope = "all"
|
|
152
|
+
) -> list[Permission]: ...
|
|
153
|
+
async def get_service_map(self) -> dict[str, str]:
|
|
154
|
+
"""Return a ``{permission_key: service_name}`` map for the whole
|
|
155
|
+
catalog. Used by the service guard in
|
|
156
|
+
:class:`ResolveAuthContextUseCase` to map a user's perm keys to the
|
|
157
|
+
services that own them. Small and slow-changing → cacheable.
|
|
158
|
+
"""
|
|
159
|
+
...
|
|
160
|
+
async def prune_absent(
|
|
161
|
+
self,
|
|
162
|
+
*,
|
|
163
|
+
service_name: str,
|
|
164
|
+
keep_keys: Iterable[PermissionKey],
|
|
165
|
+
) -> int:
|
|
166
|
+
"""Delete permissions owned by ``service_name`` whose key is NOT in
|
|
167
|
+
``keep_keys``. Returns the number of rows deleted.
|
|
168
|
+
|
|
169
|
+
If ``keep_keys`` is empty, every row for ``service_name`` is deleted.
|
|
170
|
+
The FK ``role_permissions.permission_id`` is ``ON DELETE CASCADE``, so
|
|
171
|
+
role assignments referencing the pruned rows are silently dropped.
|
|
172
|
+
"""
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ServiceRepository(Protocol):
|
|
177
|
+
"""Read/write access to the ``services`` table (the service registry).
|
|
178
|
+
|
|
179
|
+
Vendor-controlled flags (``auto_provision``, ``saas_available``) are
|
|
180
|
+
written only via :meth:`upsert_many` (the ``pkg-auth-sync-services``
|
|
181
|
+
path). :meth:`ensure_exists` is called during permission-catalog
|
|
182
|
+
registration to create a bare row with safe defaults so the
|
|
183
|
+
default-deny guard does not strip a newly-registered service's perms
|
|
184
|
+
before the vendor configures it; it must NOT overwrite existing flags.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
async def upsert_many(self, services: Sequence["ServiceSpec"]) -> None: ...
|
|
188
|
+
async def ensure_exists(self, *, service_name: str) -> None: ...
|
|
189
|
+
async def get(self, name: ServiceName) -> Service | None: ...
|
|
190
|
+
async def list_all(self) -> list[Service]: ...
|
|
191
|
+
async def prune_absent(
|
|
192
|
+
self, *, keep: Iterable[ServiceName]
|
|
193
|
+
) -> int: ...
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class OrganizationServiceRepository(Protocol):
|
|
197
|
+
"""Read/write access to the ``organization_services`` table (per-org
|
|
198
|
+
service entitlements that drive the service guard).
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
async def list_enabled_service_names(self, org_id: OrgId) -> set[str]: ...
|
|
202
|
+
async def get(
|
|
203
|
+
self, org_id: OrgId, service_name: ServiceName
|
|
204
|
+
) -> OrganizationService | None: ...
|
|
205
|
+
async def enable(
|
|
206
|
+
self, org_id: OrgId, service_name: ServiceName, *, source: str
|
|
207
|
+
) -> OrganizationService: ...
|
|
208
|
+
async def disable(
|
|
209
|
+
self, org_id: OrgId, service_name: ServiceName
|
|
210
|
+
) -> None: ...
|
|
211
|
+
async def bulk_enable(
|
|
212
|
+
self,
|
|
213
|
+
org_id: OrgId,
|
|
214
|
+
service_names: Sequence[ServiceName],
|
|
215
|
+
*,
|
|
216
|
+
source: str,
|
|
217
|
+
) -> None: ...
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Authorization value objects (typed wrappers around DB IDs and string keys)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
# A service name / slug: lowercase letter first, then lowercase letters,
|
|
11
|
+
# digits, ``_`` or ``-``. Matches the shape of the ``service_name`` strings
|
|
12
|
+
# already stamped on permissions (e.g. ``"courses"``, ``"media-library"``).
|
|
13
|
+
_SERVICE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
|
|
14
|
+
|
|
15
|
+
# Format:
|
|
16
|
+
# - one or more colon-separated segments
|
|
17
|
+
# - each segment must start with a lowercase letter
|
|
18
|
+
# - each segment may contain lowercase letters, digits, ``_``, ``-``
|
|
19
|
+
# - at least one colon (so single bare words like ``"admin"`` are rejected)
|
|
20
|
+
#
|
|
21
|
+
# Examples (valid): "course:edit", "media-library:upload", "billing:invoice:refund"
|
|
22
|
+
# Examples (invalid): "admin", "Course:edit", ":x", "x:", "1course:edit"
|
|
23
|
+
_PERMISSION_KEY_PATTERN = re.compile(
|
|
24
|
+
r"^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)+$"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class UserId:
|
|
30
|
+
"""Primary key of a row in the users table (UUID)."""
|
|
31
|
+
|
|
32
|
+
value: UUID
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
return str(self.value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class OrgId:
|
|
40
|
+
"""Primary key of a row in the organizations table (UUID)."""
|
|
41
|
+
|
|
42
|
+
value: UUID
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return str(self.value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class RoleId:
|
|
50
|
+
"""Primary key of a row in the roles table (UUID)."""
|
|
51
|
+
|
|
52
|
+
value: UUID
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return str(self.value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class PermissionId:
|
|
60
|
+
"""Primary key of a row in the permissions table (UUID)."""
|
|
61
|
+
|
|
62
|
+
value: UUID
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return str(self.value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class RoleName:
|
|
70
|
+
"""Human-readable role name. Unique per ``(organization_id, name)``."""
|
|
71
|
+
|
|
72
|
+
value: str
|
|
73
|
+
|
|
74
|
+
def __str__(self) -> str:
|
|
75
|
+
return self.value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class PermissionKey:
|
|
80
|
+
"""A permission key like ``"course:edit"`` or ``"billing:invoice:refund"``.
|
|
81
|
+
|
|
82
|
+
Format (validated in ``__post_init__``):
|
|
83
|
+
|
|
84
|
+
- one or more colon-separated segments
|
|
85
|
+
- each segment must start with a lowercase letter
|
|
86
|
+
- each segment may contain lowercase letters, digits, ``_``, ``-``
|
|
87
|
+
- at least one colon (so single bare words like ``"admin"`` are
|
|
88
|
+
rejected — they don't carry resource context)
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: when the value does not match the format.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
value: str
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
if not _PERMISSION_KEY_PATTERN.match(self.value):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Invalid permission key {self.value!r}: "
|
|
100
|
+
f"must be 'resource:action' (lowercase, colon-separated)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def __str__(self) -> str:
|
|
104
|
+
return self.value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PermissionVisibility(str, Enum):
|
|
108
|
+
"""Where a permission may be used / shown in role builders.
|
|
109
|
+
|
|
110
|
+
- ``PLATFORM_ONLY`` — only the platform org (cross-org admin perms,
|
|
111
|
+
e.g. ``organizations:create``). Hidden from normal-org role builders.
|
|
112
|
+
- ``SHARED`` — usable everywhere (the historical default).
|
|
113
|
+
- ``TENANT_ONLY`` — only normal organizations; **hidden from the
|
|
114
|
+
platform org**. Use for perms that make no sense for the vendor's
|
|
115
|
+
platform admins.
|
|
116
|
+
|
|
117
|
+
Replaces the old ``is_platform`` boolean: ``is_platform=True`` maps to
|
|
118
|
+
``PLATFORM_ONLY`` and ``is_platform=False`` maps to ``SHARED``.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
PLATFORM_ONLY = "platform_only"
|
|
122
|
+
SHARED = "shared"
|
|
123
|
+
TENANT_ONLY = "tenant_only"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class ServiceName:
|
|
128
|
+
"""A service identifier such as ``"courses"`` or ``"assessments"``.
|
|
129
|
+
|
|
130
|
+
Same shape as the ``service_name`` string already carried on
|
|
131
|
+
permissions; wrapping it as a value object lets the new services /
|
|
132
|
+
organization_services tables share validation.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
value: str
|
|
136
|
+
|
|
137
|
+
def __post_init__(self) -> None:
|
|
138
|
+
if not _SERVICE_NAME_PATTERN.match(self.value):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Invalid service name {self.value!r}: must be a lowercase "
|
|
141
|
+
f"slug (letter first; letters, digits, '_' or '-')"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def __str__(self) -> str:
|
|
145
|
+
return self.value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True, slots=True)
|
|
149
|
+
class LocalizedText:
|
|
150
|
+
"""A locale → text map for user-facing strings (permission descriptions,
|
|
151
|
+
service labels) that must support multiple languages.
|
|
152
|
+
|
|
153
|
+
Stored as JSONB in the ACL database, e.g.
|
|
154
|
+
``{"en": "Edit course", "ar": "تعديل الدورة"}``. An empty map means
|
|
155
|
+
"no text provided". Values must be non-empty strings.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
values: Mapping[str, str]
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
for locale, text in self.values.items():
|
|
162
|
+
if not isinstance(locale, str) or not locale:
|
|
163
|
+
raise ValueError(f"Invalid locale key {locale!r}")
|
|
164
|
+
if not isinstance(text, str) or not text:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"Invalid localized value for {locale!r}: must be a "
|
|
167
|
+
f"non-empty string"
|
|
168
|
+
)
|
|
169
|
+
# Normalize to a plain immutable dict copy so callers can't mutate.
|
|
170
|
+
object.__setattr__(self, "values", dict(self.values))
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_input(
|
|
174
|
+
cls,
|
|
175
|
+
value: "LocalizedText | Mapping[str, str] | str | None",
|
|
176
|
+
*,
|
|
177
|
+
default_locale: str,
|
|
178
|
+
) -> "LocalizedText":
|
|
179
|
+
"""Coerce a plain string, a dict, ``None``, or a ``LocalizedText``
|
|
180
|
+
into a ``LocalizedText``. A bare string is stored under
|
|
181
|
+
``default_locale``; ``None`` becomes the empty map.
|
|
182
|
+
"""
|
|
183
|
+
if value is None:
|
|
184
|
+
return cls({})
|
|
185
|
+
if isinstance(value, LocalizedText):
|
|
186
|
+
return value
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
return cls({default_locale: value})
|
|
189
|
+
return cls(dict(value))
|
|
190
|
+
|
|
191
|
+
def get(self, locale: str, *, fallback: str | None = None) -> str | None:
|
|
192
|
+
"""Return the text for ``locale`` or ``fallback`` if absent."""
|
|
193
|
+
return self.values.get(locale, fallback)
|
|
194
|
+
|
|
195
|
+
def resolve(self, locale: str, default_locale: str) -> str | None:
|
|
196
|
+
"""Return ``locale``'s text, falling back to ``default_locale``, then
|
|
197
|
+
to any available value, then ``None``.
|
|
198
|
+
"""
|
|
199
|
+
if locale in self.values:
|
|
200
|
+
return self.values[locale]
|
|
201
|
+
if default_locale in self.values:
|
|
202
|
+
return self.values[default_locale]
|
|
203
|
+
for text in self.values.values():
|
|
204
|
+
return text
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def as_dict(self) -> dict[str, str]:
|
|
208
|
+
return dict(self.values)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Helpers for detecting platform-org context.
|
|
2
|
+
|
|
3
|
+
A "platform" organization is one whose members are granted cross-org
|
|
4
|
+
administrative privileges by the consuming service. There's nothing
|
|
5
|
+
special about its DB row — it's just an organization that the service
|
|
6
|
+
has designated as the platform org via configuration (slug, env var,
|
|
7
|
+
settings module, etc.).
|
|
8
|
+
|
|
9
|
+
The intended pattern:
|
|
10
|
+
|
|
11
|
+
1. The service caches the platform org's id at startup, e.g. by looking
|
|
12
|
+
it up via slug from its ``OrganizationRepository`` in a lifespan or
|
|
13
|
+
``AppConfig.ready()`` hook. Where the cache lives is up to the
|
|
14
|
+
service (module global, app config, request scope, …).
|
|
15
|
+
2. Platform admins send their requests with
|
|
16
|
+
``X-Organization-Id: <platform-org-slug>`` to surface their elevated
|
|
17
|
+
privileges. The auth dependency builds an :class:`AuthContext` whose
|
|
18
|
+
``organization_id`` is the platform org id.
|
|
19
|
+
3. Inside handlers, the service calls
|
|
20
|
+
:func:`is_platform_context` with the request's ``AuthContext`` and
|
|
21
|
+
the cached platform org id. When it returns ``True``, the handler
|
|
22
|
+
broadens its queryset filters (e.g. lists users from all orgs).
|
|
23
|
+
|
|
24
|
+
This module is intentionally stateless — pkg_auth does not own the
|
|
25
|
+
platform org id cache. Each consuming service decides where the cache
|
|
26
|
+
lives and when to refresh it.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from .domain.entities import AuthContext
|
|
31
|
+
from .domain.value_objects import OrgId
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_platform_context(
|
|
35
|
+
auth_ctx: AuthContext, platform_org_id: OrgId | None,
|
|
36
|
+
) -> bool:
|
|
37
|
+
"""Return True if the request is being made in the platform org.
|
|
38
|
+
|
|
39
|
+
``platform_org_id`` is whatever the consuming service has cached
|
|
40
|
+
(typically resolved by slug at startup). When ``None`` — for
|
|
41
|
+
example before the cache is initialized, or in services that
|
|
42
|
+
don't have a designated platform org — this returns ``False``,
|
|
43
|
+
which is equivalent to "no platform-admin privileges available".
|
|
44
|
+
"""
|
|
45
|
+
if platform_org_id is None:
|
|
46
|
+
return False
|
|
47
|
+
return auth_ctx.organization_id == platform_org_id
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Django integration for pkg_auth (identity + ACL).
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- ``IdentityMiddleware`` — validates JWT, attaches ``request.identity``
|
|
5
|
+
- ``AuthContextMiddleware`` — reads ``X-Organization-Id``, attaches ``request.auth_context``
|
|
6
|
+
- ``require_permission`` — function decorator for Django views
|
|
7
|
+
- ``install_pkg_auth`` — convenience wiring helper
|
|
8
|
+
|
|
9
|
+
Add ``"pkg_auth.integrations.django"`` to ``INSTALLED_APPS`` and the
|
|
10
|
+
two middlewares to ``MIDDLEWARE`` (in order — identity first).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import django # noqa: F401
|
|
16
|
+
except ImportError as exc: # pragma: no cover
|
|
17
|
+
raise ImportError(
|
|
18
|
+
"pkg_auth.integrations.django requires Django. "
|
|
19
|
+
"Install with: pip install pkg-auth[django]"
|
|
20
|
+
) from exc
|
|
21
|
+
|
|
22
|
+
from .auth_context_middleware import AuthContextMiddleware
|
|
23
|
+
from .decorators import require_permission
|
|
24
|
+
from .install import install_pkg_auth
|
|
25
|
+
from .middleware import IdentityMiddleware
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"IdentityMiddleware",
|
|
29
|
+
"AuthContextMiddleware",
|
|
30
|
+
"require_permission",
|
|
31
|
+
"install_pkg_auth",
|
|
32
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Django AppConfig for pkg_auth's framework integration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from django.apps import AppConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PkgAuthDjangoConfig(AppConfig):
|
|
8
|
+
name = "pkg_auth.integrations.django"
|
|
9
|
+
label = "pkg_auth_django"
|
|
10
|
+
verbose_name = "pkg_auth Django integration"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""AuthContextMiddleware — reads X-Organization-Id, attaches ``request.auth_context``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Awaitable, Callable
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from asgiref.sync import iscoroutinefunction
|
|
8
|
+
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
9
|
+
|
|
10
|
+
from ...authorization import (
|
|
11
|
+
NotAMember,
|
|
12
|
+
OrgId,
|
|
13
|
+
UnknownOrganization,
|
|
14
|
+
UserNotProvisioned,
|
|
15
|
+
)
|
|
16
|
+
from ...authorization.domain.entities import User
|
|
17
|
+
from .install import get_registry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthContextMiddleware:
|
|
21
|
+
"""Resolve and attach ``request.auth_context`` for protected routes.
|
|
22
|
+
|
|
23
|
+
Requires ``IdentityMiddleware`` to run first so ``request.identity``
|
|
24
|
+
is populated. Behavior:
|
|
25
|
+
|
|
26
|
+
- no header → ``request.auth_context = None`` (route decides)
|
|
27
|
+
- identity is None and header present → 401
|
|
28
|
+
- org not found → 404
|
|
29
|
+
- user not a member → 403
|
|
30
|
+
|
|
31
|
+
pkg_auth deliberately does NOT bake in a "platform admin fallback"
|
|
32
|
+
here. Platform-admin detection is a *service-level* policy: services
|
|
33
|
+
that want it call :func:`pkg_auth.authorization.is_platform_context`
|
|
34
|
+
inside views, comparing the request's ``AuthContext.organization_id``
|
|
35
|
+
against their own cached platform org id. Services that need to
|
|
36
|
+
*resolve* the caller against a platform org when they aren't a
|
|
37
|
+
member of the requested org can subclass this middleware and
|
|
38
|
+
intercept the ``NotAMember`` branch themselves.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
sync_capable = False
|
|
42
|
+
async_capable = True
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
get_response: Callable[[HttpRequest], Awaitable[HttpResponse]],
|
|
47
|
+
) -> None:
|
|
48
|
+
self.get_response = get_response
|
|
49
|
+
if not iscoroutinefunction(get_response):
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"AuthContextMiddleware requires async middleware chain "
|
|
52
|
+
"(use ASGI / runserver)."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
56
|
+
registry = get_registry()
|
|
57
|
+
request.auth_context = None # type: ignore[attr-defined]
|
|
58
|
+
|
|
59
|
+
raw = request.headers.get(registry.header_name)
|
|
60
|
+
if raw is None:
|
|
61
|
+
return await self.get_response(request)
|
|
62
|
+
|
|
63
|
+
identity = getattr(request, "identity", None)
|
|
64
|
+
if identity is None:
|
|
65
|
+
return JsonResponse(
|
|
66
|
+
{"detail": "Not authenticated"}, status=401,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
user: User
|
|
70
|
+
try:
|
|
71
|
+
if registry.sync_user is not None:
|
|
72
|
+
user = await registry.sync_user.execute(
|
|
73
|
+
sub=identity.subject_str,
|
|
74
|
+
email=identity.email_str or "",
|
|
75
|
+
full_name=identity.full_name,
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
assert registry.resolve_user is not None
|
|
79
|
+
user = await registry.resolve_user.execute(
|
|
80
|
+
sub=identity.subject_str,
|
|
81
|
+
)
|
|
82
|
+
except UserNotProvisioned as exc:
|
|
83
|
+
return JsonResponse({"detail": str(exc)}, status=403)
|
|
84
|
+
|
|
85
|
+
# Accept UUID or slug. The package switched to UUID PKs in v1.2;
|
|
86
|
+
# the legacy isdigit() fast-path is gone.
|
|
87
|
+
try:
|
|
88
|
+
org = await registry.organization_repo.get(OrgId(UUID(raw)))
|
|
89
|
+
except (ValueError, AttributeError):
|
|
90
|
+
org = await registry.organization_repo.get_by_slug(raw)
|
|
91
|
+
if org is None:
|
|
92
|
+
return JsonResponse(
|
|
93
|
+
{"detail": f"Organization {raw!r} not found"}, status=404,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
request.auth_context = await registry.resolve_auth.execute( # type: ignore[attr-defined]
|
|
98
|
+
user.id, org.id,
|
|
99
|
+
)
|
|
100
|
+
except NotAMember as exc:
|
|
101
|
+
return JsonResponse({"detail": str(exc)}, status=403)
|
|
102
|
+
except UnknownOrganization as exc:
|
|
103
|
+
return JsonResponse({"detail": str(exc)}, status=404)
|
|
104
|
+
|
|
105
|
+
return await self.get_response(request)
|