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,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)
|