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,127 @@
1
+ """SQLAlchemy implementation of PermissionCatalogRepository (UUID PKs, injectable model)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Iterable, 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 ....application.use_cases.register_permission_catalog import CatalogEntry
12
+ from ....domain.entities import Permission
13
+ from ....domain.ports import PermissionScope
14
+ from ....domain.value_objects import (
15
+ LocalizedText,
16
+ PermissionId,
17
+ PermissionKey,
18
+ PermissionVisibility,
19
+ )
20
+ from ..models import PermissionORM as DefaultPermissionORM
21
+
22
+
23
+ def _to_permission(row: Any) -> Permission:
24
+ return Permission(
25
+ id=PermissionId(row.id),
26
+ key=PermissionKey(row.key),
27
+ service_name=row.service_name,
28
+ description=LocalizedText(row.description or {}),
29
+ visibility=PermissionVisibility(row.visibility),
30
+ )
31
+
32
+
33
+ def _scope_clause(model: type, scope: PermissionScope):
34
+ if scope in ("org", "tenant"):
35
+ return model.visibility.in_(
36
+ (PermissionVisibility.SHARED.value,
37
+ PermissionVisibility.TENANT_ONLY.value)
38
+ )
39
+ if scope == "platform":
40
+ return model.visibility.in_(
41
+ (PermissionVisibility.PLATFORM_ONLY.value,
42
+ PermissionVisibility.SHARED.value)
43
+ )
44
+ return None
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class SqlAlchemyPermissionCatalogRepository:
49
+ session_factory: async_sessionmaker[AsyncSession]
50
+ model: type = field(default=DefaultPermissionORM)
51
+
52
+ async def register_many(
53
+ self,
54
+ *,
55
+ service_name: str,
56
+ entries: Sequence[CatalogEntry],
57
+ ) -> None:
58
+ if not entries:
59
+ return
60
+ rows = [
61
+ {
62
+ "key": str(entry.key),
63
+ "service_name": service_name,
64
+ "description": entry.description.as_dict() or None,
65
+ "visibility": entry.visibility.value,
66
+ }
67
+ for entry in entries
68
+ ]
69
+ stmt = pg_insert(self.model).values(rows)
70
+ stmt = stmt.on_conflict_do_update(
71
+ index_elements=["key"],
72
+ set_={
73
+ "service_name": stmt.excluded.service_name,
74
+ "description": stmt.excluded.description,
75
+ "visibility": stmt.excluded.visibility,
76
+ },
77
+ )
78
+ async with self.session_factory() as session:
79
+ await session.execute(stmt)
80
+ await session.commit()
81
+
82
+ async def list_all(
83
+ self, *, scope: PermissionScope = "all"
84
+ ) -> list[Permission]:
85
+ async with self.session_factory() as session:
86
+ stmt = select(self.model).order_by(self.model.id)
87
+ clause = _scope_clause(self.model, scope)
88
+ if clause is not None:
89
+ stmt = stmt.where(clause)
90
+ rows = (await session.execute(stmt)).scalars().all()
91
+ return [_to_permission(r) for r in rows]
92
+
93
+ async def list_for_service(
94
+ self, service_name: str, *, scope: PermissionScope = "all"
95
+ ) -> list[Permission]:
96
+ async with self.session_factory() as session:
97
+ stmt = (
98
+ select(self.model)
99
+ .where(self.model.service_name == service_name)
100
+ .order_by(self.model.id)
101
+ )
102
+ clause = _scope_clause(self.model, scope)
103
+ if clause is not None:
104
+ stmt = stmt.where(clause)
105
+ rows = (await session.execute(stmt)).scalars().all()
106
+ return [_to_permission(r) for r in rows]
107
+
108
+ async def get_service_map(self) -> dict[str, str]:
109
+ async with self.session_factory() as session:
110
+ stmt = select(self.model.key, self.model.service_name)
111
+ rows = (await session.execute(stmt)).all()
112
+ return {key: service_name for key, service_name in rows}
113
+
114
+ async def prune_absent(
115
+ self,
116
+ *,
117
+ service_name: str,
118
+ keep_keys: Iterable[PermissionKey],
119
+ ) -> int:
120
+ keys = [str(k) for k in keep_keys]
121
+ stmt = delete(self.model).where(self.model.service_name == service_name)
122
+ if keys:
123
+ stmt = stmt.where(self.model.key.notin_(keys))
124
+ async with self.session_factory() as session:
125
+ result = await session.execute(stmt)
126
+ await session.commit()
127
+ return int(result.rowcount or 0)
@@ -0,0 +1,171 @@
1
+ """SQLAlchemy implementation of RoleRepository (UUID PKs, injectable model)."""
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, update
8
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
9
+ from sqlalchemy.orm import selectinload
10
+
11
+ from ....domain.entities import Role
12
+ from ....domain.value_objects import (
13
+ OrgId,
14
+ PermissionKey,
15
+ RoleId,
16
+ RoleName,
17
+ )
18
+ from ..models import PermissionORM as DefaultPermissionORM
19
+ from ..models import RoleORM as DefaultRoleORM
20
+
21
+
22
+ def _to_role(row: Any) -> Role:
23
+ return Role(
24
+ id=RoleId(row.id),
25
+ organization_id=(
26
+ OrgId(row.organization_id) if row.organization_id is not None else None
27
+ ),
28
+ name=RoleName(row.name),
29
+ description=row.description,
30
+ permission_keys=frozenset(p.key for p in row.permissions),
31
+ )
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class SqlAlchemyRoleRepository:
36
+ session_factory: async_sessionmaker[AsyncSession]
37
+ model: type = field(default=DefaultRoleORM)
38
+ permission_model: type = field(default=DefaultPermissionORM)
39
+
40
+ async def get(self, role_id: RoleId) -> Role | None:
41
+ async with self.session_factory() as session:
42
+ row = (
43
+ await session.execute(
44
+ select(self.model)
45
+ .options(selectinload(self.model.permissions))
46
+ .where(self.model.id == role_id.value)
47
+ )
48
+ ).scalar_one_or_none()
49
+ return _to_role(row) if row is not None else None
50
+
51
+ async def get_by_name(
52
+ self, org_id: OrgId | None, name: RoleName
53
+ ) -> Role | None:
54
+ async with self.session_factory() as session:
55
+ cond = (
56
+ self.model.organization_id.is_(None)
57
+ if org_id is None
58
+ else self.model.organization_id == org_id.value
59
+ )
60
+ row = (
61
+ await session.execute(
62
+ select(self.model)
63
+ .options(selectinload(self.model.permissions))
64
+ .where(cond, self.model.name == str(name))
65
+ )
66
+ ).scalar_one_or_none()
67
+ return _to_role(row) if row is not None else None
68
+
69
+ async def create(
70
+ self,
71
+ *,
72
+ org_id: OrgId | None,
73
+ name: RoleName,
74
+ description: str | None,
75
+ permission_keys: Sequence[PermissionKey],
76
+ ) -> Role:
77
+ async with self.session_factory() as session:
78
+ perm_rows: list[Any] = []
79
+ if permission_keys:
80
+ key_strs = [str(k) for k in permission_keys]
81
+ perm_rows = list(
82
+ (
83
+ await session.execute(
84
+ select(self.permission_model).where(
85
+ self.permission_model.key.in_(key_strs)
86
+ )
87
+ )
88
+ )
89
+ .scalars()
90
+ .all()
91
+ )
92
+
93
+ row = self.model(
94
+ organization_id=org_id.value if org_id is not None else None,
95
+ name=str(name),
96
+ description=description,
97
+ )
98
+ row.permissions = perm_rows
99
+ session.add(row)
100
+ await session.commit()
101
+ row = (
102
+ await session.execute(
103
+ select(self.model)
104
+ .options(selectinload(self.model.permissions))
105
+ .where(self.model.id == row.id)
106
+ )
107
+ ).scalar_one()
108
+ return _to_role(row)
109
+
110
+ async def update(
111
+ self,
112
+ role_id: RoleId,
113
+ *,
114
+ name: RoleName | None,
115
+ description: str | None,
116
+ permission_keys: Sequence[PermissionKey] | None,
117
+ ) -> Role:
118
+ async with self.session_factory() as session:
119
+ values: dict[str, object] = {}
120
+ if name is not None:
121
+ values["name"] = str(name)
122
+ if description is not None:
123
+ values["description"] = description
124
+ if values:
125
+ await session.execute(
126
+ update(self.model)
127
+ .where(self.model.id == role_id.value)
128
+ .values(**values)
129
+ )
130
+
131
+ if permission_keys is not None:
132
+ row = (
133
+ await session.execute(
134
+ select(self.model)
135
+ .options(selectinload(self.model.permissions))
136
+ .where(self.model.id == role_id.value)
137
+ )
138
+ ).scalar_one()
139
+ key_strs = [str(k) for k in permission_keys]
140
+ if key_strs:
141
+ new_perms = list(
142
+ (
143
+ await session.execute(
144
+ select(self.permission_model).where(
145
+ self.permission_model.key.in_(key_strs)
146
+ )
147
+ )
148
+ )
149
+ .scalars()
150
+ .all()
151
+ )
152
+ else:
153
+ new_perms = []
154
+ row.permissions = new_perms
155
+
156
+ await session.commit()
157
+ row = (
158
+ await session.execute(
159
+ select(self.model)
160
+ .options(selectinload(self.model.permissions))
161
+ .where(self.model.id == role_id.value)
162
+ )
163
+ ).scalar_one()
164
+ return _to_role(row)
165
+
166
+ async def delete(self, role_id: RoleId) -> None:
167
+ async with self.session_factory() as session:
168
+ await session.execute(
169
+ delete(self.model).where(self.model.id == role_id.value)
170
+ )
171
+ await session.commit()
@@ -0,0 +1,93 @@
1
+ """SQLAlchemy implementation of ServiceRepository (UUID PKs, injectable model)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Iterable, 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 ....application.use_cases.sync_service_catalog import ServiceSpec
12
+ from ....domain.entities import Service
13
+ from ....domain.value_objects import LocalizedText, ServiceName
14
+ from ..models import ServiceORM as DefaultServiceORM
15
+
16
+
17
+ def _to_service(row: Any) -> Service:
18
+ return Service(
19
+ name=ServiceName(row.name),
20
+ display_label=LocalizedText(row.display_label or {}),
21
+ auto_provision=bool(row.auto_provision),
22
+ saas_available=bool(row.saas_available),
23
+ created_at=row.created_at,
24
+ )
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class SqlAlchemyServiceRepository:
29
+ session_factory: async_sessionmaker[AsyncSession]
30
+ model: type = field(default=DefaultServiceORM)
31
+
32
+ async def upsert_many(self, services: Sequence[ServiceSpec]) -> None:
33
+ if not services:
34
+ return
35
+ rows = [
36
+ {
37
+ "name": str(s.name),
38
+ "display_label": s.display_label.as_dict() or None,
39
+ "auto_provision": s.auto_provision,
40
+ "saas_available": s.saas_available,
41
+ }
42
+ for s in services
43
+ ]
44
+ stmt = pg_insert(self.model).values(rows)
45
+ stmt = stmt.on_conflict_do_update(
46
+ index_elements=["name"],
47
+ set_={
48
+ "display_label": stmt.excluded.display_label,
49
+ "auto_provision": stmt.excluded.auto_provision,
50
+ "saas_available": stmt.excluded.saas_available,
51
+ },
52
+ )
53
+ async with self.session_factory() as session:
54
+ await session.execute(stmt)
55
+ await session.commit()
56
+
57
+ async def ensure_exists(self, *, service_name: str) -> None:
58
+ """Insert a bare service row if missing; never overwrite vendor flags."""
59
+ stmt = pg_insert(self.model).values(
60
+ name=service_name,
61
+ auto_provision=False,
62
+ saas_available=False,
63
+ )
64
+ stmt = stmt.on_conflict_do_nothing(index_elements=["name"])
65
+ async with self.session_factory() as session:
66
+ await session.execute(stmt)
67
+ await session.commit()
68
+
69
+ async def get(self, name: ServiceName) -> Service | None:
70
+ async with self.session_factory() as session:
71
+ row = (
72
+ await session.execute(
73
+ select(self.model).where(self.model.name == str(name))
74
+ )
75
+ ).scalar_one_or_none()
76
+ return _to_service(row) if row is not None else None
77
+
78
+ async def list_all(self) -> list[Service]:
79
+ async with self.session_factory() as session:
80
+ rows = (
81
+ await session.execute(select(self.model).order_by(self.model.name))
82
+ ).scalars().all()
83
+ return [_to_service(r) for r in rows]
84
+
85
+ async def prune_absent(self, *, keep: Iterable[ServiceName]) -> int:
86
+ names = [str(n) for n in keep]
87
+ stmt = delete(self.model)
88
+ if names:
89
+ stmt = stmt.where(self.model.name.notin_(names))
90
+ async with self.session_factory() as session:
91
+ result = await session.execute(stmt)
92
+ await session.commit()
93
+ return int(result.rowcount or 0)
@@ -0,0 +1,74 @@
1
+ """SQLAlchemy implementation of UserRepository (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 func, 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 User
12
+ from ....domain.value_objects import UserId
13
+ from ..models import UserORM as DefaultUserORM
14
+
15
+
16
+ def _to_user(row: Any) -> User:
17
+ return User(
18
+ id=UserId(row.id),
19
+ keycloak_sub=row.keycloak_sub,
20
+ email=row.email,
21
+ full_name=row.full_name,
22
+ first_seen_at=row.first_seen_at,
23
+ last_seen_at=row.last_seen_at,
24
+ )
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class SqlAlchemyUserRepository:
29
+ session_factory: async_sessionmaker[AsyncSession]
30
+ model: type = field(default=DefaultUserORM)
31
+
32
+ async def get_by_id(self, user_id: UserId) -> User | None:
33
+ async with self.session_factory() as session:
34
+ row = (
35
+ await session.execute(
36
+ select(self.model).where(self.model.id == user_id.value)
37
+ )
38
+ ).scalar_one_or_none()
39
+ return _to_user(row) if row is not None else None
40
+
41
+ async def get_by_keycloak_sub(self, sub: str) -> User | None:
42
+ async with self.session_factory() as session:
43
+ row = (
44
+ await session.execute(
45
+ select(self.model).where(self.model.keycloak_sub == sub)
46
+ )
47
+ ).scalar_one_or_none()
48
+ return _to_user(row) if row is not None else None
49
+
50
+ async def upsert_from_identity(
51
+ self,
52
+ *,
53
+ sub: str,
54
+ email: str,
55
+ full_name: str | None,
56
+ ) -> User:
57
+ stmt = (
58
+ pg_insert(self.model)
59
+ .values(keycloak_sub=sub, email=email, full_name=full_name)
60
+ .on_conflict_do_update(
61
+ index_elements=["keycloak_sub"],
62
+ set_={
63
+ "email": email,
64
+ "full_name": full_name,
65
+ "last_seen_at": func.now(),
66
+ },
67
+ )
68
+ .returning(self.model)
69
+ )
70
+ async with self.session_factory() as session:
71
+ result = await session.execute(stmt)
72
+ await session.commit()
73
+ row = result.scalar_one()
74
+ return _to_user(row)
@@ -0,0 +1 @@
1
+ """Authorization application layer (use cases)."""
@@ -0,0 +1 @@
1
+ """Authorization use cases."""
@@ -0,0 +1,82 @@
1
+ """Internal helpers shared across authorization use cases.
2
+
3
+ The leading underscore signals "internal" — these are not part of the
4
+ public application API and may change between versions.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Sequence
9
+
10
+ from ...domain.exceptions import (
11
+ PermissionVisibilityConflict,
12
+ UnknownPermission,
13
+ )
14
+ from ...domain.ports import PermissionCatalogRepository
15
+ from ...domain.value_objects import PermissionKey, PermissionVisibility
16
+
17
+
18
+ async def validate_permission_keys_exist(
19
+ catalog_repo: PermissionCatalogRepository,
20
+ keys: Sequence[PermissionKey],
21
+ ) -> None:
22
+ """Raise :class:`UnknownPermission` if any key is not in the catalog.
23
+
24
+ Used by :class:`CreateRoleUseCase` and :class:`UpdateRoleUseCase` to
25
+ enforce that role definitions only reference perms that some service
26
+ has actually registered.
27
+ """
28
+ if not keys:
29
+ return
30
+ all_perms = await catalog_repo.list_all()
31
+ known_keys = {str(p.key) for p in all_perms}
32
+ unknown = sorted(str(k) for k in keys if str(k) not in known_keys)
33
+ if unknown:
34
+ raise UnknownPermission(
35
+ f"unknown permission key(s): {unknown}"
36
+ )
37
+
38
+
39
+ async def validate_permission_keys_for_role(
40
+ catalog_repo: PermissionCatalogRepository,
41
+ keys: Sequence[PermissionKey],
42
+ *,
43
+ is_platform_org: bool | None,
44
+ ) -> None:
45
+ """Validate permission keys for a role, enforcing visibility.
46
+
47
+ Always checks existence. When ``is_platform_org`` is not ``None`` (i.e.
48
+ a platform org id is configured and the role is org-scoped), it also
49
+ rejects cross-visibility assignment:
50
+
51
+ - a platform-org role may not use ``TENANT_ONLY`` perms;
52
+ - a normal-org role may not use ``PLATFORM_ONLY`` perms.
53
+
54
+ Pass ``is_platform_org=None`` for global role templates (org_id is None)
55
+ or when no platform org is configured — only existence is checked, which
56
+ preserves the pre-visibility behavior.
57
+ """
58
+ if not keys:
59
+ return
60
+ all_perms = await catalog_repo.list_all()
61
+ by_key = {str(p.key): p for p in all_perms}
62
+ unknown = sorted(str(k) for k in keys if str(k) not in by_key)
63
+ if unknown:
64
+ raise UnknownPermission(f"unknown permission key(s): {unknown}")
65
+
66
+ if is_platform_org is None:
67
+ return
68
+
69
+ forbidden = (
70
+ PermissionVisibility.TENANT_ONLY
71
+ if is_platform_org
72
+ else PermissionVisibility.PLATFORM_ONLY
73
+ )
74
+ violators = sorted(
75
+ str(k) for k in keys if by_key[str(k)].visibility == forbidden
76
+ )
77
+ if violators:
78
+ scope = "platform" if is_platform_org else "normal"
79
+ raise PermissionVisibilityConflict(
80
+ f"{forbidden.value} permission(s) {violators} cannot be assigned "
81
+ f"to a role in a {scope} organization"
82
+ )
@@ -0,0 +1,21 @@
1
+ """Check that an :class:`AuthContext` grants a specific permission."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import AuthContext
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class CheckPermissionUseCase:
11
+ """Pure wrapper around :meth:`AuthContext.require`.
12
+
13
+ Exists as a use case mostly for symmetry with the rest of the
14
+ application layer — most call sites will use ``ctx.require(perm)``
15
+ directly. Provided here for services that prefer a use-case-shaped
16
+ API or want to add cross-cutting behavior (audit logging, metrics)
17
+ via decoration.
18
+ """
19
+
20
+ async def execute(self, ctx: AuthContext, perm: str) -> None:
21
+ ctx.require(perm)
@@ -0,0 +1,41 @@
1
+ """Create an organization."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import Organization
7
+ from ...domain.ports import (
8
+ OrganizationRepository,
9
+ OrganizationServiceRepository,
10
+ ServiceRepository,
11
+ )
12
+ from .provision_default_services import ProvisionDefaultServicesUseCase
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class CreateOrganizationUseCase:
17
+ """Create a new organization.
18
+
19
+ Slug uniqueness is enforced at the database level by a UNIQUE
20
+ constraint; the repository may raise a conflict error which the
21
+ integration layer can map to HTTP 409.
22
+
23
+ When ``service_repo`` and ``org_service_repo`` are wired, every
24
+ ``auto_provision`` service is enabled for the new org (so default-deny
25
+ members still get the default product surfaces). Leaving them unset
26
+ skips provisioning — Mode A services that own their own create flow call
27
+ :class:`ProvisionDefaultServicesUseCase` themselves instead.
28
+ """
29
+
30
+ organization_repo: OrganizationRepository
31
+ service_repo: ServiceRepository | None = None
32
+ org_service_repo: OrganizationServiceRepository | None = None
33
+
34
+ async def execute(self, *, slug: str, name: str) -> Organization:
35
+ org = await self.organization_repo.create(slug=slug, name=name)
36
+ if self.service_repo is not None and self.org_service_repo is not None:
37
+ await ProvisionDefaultServicesUseCase(
38
+ service_repo=self.service_repo,
39
+ org_service_repo=self.org_service_repo,
40
+ ).execute(org_id=org.id)
41
+ return org
@@ -0,0 +1,69 @@
1
+ """Create a role with a set of permissions."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Sequence
6
+
7
+ from ...domain.entities import Role
8
+ from ...domain.exceptions import UnknownOrganization
9
+ from ...domain.ports import (
10
+ OrganizationRepository,
11
+ PermissionCatalogRepository,
12
+ RoleRepository,
13
+ )
14
+ from ...domain.value_objects import OrgId, PermissionKey, RoleName
15
+ from ._helpers import validate_permission_keys_for_role
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class CreateRoleUseCase:
20
+ """Create a new role under an organization (or as a global template).
21
+
22
+ Validates:
23
+ - the organization exists (when ``org_id`` is not ``None``)
24
+ - every referenced permission key is registered in the catalog
25
+ - permission visibility matches the role's org (when
26
+ ``platform_org_id`` is configured): a platform-org role may not use
27
+ ``tenant_only`` perms; a normal-org role may not use
28
+ ``platform_only`` perms.
29
+ """
30
+
31
+ organization_repo: OrganizationRepository
32
+ role_repo: RoleRepository
33
+ catalog_repo: PermissionCatalogRepository
34
+ platform_org_id: OrgId | None = None
35
+
36
+ async def execute(
37
+ self,
38
+ *,
39
+ org_id: OrgId | None,
40
+ name: RoleName,
41
+ description: str | None,
42
+ permission_keys: Sequence[PermissionKey],
43
+ ) -> Role:
44
+ if org_id is not None:
45
+ if await self.organization_repo.get(org_id) is None:
46
+ raise UnknownOrganization(f"organization {org_id} not found")
47
+
48
+ await validate_permission_keys_for_role(
49
+ self.catalog_repo,
50
+ permission_keys,
51
+ is_platform_org=self._is_platform_org(org_id),
52
+ )
53
+
54
+ return await self.role_repo.create(
55
+ org_id=org_id,
56
+ name=name,
57
+ description=description,
58
+ permission_keys=permission_keys,
59
+ )
60
+
61
+ def _is_platform_org(self, org_id: OrgId | None) -> bool | None:
62
+ """``True``/``False`` when visibility should be enforced, else ``None``.
63
+
64
+ Returns ``None`` for global templates (org_id is None) or when no
65
+ platform org is configured, so only existence is checked.
66
+ """
67
+ if org_id is None or self.platform_org_id is None:
68
+ return None
69
+ return org_id == self.platform_org_id