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