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.
Files changed (110) hide show
  1. pkg_auth/__init__.py +15 -0
  2. pkg_auth/admin/__init__.py +35 -0
  3. pkg_auth/admin/cli.py +87 -0
  4. pkg_auth/admin/client.py +401 -0
  5. pkg_auth/admin/env.py +74 -0
  6. pkg_auth/admin/helpers.py +113 -0
  7. pkg_auth/admin/provision_client.py +86 -0
  8. pkg_auth/admin/settings.py +33 -0
  9. pkg_auth/authentication/__init__.py +33 -0
  10. pkg_auth/authentication/adapters/__init__.py +1 -0
  11. pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
  12. pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
  13. pkg_auth/authentication/application/__init__.py +1 -0
  14. pkg_auth/authentication/application/use_cases/__init__.py +1 -0
  15. pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
  16. pkg_auth/authentication/domain/__init__.py +1 -0
  17. pkg_auth/authentication/domain/entities.py +50 -0
  18. pkg_auth/authentication/domain/exceptions.py +18 -0
  19. pkg_auth/authentication/domain/ports.py +26 -0
  20. pkg_auth/authentication/domain/value_objects.py +42 -0
  21. pkg_auth/authorization/__init__.py +117 -0
  22. pkg_auth/authorization/adapters/__init__.py +1 -0
  23. pkg_auth/authorization/adapters/cache/__init__.py +32 -0
  24. pkg_auth/authorization/adapters/cache/decorators.py +181 -0
  25. pkg_auth/authorization/adapters/cache/memory.py +61 -0
  26. pkg_auth/authorization/adapters/cache/protocol.py +36 -0
  27. pkg_auth/authorization/adapters/cache/redis.py +60 -0
  28. pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
  29. pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
  30. pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
  31. pkg_auth/authorization/adapters/django_orm/models.py +226 -0
  32. pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
  33. pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
  34. pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
  35. pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
  36. pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
  37. pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
  38. pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
  39. pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
  40. pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
  41. pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
  42. pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
  43. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
  44. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
  45. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
  46. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
  47. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
  48. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
  49. pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
  50. pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
  51. pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
  52. pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
  53. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
  54. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
  55. pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
  56. pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
  57. pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
  58. pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
  59. pkg_auth/authorization/application/__init__.py +1 -0
  60. pkg_auth/authorization/application/use_cases/__init__.py +1 -0
  61. pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
  62. pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
  63. pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
  64. pkg_auth/authorization/application/use_cases/create_role.py +69 -0
  65. pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
  66. pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
  67. pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
  68. pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
  69. pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
  70. pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
  71. pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
  72. pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
  73. pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
  74. pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
  75. pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
  76. pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
  77. pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
  78. pkg_auth/authorization/application/use_cases/update_role.py +61 -0
  79. pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
  80. pkg_auth/authorization/cli/__init__.py +1 -0
  81. pkg_auth/authorization/cli/sync_catalog.py +180 -0
  82. pkg_auth/authorization/cli/sync_services.py +151 -0
  83. pkg_auth/authorization/config.py +21 -0
  84. pkg_auth/authorization/domain/__init__.py +1 -0
  85. pkg_auth/authorization/domain/entities.py +192 -0
  86. pkg_auth/authorization/domain/exceptions.py +68 -0
  87. pkg_auth/authorization/domain/ports.py +217 -0
  88. pkg_auth/authorization/domain/value_objects.py +208 -0
  89. pkg_auth/authorization/platform.py +47 -0
  90. pkg_auth/integrations/__init__.py +0 -0
  91. pkg_auth/integrations/django/__init__.py +32 -0
  92. pkg_auth/integrations/django/apps.py +10 -0
  93. pkg_auth/integrations/django/auth_context_middleware.py +105 -0
  94. pkg_auth/integrations/django/decorators.py +74 -0
  95. pkg_auth/integrations/django/install.py +136 -0
  96. pkg_auth/integrations/django/middleware.py +63 -0
  97. pkg_auth/integrations/fastapi/__init__.py +26 -0
  98. pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
  99. pkg_auth/integrations/fastapi/auth_factory.py +84 -0
  100. pkg_auth/integrations/fastapi/decorators.py +55 -0
  101. pkg_auth/integrations/fastapi/errors.py +72 -0
  102. pkg_auth/integrations/fastapi/identity_dep.py +41 -0
  103. pkg_auth/integrations/strawberry/__init__.py +20 -0
  104. pkg_auth/integrations/strawberry/auth.py +137 -0
  105. pkg_auth/integrations/strawberry/permissions.py +56 -0
  106. pkg_auth-3.0.0.dist-info/METADATA +147 -0
  107. pkg_auth-3.0.0.dist-info/RECORD +110 -0
  108. pkg_auth-3.0.0.dist-info/WHEEL +5 -0
  109. pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
  110. 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)