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,268 @@
1
+ """Default concrete ORM models for the bundled ACL (UUID PKs).
2
+
3
+ .. warning::
4
+ **Mode B only.** These concrete ``*ORM`` classes inherit from
5
+ ``AclBase`` — the bundled declarative base whose ``MetaData``
6
+ emits unqualified table names (resolving via the database
7
+ ``search_path``). They are ready to use out of the box for
8
+ *consuming* services (Mode B) that point their own sessionmaker
9
+ at the shared ACL database.
10
+
11
+ **Do NOT import any class from this module into a Mode A service**
12
+ (one that extends the mixins to add service-specific columns, e.g.
13
+ ``itq_users``). Mode A services own the ACL schema and define
14
+ their own concrete models against their own ``DeclarativeBase``.
15
+ Accidentally importing ``UserORM`` / ``OrganizationORM`` / … into
16
+ a Mode A service splits its models across two metadata objects
17
+ and breaks ``metadata.create_all`` / migration tooling.
18
+
19
+ Mode A services must:
20
+
21
+ - Import the abstract column mixins from
22
+ ``pkg_auth.authorization.adapters.sqlalchemy.mixins`` (NOT from
23
+ this module or ``base.py``)
24
+ - Define their own concrete ``*ORM`` classes against their own
25
+ ``DeclarativeBase``
26
+ - Write their own Alembic migrations
27
+
28
+ See ``docs/Django.md`` for the Mode A vs Mode B distinction.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ from datetime import datetime
33
+ from typing import Any
34
+ from uuid import UUID
35
+
36
+ from sqlalchemy import (
37
+ DateTime,
38
+ ForeignKey,
39
+ String,
40
+ Text,
41
+ UniqueConstraint,
42
+ Uuid,
43
+ func,
44
+ text,
45
+ )
46
+ from sqlalchemy.dialects.postgresql import JSONB
47
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
48
+
49
+ from .base import AclBase
50
+ from .mixins import (
51
+ MembershipMixin,
52
+ OrganizationMixin,
53
+ OrganizationServiceMixin,
54
+ PermissionMixin,
55
+ RoleMixin,
56
+ ServiceMixin,
57
+ UserMixin,
58
+ )
59
+
60
+
61
+ class UserORM(AclBase, UserMixin):
62
+ __tablename__ = "users"
63
+
64
+ id: Mapped[UUID] = mapped_column(
65
+ Uuid(as_uuid=True),
66
+ primary_key=True,
67
+ server_default=text("gen_random_uuid()"),
68
+ )
69
+
70
+ memberships: Mapped[list["MembershipORM"]] = relationship(
71
+ back_populates="user",
72
+ cascade="all, delete-orphan",
73
+ )
74
+
75
+
76
+ class OrganizationORM(AclBase, OrganizationMixin):
77
+ __tablename__ = "organizations"
78
+
79
+ id: Mapped[UUID] = mapped_column(
80
+ Uuid(as_uuid=True),
81
+ primary_key=True,
82
+ server_default=text("gen_random_uuid()"),
83
+ )
84
+
85
+ memberships: Mapped[list["MembershipORM"]] = relationship(
86
+ back_populates="organization",
87
+ cascade="all, delete-orphan",
88
+ )
89
+ roles: Mapped[list["RoleORM"]] = relationship(
90
+ back_populates="organization",
91
+ cascade="all, delete-orphan",
92
+ )
93
+
94
+
95
+ class PermissionORM(AclBase, PermissionMixin):
96
+ __tablename__ = "permissions"
97
+
98
+ id: Mapped[UUID] = mapped_column(
99
+ Uuid(as_uuid=True),
100
+ primary_key=True,
101
+ server_default=text("gen_random_uuid()"),
102
+ )
103
+
104
+
105
+ class ServiceORM(AclBase, ServiceMixin):
106
+ __tablename__ = "services"
107
+
108
+ id: Mapped[UUID] = mapped_column(
109
+ Uuid(as_uuid=True),
110
+ primary_key=True,
111
+ server_default=text("gen_random_uuid()"),
112
+ )
113
+
114
+
115
+ class OrganizationServiceORM(AclBase, OrganizationServiceMixin):
116
+ __tablename__ = "organization_services"
117
+ __table_args__ = (
118
+ UniqueConstraint(
119
+ "organization_id", "service_name",
120
+ name="uq_org_services_org_service",
121
+ ),
122
+ )
123
+
124
+ id: Mapped[UUID] = mapped_column(
125
+ Uuid(as_uuid=True),
126
+ primary_key=True,
127
+ server_default=text("gen_random_uuid()"),
128
+ )
129
+ organization_id: Mapped[UUID] = mapped_column(
130
+ Uuid(as_uuid=True),
131
+ ForeignKey("organizations.id", ondelete="CASCADE"),
132
+ index=True,
133
+ )
134
+
135
+
136
+ class RoleORM(AclBase, RoleMixin):
137
+ __tablename__ = "roles"
138
+ __table_args__ = (
139
+ UniqueConstraint("organization_id", "name", name="uq_roles_org_name"),
140
+ )
141
+
142
+ id: Mapped[UUID] = mapped_column(
143
+ Uuid(as_uuid=True),
144
+ primary_key=True,
145
+ server_default=text("gen_random_uuid()"),
146
+ )
147
+ organization_id: Mapped[UUID | None] = mapped_column(
148
+ Uuid(as_uuid=True),
149
+ ForeignKey("organizations.id", ondelete="CASCADE"),
150
+ index=True,
151
+ )
152
+
153
+ organization: Mapped[OrganizationORM | None] = relationship(
154
+ back_populates="roles"
155
+ )
156
+ permissions: Mapped[list[PermissionORM]] = relationship(
157
+ secondary="role_permissions"
158
+ )
159
+ memberships: Mapped[list["MembershipORM"]] = relationship(
160
+ back_populates="role"
161
+ )
162
+
163
+
164
+ class RolePermissionORM(AclBase):
165
+ __tablename__ = "role_permissions"
166
+
167
+ role_id: Mapped[UUID] = mapped_column(
168
+ Uuid(as_uuid=True),
169
+ ForeignKey("roles.id", ondelete="CASCADE"),
170
+ primary_key=True,
171
+ )
172
+ permission_id: Mapped[UUID] = mapped_column(
173
+ Uuid(as_uuid=True),
174
+ ForeignKey("permissions.id", ondelete="CASCADE"),
175
+ primary_key=True,
176
+ )
177
+
178
+
179
+ class MembershipORM(AclBase, MembershipMixin):
180
+ __tablename__ = "memberships"
181
+ __table_args__ = (
182
+ UniqueConstraint(
183
+ "user_id", "organization_id", "role_id",
184
+ name="uq_memberships_user_org_role",
185
+ ),
186
+ )
187
+
188
+ id: Mapped[UUID] = mapped_column(
189
+ Uuid(as_uuid=True),
190
+ primary_key=True,
191
+ server_default=text("gen_random_uuid()"),
192
+ )
193
+ user_id: Mapped[UUID] = mapped_column(
194
+ Uuid(as_uuid=True),
195
+ ForeignKey("users.id", ondelete="CASCADE"),
196
+ index=True,
197
+ )
198
+ organization_id: Mapped[UUID] = mapped_column(
199
+ Uuid(as_uuid=True),
200
+ ForeignKey("organizations.id", ondelete="CASCADE"),
201
+ index=True,
202
+ )
203
+ role_id: Mapped[UUID] = mapped_column(
204
+ Uuid(as_uuid=True),
205
+ ForeignKey("roles.id", ondelete="RESTRICT"),
206
+ index=True,
207
+ )
208
+ status: Mapped[str] = mapped_column(String(32), server_default="active")
209
+
210
+ user: Mapped[UserORM] = relationship(back_populates="memberships")
211
+ organization: Mapped[OrganizationORM] = relationship(
212
+ back_populates="memberships"
213
+ )
214
+ role: Mapped[RoleORM] = relationship(back_populates="memberships")
215
+
216
+
217
+ class MembershipInvitationORM(AclBase):
218
+ __tablename__ = "membership_invitations"
219
+
220
+ id: Mapped[UUID] = mapped_column(
221
+ Uuid(as_uuid=True),
222
+ primary_key=True,
223
+ server_default=text("gen_random_uuid()"),
224
+ )
225
+ organization_id: Mapped[UUID] = mapped_column(
226
+ Uuid(as_uuid=True),
227
+ ForeignKey("organizations.id", ondelete="CASCADE"),
228
+ )
229
+ email: Mapped[str] = mapped_column(String(255), index=True)
230
+ role_id: Mapped[UUID] = mapped_column(
231
+ Uuid(as_uuid=True),
232
+ ForeignKey("roles.id", ondelete="RESTRICT"),
233
+ )
234
+ token: Mapped[str] = mapped_column(String(64), unique=True)
235
+ invited_by_user_id: Mapped[UUID | None] = mapped_column(
236
+ Uuid(as_uuid=True),
237
+ ForeignKey("users.id", ondelete="SET NULL"),
238
+ )
239
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
240
+ accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
241
+ created_at: Mapped[datetime] = mapped_column(
242
+ DateTime(timezone=True), server_default=func.now()
243
+ )
244
+
245
+
246
+ class AuthAuditLogORM(AclBase):
247
+ __tablename__ = "auth_audit_log"
248
+
249
+ id: Mapped[UUID] = mapped_column(
250
+ Uuid(as_uuid=True),
251
+ primary_key=True,
252
+ server_default=text("gen_random_uuid()"),
253
+ )
254
+ actor_user_id: Mapped[UUID | None] = mapped_column(
255
+ Uuid(as_uuid=True),
256
+ ForeignKey("users.id", ondelete="SET NULL"),
257
+ index=True,
258
+ )
259
+ action: Mapped[str] = mapped_column(String(128), index=True)
260
+ target_type: Mapped[str] = mapped_column(String(64))
261
+ target_id: Mapped[str] = mapped_column(String(64))
262
+ payload: Mapped[dict[str, Any]] = mapped_column(JSONB)
263
+ occurred_at: Mapped[datetime] = mapped_column(
264
+ DateTime(timezone=True),
265
+ server_default=func.now(),
266
+ index=True,
267
+ )
268
+ request_id: Mapped[str | None] = mapped_column(String(64))
@@ -0,0 +1,16 @@
1
+ """SQLAlchemy repository implementations for the ACL ports."""
2
+ from __future__ import annotations
3
+
4
+ from .membership import SqlAlchemyMembershipRepository
5
+ from .organization import SqlAlchemyOrganizationRepository
6
+ from .permission_catalog import SqlAlchemyPermissionCatalogRepository
7
+ from .role import SqlAlchemyRoleRepository
8
+ from .user import SqlAlchemyUserRepository
9
+
10
+ __all__ = [
11
+ "SqlAlchemyUserRepository",
12
+ "SqlAlchemyOrganizationRepository",
13
+ "SqlAlchemyRoleRepository",
14
+ "SqlAlchemyMembershipRepository",
15
+ "SqlAlchemyPermissionCatalogRepository",
16
+ ]
@@ -0,0 +1,146 @@
1
+ """SQLAlchemy implementation of MembershipRepository (UUID PKs, injectable model)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from sqlalchemy import delete, select
8
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
9
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
10
+ from sqlalchemy.orm import selectinload
11
+
12
+ from ....domain.entities import AuthContext, Membership
13
+ from ....domain.value_objects import (
14
+ OrgId,
15
+ RoleId,
16
+ RoleName,
17
+ UserId,
18
+ )
19
+ from ..models import MembershipORM as DefaultMembershipORM
20
+ from ..models import RoleORM as DefaultRoleORM
21
+
22
+
23
+ def _to_membership(row: Any, role_name: str) -> Membership:
24
+ return Membership(
25
+ id=row.id,
26
+ user_id=UserId(row.user_id),
27
+ organization_id=OrgId(row.organization_id),
28
+ role_id=RoleId(row.role_id),
29
+ role_name=RoleName(role_name),
30
+ status=row.status,
31
+ joined_at=row.joined_at,
32
+ )
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class SqlAlchemyMembershipRepository:
37
+ session_factory: async_sessionmaker[AsyncSession]
38
+ model: type = field(default=DefaultMembershipORM)
39
+ role_model: type = field(default=DefaultRoleORM)
40
+
41
+ async def get(
42
+ self, user_id: UserId, org_id: OrgId
43
+ ) -> Membership | None:
44
+ async with self.session_factory() as session:
45
+ row = (
46
+ await session.execute(
47
+ select(self.model)
48
+ .options(selectinload(self.model.role))
49
+ .where(
50
+ self.model.user_id == user_id.value,
51
+ self.model.organization_id == org_id.value,
52
+ )
53
+ )
54
+ ).scalar_one_or_none()
55
+ return _to_membership(row, row.role.name) if row is not None else None
56
+
57
+ async def upsert(
58
+ self,
59
+ *,
60
+ user_id: UserId,
61
+ org_id: OrgId,
62
+ role_id: RoleId,
63
+ status: str,
64
+ ) -> Membership:
65
+ stmt = (
66
+ pg_insert(self.model)
67
+ .values(
68
+ user_id=user_id.value,
69
+ organization_id=org_id.value,
70
+ role_id=role_id.value,
71
+ status=status,
72
+ )
73
+ .on_conflict_do_update(
74
+ index_elements=["user_id", "organization_id"],
75
+ set_={"role_id": role_id.value, "status": status},
76
+ )
77
+ .returning(self.model)
78
+ )
79
+ async with self.session_factory() as session:
80
+ result = await session.execute(stmt)
81
+ await session.commit()
82
+ row = result.scalar_one()
83
+ role = (
84
+ await session.execute(
85
+ select(self.role_model).where(
86
+ self.role_model.id == row.role_id
87
+ )
88
+ )
89
+ ).scalar_one()
90
+ return _to_membership(row, role.name)
91
+
92
+ async def delete(self, user_id: UserId, org_id: OrgId) -> None:
93
+ async with self.session_factory() as session:
94
+ await session.execute(
95
+ delete(self.model).where(
96
+ self.model.user_id == user_id.value,
97
+ self.model.organization_id == org_id.value,
98
+ )
99
+ )
100
+ await session.commit()
101
+
102
+ async def load_auth_context(
103
+ self, user_id: UserId, org_id: OrgId
104
+ ) -> AuthContext | None:
105
+ """Multi-role: fetch ALL active memberships for (user, org),
106
+ merge their roles' permissions into a single AuthContext.
107
+ """
108
+ async with self.session_factory() as session:
109
+ stmt = (
110
+ select(self.model)
111
+ .options(
112
+ selectinload(self.model.role).selectinload(
113
+ self.role_model.permissions
114
+ )
115
+ )
116
+ .where(
117
+ self.model.user_id == user_id.value,
118
+ self.model.organization_id == org_id.value,
119
+ self.model.status == "active",
120
+ )
121
+ )
122
+ rows = (await session.execute(stmt)).scalars().all()
123
+ if not rows:
124
+ return None
125
+ role_names: set[str] = set()
126
+ all_perms: set[str] = set()
127
+ for row in rows:
128
+ role_names.add(row.role.name)
129
+ all_perms.update(p.key for p in row.role.permissions)
130
+ return AuthContext(
131
+ user_id=UserId(rows[0].user_id),
132
+ organization_id=OrgId(rows[0].organization_id),
133
+ role_names=frozenset(role_names),
134
+ perms=frozenset(all_perms),
135
+ )
136
+
137
+ async def list_for_user(self, user_id: UserId) -> list[Membership]:
138
+ async with self.session_factory() as session:
139
+ rows = (
140
+ await session.execute(
141
+ select(self.model)
142
+ .options(selectinload(self.model.role))
143
+ .where(self.model.user_id == user_id.value)
144
+ )
145
+ ).scalars().all()
146
+ return [_to_membership(r, r.role.name) for r in rows]
@@ -0,0 +1,97 @@
1
+ """SQLAlchemy implementation of OrganizationRepository (UUID PKs, injectable model)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from sqlalchemy import delete, select, update
8
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9
+
10
+ from ....domain.entities import Organization
11
+ from ....domain.value_objects import OrgId, UserId
12
+ from ..models import MembershipORM as DefaultMembershipORM
13
+ from ..models import OrganizationORM as DefaultOrganizationORM
14
+
15
+
16
+ def _to_org(row: Any) -> Organization:
17
+ return Organization(
18
+ id=OrgId(row.id),
19
+ slug=row.slug,
20
+ name=row.name,
21
+ created_at=row.created_at,
22
+ )
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class SqlAlchemyOrganizationRepository:
27
+ session_factory: async_sessionmaker[AsyncSession]
28
+ model: type = field(default=DefaultOrganizationORM)
29
+ membership_model: type = field(default=DefaultMembershipORM)
30
+
31
+ async def get(self, org_id: OrgId) -> Organization | None:
32
+ async with self.session_factory() as session:
33
+ row = (
34
+ await session.execute(
35
+ select(self.model).where(self.model.id == org_id.value)
36
+ )
37
+ ).scalar_one_or_none()
38
+ return _to_org(row) if row is not None else None
39
+
40
+ async def get_by_slug(self, slug: str) -> Organization | None:
41
+ async with self.session_factory() as session:
42
+ row = (
43
+ await session.execute(
44
+ select(self.model).where(self.model.slug == slug)
45
+ )
46
+ ).scalar_one_or_none()
47
+ return _to_org(row) if row is not None else None
48
+
49
+ async def create(self, *, slug: str, name: str) -> Organization:
50
+ async with self.session_factory() as session:
51
+ row = self.model(slug=slug, name=name)
52
+ session.add(row)
53
+ await session.commit()
54
+ await session.refresh(row)
55
+ return _to_org(row)
56
+
57
+ async def update(
58
+ self, org_id: OrgId, *, name: str | None
59
+ ) -> Organization:
60
+ async with self.session_factory() as session:
61
+ values: dict[str, object] = {}
62
+ if name is not None:
63
+ values["name"] = name
64
+ if values:
65
+ await session.execute(
66
+ update(self.model)
67
+ .where(self.model.id == org_id.value)
68
+ .values(**values)
69
+ )
70
+ await session.commit()
71
+ row = (
72
+ await session.execute(
73
+ select(self.model).where(self.model.id == org_id.value)
74
+ )
75
+ ).scalar_one()
76
+ return _to_org(row)
77
+
78
+ async def delete(self, org_id: OrgId) -> None:
79
+ async with self.session_factory() as session:
80
+ await session.execute(
81
+ delete(self.model).where(self.model.id == org_id.value)
82
+ )
83
+ await session.commit()
84
+
85
+ async def list_for_user(self, user_id: UserId) -> list[Organization]:
86
+ async with self.session_factory() as session:
87
+ stmt = (
88
+ select(self.model)
89
+ .join(
90
+ self.membership_model,
91
+ self.membership_model.organization_id == self.model.id,
92
+ )
93
+ .where(self.membership_model.user_id == user_id.value)
94
+ .order_by(self.model.id)
95
+ )
96
+ rows = (await session.execute(stmt)).scalars().all()
97
+ return [_to_org(r) for r in rows]
@@ -0,0 +1,106 @@
1
+ """SQLAlchemy implementation of OrganizationServiceRepository."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Sequence
6
+
7
+ from sqlalchemy import delete, select
8
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
9
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
10
+
11
+ from ....domain.entities import OrganizationService
12
+ from ....domain.value_objects import OrgId, ServiceName
13
+ from ..models import OrganizationServiceORM as DefaultOrganizationServiceORM
14
+
15
+
16
+ def _to_entitlement(row: Any) -> OrganizationService:
17
+ return OrganizationService(
18
+ organization_id=OrgId(row.organization_id),
19
+ service_name=ServiceName(row.service_name),
20
+ enabled=bool(row.enabled),
21
+ source=row.source,
22
+ granted_at=row.granted_at,
23
+ )
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class SqlAlchemyOrganizationServiceRepository:
28
+ session_factory: async_sessionmaker[AsyncSession]
29
+ model: type = field(default=DefaultOrganizationServiceORM)
30
+
31
+ async def list_enabled_service_names(self, org_id: OrgId) -> set[str]:
32
+ async with self.session_factory() as session:
33
+ stmt = select(self.model.service_name).where(
34
+ self.model.organization_id == org_id.value,
35
+ self.model.enabled.is_(True),
36
+ )
37
+ rows = (await session.execute(stmt)).scalars().all()
38
+ return set(rows)
39
+
40
+ async def get(
41
+ self, org_id: OrgId, service_name: ServiceName
42
+ ) -> OrganizationService | None:
43
+ async with self.session_factory() as session:
44
+ row = (
45
+ await session.execute(
46
+ select(self.model).where(
47
+ self.model.organization_id == org_id.value,
48
+ self.model.service_name == str(service_name),
49
+ )
50
+ )
51
+ ).scalar_one_or_none()
52
+ return _to_entitlement(row) if row is not None else None
53
+
54
+ async def enable(
55
+ self, org_id: OrgId, service_name: ServiceName, *, source: str
56
+ ) -> OrganizationService:
57
+ await self._upsert(org_id, [str(service_name)], source=source)
58
+ result = await self.get(org_id, service_name)
59
+ assert result is not None # just upserted
60
+ return result
61
+
62
+ async def disable(
63
+ self, org_id: OrgId, service_name: ServiceName
64
+ ) -> None:
65
+ async with self.session_factory() as session:
66
+ await session.execute(
67
+ delete(self.model).where(
68
+ self.model.organization_id == org_id.value,
69
+ self.model.service_name == str(service_name),
70
+ )
71
+ )
72
+ await session.commit()
73
+
74
+ async def bulk_enable(
75
+ self,
76
+ org_id: OrgId,
77
+ service_names: Sequence[ServiceName],
78
+ *,
79
+ source: str,
80
+ ) -> None:
81
+ if not service_names:
82
+ return
83
+ await self._upsert(
84
+ org_id, [str(n) for n in service_names], source=source
85
+ )
86
+
87
+ async def _upsert(
88
+ self, org_id: OrgId, names: list[str], *, source: str
89
+ ) -> None:
90
+ rows = [
91
+ {
92
+ "organization_id": org_id.value,
93
+ "service_name": name,
94
+ "enabled": True,
95
+ "source": source,
96
+ }
97
+ for name in names
98
+ ]
99
+ stmt = pg_insert(self.model).values(rows)
100
+ stmt = stmt.on_conflict_do_update(
101
+ index_elements=["organization_id", "service_name"],
102
+ set_={"enabled": True, "source": stmt.excluded.source},
103
+ )
104
+ async with self.session_factory() as session:
105
+ await session.execute(stmt)
106
+ await session.commit()