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,226 @@
1
+ """Default concrete Django ORM mirror models for the ACL (UUID PKs).
2
+
3
+ .. warning::
4
+ **Mode B only.** These models declare ``Meta.managed = False`` and
5
+ map to the default ACL table names (``users``, ``organizations``,
6
+ …) so consuming services (Mode B) can read the shared ACL tables
7
+ via Django's ORM without owning the schema. The schema is owned
8
+ by the source-of-truth service.
9
+
10
+ **Do NOT import any class from this module into a Mode A service**
11
+ (one that extends the mixins to add service-specific columns, e.g.
12
+ a Django version of ``itq_users``). Mode A services own the ACL
13
+ schema, run their own migrations with ``Meta.managed = True``, and
14
+ define their own concrete models.
15
+
16
+ Mode A services must:
17
+
18
+ - Import the abstract column mixins from
19
+ ``pkg_auth.authorization.adapters.django_orm.mixins`` (NOT from
20
+ this module)
21
+ - Define their own concrete models with their own ``db_table`` and
22
+ ``Meta.managed = True``
23
+ - Run their own ``manage.py migrate`` (not ``alembic upgrade``)
24
+ - Inject their concrete model classes into the package repos via
25
+ ``DjangoUserRepository(model=MyUser)`` etc.
26
+
27
+ See ``docs/Django.md`` for the Mode A vs Mode B distinction.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import uuid
32
+
33
+ from django.db import models
34
+
35
+ from .mixins import (
36
+ MembershipMixin,
37
+ OrganizationMixin,
38
+ OrganizationServiceMixin,
39
+ PermissionMixin,
40
+ RoleMixin,
41
+ ServiceMixin,
42
+ UserMixin,
43
+ )
44
+
45
+
46
+ class User(UserMixin):
47
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
48
+
49
+ class Meta:
50
+ managed = False
51
+ db_table = "users"
52
+ app_label = "pkg_auth_acl"
53
+
54
+
55
+ class Organization(OrganizationMixin):
56
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
57
+
58
+ class Meta:
59
+ managed = False
60
+ db_table = "organizations"
61
+ app_label = "pkg_auth_acl"
62
+
63
+
64
+ class Permission(PermissionMixin):
65
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
66
+
67
+ class Meta:
68
+ managed = False
69
+ db_table = "permissions"
70
+ app_label = "pkg_auth_acl"
71
+
72
+
73
+ class Service(ServiceMixin):
74
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
75
+
76
+ class Meta:
77
+ managed = False
78
+ db_table = "services"
79
+ app_label = "pkg_auth_acl"
80
+
81
+
82
+ class OrganizationService(OrganizationServiceMixin):
83
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
84
+ organization = models.ForeignKey(
85
+ Organization,
86
+ on_delete=models.CASCADE,
87
+ db_column="organization_id",
88
+ related_name="services",
89
+ )
90
+
91
+ class Meta:
92
+ managed = False
93
+ db_table = "organization_services"
94
+ app_label = "pkg_auth_acl"
95
+ unique_together = (("organization", "service_name"),)
96
+
97
+
98
+ class Role(RoleMixin):
99
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
100
+ organization = models.ForeignKey(
101
+ Organization,
102
+ on_delete=models.CASCADE,
103
+ null=True,
104
+ blank=True,
105
+ db_column="organization_id",
106
+ related_name="roles",
107
+ )
108
+ permissions = models.ManyToManyField(
109
+ Permission,
110
+ through="RolePermission",
111
+ related_name="roles",
112
+ )
113
+
114
+ class Meta:
115
+ managed = False
116
+ db_table = "roles"
117
+ app_label = "pkg_auth_acl"
118
+ unique_together = (("organization", "name"),)
119
+
120
+
121
+ class RolePermission(models.Model):
122
+ role = models.ForeignKey(
123
+ Role,
124
+ on_delete=models.CASCADE,
125
+ db_column="role_id",
126
+ related_name="role_permissions",
127
+ )
128
+ permission = models.ForeignKey(
129
+ Permission,
130
+ on_delete=models.CASCADE,
131
+ db_column="permission_id",
132
+ related_name="role_permissions",
133
+ )
134
+
135
+ class Meta:
136
+ managed = False
137
+ db_table = "role_permissions"
138
+ app_label = "pkg_auth_acl"
139
+ unique_together = (("role", "permission"),)
140
+
141
+
142
+ class Membership(MembershipMixin):
143
+ """Multi-role-aware: ``UNIQUE(user, organization, role)``.
144
+
145
+ A user can hold multiple memberships in the same organization (one row
146
+ per role); ``DjangoMembershipRepository.load_auth_context`` aggregates
147
+ them into the union of all active roles' permissions.
148
+ """
149
+
150
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
151
+ user = models.ForeignKey(
152
+ User,
153
+ on_delete=models.CASCADE,
154
+ db_column="user_id",
155
+ related_name="memberships",
156
+ )
157
+ organization = models.ForeignKey(
158
+ Organization,
159
+ on_delete=models.CASCADE,
160
+ db_column="organization_id",
161
+ related_name="memberships",
162
+ )
163
+ role = models.ForeignKey(
164
+ Role,
165
+ on_delete=models.PROTECT,
166
+ db_column="role_id",
167
+ related_name="memberships",
168
+ )
169
+ status = models.CharField(max_length=32, default="active")
170
+
171
+ class Meta:
172
+ managed = False
173
+ db_table = "memberships"
174
+ app_label = "pkg_auth_acl"
175
+ unique_together = (("user", "organization", "role"),)
176
+
177
+
178
+ class MembershipInvitation(models.Model):
179
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
180
+ organization = models.ForeignKey(
181
+ Organization,
182
+ on_delete=models.CASCADE,
183
+ db_column="organization_id",
184
+ )
185
+ email = models.CharField(max_length=255)
186
+ role = models.ForeignKey(
187
+ Role,
188
+ on_delete=models.PROTECT,
189
+ db_column="role_id",
190
+ )
191
+ token = models.CharField(max_length=64, unique=True)
192
+ invited_by_user = models.ForeignKey(
193
+ User,
194
+ on_delete=models.SET_NULL,
195
+ null=True,
196
+ db_column="invited_by_user_id",
197
+ )
198
+ expires_at = models.DateTimeField()
199
+ accepted_at = models.DateTimeField(null=True)
200
+ created_at = models.DateTimeField(auto_now_add=True)
201
+
202
+ class Meta:
203
+ managed = False
204
+ db_table = "membership_invitations"
205
+ app_label = "pkg_auth_acl"
206
+
207
+
208
+ class AuthAuditLog(models.Model):
209
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
210
+ actor_user = models.ForeignKey(
211
+ User,
212
+ on_delete=models.SET_NULL,
213
+ null=True,
214
+ db_column="actor_user_id",
215
+ )
216
+ action = models.CharField(max_length=128)
217
+ target_type = models.CharField(max_length=64)
218
+ target_id = models.CharField(max_length=64)
219
+ payload = models.JSONField()
220
+ occurred_at = models.DateTimeField(auto_now_add=True)
221
+ request_id = models.CharField(max_length=64, null=True)
222
+
223
+ class Meta:
224
+ managed = False
225
+ db_table = "auth_audit_log"
226
+ app_label = "pkg_auth_acl"
@@ -0,0 +1,20 @@
1
+ """Django ORM repository implementations for the ACL ports."""
2
+ from __future__ import annotations
3
+
4
+ from .membership import DjangoMembershipRepository
5
+ from .organization import DjangoOrganizationRepository
6
+ from .organization_service import DjangoOrganizationServiceRepository
7
+ from .permission_catalog import DjangoPermissionCatalogRepository
8
+ from .role import DjangoRoleRepository
9
+ from .service import DjangoServiceRepository
10
+ from .user import DjangoUserRepository
11
+
12
+ __all__ = [
13
+ "DjangoUserRepository",
14
+ "DjangoOrganizationRepository",
15
+ "DjangoRoleRepository",
16
+ "DjangoMembershipRepository",
17
+ "DjangoPermissionCatalogRepository",
18
+ "DjangoServiceRepository",
19
+ "DjangoOrganizationServiceRepository",
20
+ ]
@@ -0,0 +1,118 @@
1
+ """Django ORM implementation of MembershipRepository (multi-role aware)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+
7
+ from ....domain.entities import AuthContext, Membership as DomainMembership
8
+ from ....domain.value_objects import (
9
+ OrgId,
10
+ RoleId,
11
+ RoleName,
12
+ UserId,
13
+ )
14
+ from ..models import Membership as DefaultMembershipModel
15
+ from ..models import Role as DefaultRoleModel
16
+
17
+
18
+ def _to_domain(row, role_name: str) -> DomainMembership:
19
+ return DomainMembership(
20
+ id=row.id,
21
+ user_id=UserId(row.user_id),
22
+ organization_id=OrgId(row.organization_id),
23
+ role_id=RoleId(row.role_id),
24
+ role_name=RoleName(role_name),
25
+ status=row.status,
26
+ joined_at=row.joined_at,
27
+ )
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class DjangoMembershipRepository:
32
+ """Multi-role per (user, org). Storage uniqueness is on
33
+ ``(user_id, organization_id, role_id)`` and ``load_auth_context``
34
+ aggregates the union of all active memberships."""
35
+
36
+ model: type = field(default=DefaultMembershipModel)
37
+ role_model: type = field(default=DefaultRoleModel)
38
+
39
+ async def get(
40
+ self, user_id: UserId, org_id: OrgId
41
+ ) -> DomainMembership | None:
42
+ row = await (
43
+ self.model.objects
44
+ .select_related("role")
45
+ .filter(user_id=user_id.value, organization_id=org_id.value)
46
+ .afirst()
47
+ )
48
+ if row is None:
49
+ return None
50
+ return _to_domain(row, row.role.name)
51
+
52
+ async def upsert(
53
+ self,
54
+ *,
55
+ user_id: UserId,
56
+ org_id: OrgId,
57
+ role_id: RoleId,
58
+ status: str,
59
+ ) -> DomainMembership:
60
+ now = datetime.now(timezone.utc)
61
+ row, created = await self.model.objects.aupdate_or_create(
62
+ user_id=user_id.value,
63
+ organization_id=org_id.value,
64
+ role_id=role_id.value,
65
+ defaults={
66
+ "status": status,
67
+ "updated_at": now,
68
+ },
69
+ )
70
+ if created:
71
+ row.joined_at = now
72
+ row.created_at = now
73
+ await row.asave(update_fields=["joined_at", "created_at"])
74
+ # Re-fetch with role for role_name
75
+ row = await self.model.objects.select_related("role").aget(id=row.id)
76
+ return _to_domain(row, row.role.name)
77
+
78
+ async def delete(self, user_id: UserId, org_id: OrgId) -> None:
79
+ # Multi-role: removes ALL memberships for (user, org).
80
+ await self.model.objects.filter(
81
+ user_id=user_id.value, organization_id=org_id.value,
82
+ ).adelete()
83
+
84
+ async def load_auth_context(
85
+ self, user_id: UserId, org_id: OrgId
86
+ ) -> AuthContext | None:
87
+ rows = (
88
+ self.model.objects
89
+ .select_related("role")
90
+ .prefetch_related("role__permissions")
91
+ .filter(
92
+ user_id=user_id.value,
93
+ organization_id=org_id.value,
94
+ status="active",
95
+ )
96
+ )
97
+ role_names: set[str] = set()
98
+ perms: set[str] = set()
99
+ any_active = False
100
+ async for row in rows:
101
+ any_active = True
102
+ role_names.add(row.role.name)
103
+ async for k in row.role.permissions.all().values_list("key", flat=True):
104
+ perms.add(k)
105
+ if not any_active:
106
+ return None
107
+ return AuthContext(
108
+ user_id=user_id,
109
+ organization_id=org_id,
110
+ role_names=frozenset(role_names),
111
+ perms=frozenset(perms),
112
+ )
113
+
114
+ async def list_for_user(self, user_id: UserId) -> list[DomainMembership]:
115
+ rows = self.model.objects.select_related("role").filter(
116
+ user_id=user_id.value
117
+ )
118
+ return [_to_domain(r, r.role.name) async for r in rows]
@@ -0,0 +1,73 @@
1
+ """Django ORM implementation of OrganizationRepository."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+
7
+ from ....domain.entities import Organization as DomainOrganization
8
+ from ....domain.value_objects import OrgId, UserId
9
+ from ..models import Membership as DefaultMembershipModel
10
+ from ..models import Organization as DefaultOrganizationModel
11
+
12
+
13
+ def _to_domain(row) -> DomainOrganization:
14
+ return DomainOrganization(
15
+ id=OrgId(row.id),
16
+ slug=row.slug,
17
+ name=row.name,
18
+ created_at=row.created_at,
19
+ )
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class DjangoOrganizationRepository:
24
+ """``model`` and ``membership_model`` are injectable so consuming
25
+ services can swap in their own concrete classes (extending the
26
+ abstract mixins). Defaults are the package's managed=False mirrors."""
27
+
28
+ model: type = field(default=DefaultOrganizationModel)
29
+ membership_model: type = field(default=DefaultMembershipModel)
30
+
31
+ async def get(self, org_id: OrgId) -> DomainOrganization | None:
32
+ try:
33
+ row = await self.model.objects.aget(id=org_id.value)
34
+ except self.model.DoesNotExist:
35
+ return None
36
+ return _to_domain(row)
37
+
38
+ async def get_by_slug(self, slug: str) -> DomainOrganization | None:
39
+ try:
40
+ row = await self.model.objects.aget(slug=slug)
41
+ except self.model.DoesNotExist:
42
+ return None
43
+ return _to_domain(row)
44
+
45
+ async def create(self, *, slug: str, name: str) -> DomainOrganization:
46
+ now = datetime.now(timezone.utc)
47
+ row = await self.model.objects.acreate(
48
+ slug=slug, name=name, created_at=now, updated_at=now,
49
+ )
50
+ return _to_domain(row)
51
+
52
+ async def update(
53
+ self, org_id: OrgId, *, name: str | None
54
+ ) -> DomainOrganization:
55
+ row = await self.model.objects.aget(id=org_id.value)
56
+ if name is not None:
57
+ row.name = name
58
+ row.updated_at = datetime.now(timezone.utc)
59
+ await row.asave(update_fields=["name", "updated_at"])
60
+ return _to_domain(row)
61
+
62
+ async def delete(self, org_id: OrgId) -> None:
63
+ await self.model.objects.filter(id=org_id.value).adelete()
64
+
65
+ async def list_for_user(self, user_id: UserId) -> list[DomainOrganization]:
66
+ # Distinct because a multi-role user has multiple membership rows per org.
67
+ rows = (
68
+ self.model.objects
69
+ .filter(memberships__user_id=user_id.value)
70
+ .distinct()
71
+ .order_by("id")
72
+ )
73
+ return [_to_domain(r) async for r in rows]
@@ -0,0 +1,71 @@
1
+ """Django ORM implementation of OrganizationServiceRepository."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Sequence
6
+
7
+ from ....domain.entities import OrganizationService
8
+ from ....domain.value_objects import OrgId, ServiceName
9
+ from ..models import OrganizationService as DefaultOrganizationServiceModel
10
+
11
+
12
+ def _to_domain(row) -> OrganizationService:
13
+ return OrganizationService(
14
+ organization_id=OrgId(row.organization_id),
15
+ service_name=ServiceName(row.service_name),
16
+ enabled=bool(row.enabled),
17
+ source=row.source,
18
+ granted_at=row.granted_at,
19
+ )
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class DjangoOrganizationServiceRepository:
24
+ model: type = field(default=DefaultOrganizationServiceModel)
25
+
26
+ async def list_enabled_service_names(self, org_id: OrgId) -> set[str]:
27
+ return {
28
+ row.service_name
29
+ async for row in self.model.objects.filter(
30
+ organization_id=org_id.value, enabled=True
31
+ ).only("service_name")
32
+ }
33
+
34
+ async def get(
35
+ self, org_id: OrgId, service_name: ServiceName
36
+ ) -> OrganizationService | None:
37
+ row = await self.model.objects.filter(
38
+ organization_id=org_id.value, service_name=str(service_name)
39
+ ).afirst()
40
+ return _to_domain(row) if row is not None else None
41
+
42
+ async def enable(
43
+ self, org_id: OrgId, service_name: ServiceName, *, source: str
44
+ ) -> OrganizationService:
45
+ row, _ = await self.model.objects.aupdate_or_create(
46
+ organization_id=org_id.value,
47
+ service_name=str(service_name),
48
+ defaults={"enabled": True, "source": source},
49
+ )
50
+ return _to_domain(row)
51
+
52
+ async def disable(
53
+ self, org_id: OrgId, service_name: ServiceName
54
+ ) -> None:
55
+ await self.model.objects.filter(
56
+ organization_id=org_id.value, service_name=str(service_name)
57
+ ).adelete()
58
+
59
+ async def bulk_enable(
60
+ self,
61
+ org_id: OrgId,
62
+ service_names: Sequence[ServiceName],
63
+ *,
64
+ source: str,
65
+ ) -> None:
66
+ for name in service_names:
67
+ await self.model.objects.aupdate_or_create(
68
+ organization_id=org_id.value,
69
+ service_name=str(name),
70
+ defaults={"enabled": True, "source": source},
71
+ )
@@ -0,0 +1,102 @@
1
+ """Django ORM implementation of PermissionCatalogRepository (visibility + scope)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+ from typing import Iterable, Sequence
7
+
8
+ from ....application.use_cases.register_permission_catalog import CatalogEntry
9
+ from ....domain.entities import Permission as DomainPermission
10
+ from ....domain.ports import PermissionScope
11
+ from ....domain.value_objects import (
12
+ LocalizedText,
13
+ PermissionId,
14
+ PermissionKey,
15
+ PermissionVisibility,
16
+ )
17
+ from ..models import Permission as DefaultPermissionModel
18
+
19
+
20
+ def _to_domain(row) -> DomainPermission:
21
+ return DomainPermission(
22
+ id=PermissionId(row.id),
23
+ key=PermissionKey(row.key),
24
+ service_name=row.service_name,
25
+ description=LocalizedText(row.description or {}),
26
+ visibility=PermissionVisibility(row.visibility),
27
+ )
28
+
29
+
30
+ def _scope_filter(qs, scope: PermissionScope):
31
+ if scope in ("org", "tenant"):
32
+ return qs.filter(
33
+ visibility__in=(
34
+ PermissionVisibility.SHARED.value,
35
+ PermissionVisibility.TENANT_ONLY.value,
36
+ )
37
+ )
38
+ if scope == "platform":
39
+ return qs.filter(
40
+ visibility__in=(
41
+ PermissionVisibility.PLATFORM_ONLY.value,
42
+ PermissionVisibility.SHARED.value,
43
+ )
44
+ )
45
+ return qs
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class DjangoPermissionCatalogRepository:
50
+ model: type = field(default=DefaultPermissionModel)
51
+
52
+ async def register_many(
53
+ self,
54
+ *,
55
+ service_name: str,
56
+ entries: Sequence[CatalogEntry],
57
+ ) -> None:
58
+ now = datetime.now(timezone.utc)
59
+ for entry in entries:
60
+ await self.model.objects.aupdate_or_create(
61
+ key=str(entry.key),
62
+ defaults={
63
+ "service_name": service_name,
64
+ "description": entry.description.as_dict() or None,
65
+ "visibility": entry.visibility.value,
66
+ "registered_at": now,
67
+ },
68
+ )
69
+
70
+ async def list_all(
71
+ self, *, scope: PermissionScope = "all"
72
+ ) -> list[DomainPermission]:
73
+ qs = _scope_filter(self.model.objects.order_by("id"), scope)
74
+ return [_to_domain(r) async for r in qs]
75
+
76
+ async def list_for_service(
77
+ self, service_name: str, *, scope: PermissionScope = "all"
78
+ ) -> list[DomainPermission]:
79
+ qs = _scope_filter(
80
+ self.model.objects.filter(service_name=service_name).order_by("id"),
81
+ scope,
82
+ )
83
+ return [_to_domain(r) async for r in qs]
84
+
85
+ async def get_service_map(self) -> dict[str, str]:
86
+ return {
87
+ row.key: row.service_name
88
+ async for row in self.model.objects.only("key", "service_name")
89
+ }
90
+
91
+ async def prune_absent(
92
+ self,
93
+ *,
94
+ service_name: str,
95
+ keep_keys: Iterable[PermissionKey],
96
+ ) -> int:
97
+ keys = [str(k) for k in keep_keys]
98
+ qs = self.model.objects.filter(service_name=service_name)
99
+ if keys:
100
+ qs = qs.exclude(key__in=keys)
101
+ deleted, _ = await qs.adelete()
102
+ return int(deleted)