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