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,120 @@
|
|
|
1
|
+
"""Django ORM implementation of RoleRepository."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Sequence
|
|
7
|
+
|
|
8
|
+
from ....domain.entities import Role as DomainRole
|
|
9
|
+
from ....domain.value_objects import (
|
|
10
|
+
OrgId,
|
|
11
|
+
PermissionKey,
|
|
12
|
+
RoleId,
|
|
13
|
+
RoleName,
|
|
14
|
+
)
|
|
15
|
+
from ..models import Permission as DefaultPermissionModel
|
|
16
|
+
from ..models import Role as DefaultRoleModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _role_to_domain(row) -> DomainRole:
|
|
20
|
+
perm_keys = [
|
|
21
|
+
k async for k in row.permissions.all().values_list("key", flat=True)
|
|
22
|
+
]
|
|
23
|
+
return DomainRole(
|
|
24
|
+
id=RoleId(row.id),
|
|
25
|
+
organization_id=OrgId(row.organization_id) if row.organization_id else None,
|
|
26
|
+
name=RoleName(row.name),
|
|
27
|
+
description=row.description,
|
|
28
|
+
permission_keys=frozenset(perm_keys),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class DjangoRoleRepository:
|
|
34
|
+
model: type = field(default=DefaultRoleModel)
|
|
35
|
+
permission_model: type = field(default=DefaultPermissionModel)
|
|
36
|
+
|
|
37
|
+
async def get(self, role_id: RoleId) -> DomainRole | None:
|
|
38
|
+
try:
|
|
39
|
+
row = await self.model.objects.aget(id=role_id.value)
|
|
40
|
+
except self.model.DoesNotExist:
|
|
41
|
+
return None
|
|
42
|
+
return await _role_to_domain(row)
|
|
43
|
+
|
|
44
|
+
async def get_by_name(
|
|
45
|
+
self, org_id: OrgId | None, name: RoleName
|
|
46
|
+
) -> DomainRole | None:
|
|
47
|
+
qs = self.model.objects.filter(name=str(name))
|
|
48
|
+
qs = (
|
|
49
|
+
qs.filter(organization__isnull=True)
|
|
50
|
+
if org_id is None
|
|
51
|
+
else qs.filter(organization_id=org_id.value)
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
row = await qs.aget()
|
|
55
|
+
except self.model.DoesNotExist:
|
|
56
|
+
return None
|
|
57
|
+
return await _role_to_domain(row)
|
|
58
|
+
|
|
59
|
+
async def create(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
org_id: OrgId | None,
|
|
63
|
+
name: RoleName,
|
|
64
|
+
description: str | None,
|
|
65
|
+
permission_keys: Sequence[PermissionKey],
|
|
66
|
+
) -> DomainRole:
|
|
67
|
+
now = datetime.now(timezone.utc)
|
|
68
|
+
row = await self.model.objects.acreate(
|
|
69
|
+
organization_id=org_id.value if org_id is not None else None,
|
|
70
|
+
name=str(name),
|
|
71
|
+
description=description,
|
|
72
|
+
created_at=now,
|
|
73
|
+
updated_at=now,
|
|
74
|
+
)
|
|
75
|
+
if permission_keys:
|
|
76
|
+
key_strs = [str(k) for k in permission_keys]
|
|
77
|
+
perm_ids = [
|
|
78
|
+
pid
|
|
79
|
+
async for pid in self.permission_model.objects.filter(
|
|
80
|
+
key__in=key_strs
|
|
81
|
+
).values_list("id", flat=True)
|
|
82
|
+
]
|
|
83
|
+
await row.permissions.aset(perm_ids)
|
|
84
|
+
return await _role_to_domain(row)
|
|
85
|
+
|
|
86
|
+
async def update(
|
|
87
|
+
self,
|
|
88
|
+
role_id: RoleId,
|
|
89
|
+
*,
|
|
90
|
+
name: RoleName | None,
|
|
91
|
+
description: str | None,
|
|
92
|
+
permission_keys: Sequence[PermissionKey] | None,
|
|
93
|
+
) -> DomainRole:
|
|
94
|
+
row = await self.model.objects.aget(id=role_id.value)
|
|
95
|
+
update_fields: list[str] = []
|
|
96
|
+
if name is not None:
|
|
97
|
+
row.name = str(name)
|
|
98
|
+
update_fields.append("name")
|
|
99
|
+
if description is not None:
|
|
100
|
+
row.description = description
|
|
101
|
+
update_fields.append("description")
|
|
102
|
+
if update_fields:
|
|
103
|
+
row.updated_at = datetime.now(timezone.utc)
|
|
104
|
+
update_fields.append("updated_at")
|
|
105
|
+
await row.asave(update_fields=update_fields)
|
|
106
|
+
|
|
107
|
+
if permission_keys is not None:
|
|
108
|
+
key_strs = [str(k) for k in permission_keys]
|
|
109
|
+
perm_ids = [
|
|
110
|
+
pid
|
|
111
|
+
async for pid in self.permission_model.objects.filter(
|
|
112
|
+
key__in=key_strs
|
|
113
|
+
).values_list("id", flat=True)
|
|
114
|
+
]
|
|
115
|
+
await row.permissions.aset(perm_ids)
|
|
116
|
+
|
|
117
|
+
return await _role_to_domain(row)
|
|
118
|
+
|
|
119
|
+
async def delete(self, role_id: RoleId) -> None:
|
|
120
|
+
await self.model.objects.filter(id=role_id.value).adelete()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Django ORM implementation of ServiceRepository."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Iterable, Sequence
|
|
6
|
+
|
|
7
|
+
from ....application.use_cases.sync_service_catalog import ServiceSpec
|
|
8
|
+
from ....domain.entities import Service
|
|
9
|
+
from ....domain.value_objects import LocalizedText, ServiceName
|
|
10
|
+
from ..models import Service as DefaultServiceModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_domain(row) -> Service:
|
|
14
|
+
return Service(
|
|
15
|
+
name=ServiceName(row.name),
|
|
16
|
+
display_label=LocalizedText(row.display_label or {}),
|
|
17
|
+
auto_provision=bool(row.auto_provision),
|
|
18
|
+
saas_available=bool(row.saas_available),
|
|
19
|
+
created_at=row.created_at,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class DjangoServiceRepository:
|
|
25
|
+
model: type = field(default=DefaultServiceModel)
|
|
26
|
+
|
|
27
|
+
async def upsert_many(self, services: Sequence[ServiceSpec]) -> None:
|
|
28
|
+
for s in services:
|
|
29
|
+
await self.model.objects.aupdate_or_create(
|
|
30
|
+
name=str(s.name),
|
|
31
|
+
defaults={
|
|
32
|
+
"display_label": s.display_label.as_dict() or None,
|
|
33
|
+
"auto_provision": s.auto_provision,
|
|
34
|
+
"saas_available": s.saas_available,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def ensure_exists(self, *, service_name: str) -> None:
|
|
39
|
+
await self.model.objects.aget_or_create(
|
|
40
|
+
name=service_name,
|
|
41
|
+
defaults={"auto_provision": False, "saas_available": False},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def get(self, name: ServiceName) -> Service | None:
|
|
45
|
+
row = await self.model.objects.filter(name=str(name)).afirst()
|
|
46
|
+
return _to_domain(row) if row is not None else None
|
|
47
|
+
|
|
48
|
+
async def list_all(self) -> list[Service]:
|
|
49
|
+
return [
|
|
50
|
+
_to_domain(r)
|
|
51
|
+
async for r in self.model.objects.order_by("name")
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
async def prune_absent(self, *, keep: Iterable[ServiceName]) -> int:
|
|
55
|
+
names = [str(n) for n in keep]
|
|
56
|
+
qs = self.model.objects.all()
|
|
57
|
+
if names:
|
|
58
|
+
qs = qs.exclude(name__in=names)
|
|
59
|
+
deleted, _ = await qs.adelete()
|
|
60
|
+
return int(deleted)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Django ORM implementation of UserRepository."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from django.db import IntegrityError
|
|
8
|
+
|
|
9
|
+
from ....domain.entities import User as DomainUser
|
|
10
|
+
from ....domain.value_objects import UserId
|
|
11
|
+
from ..models import User as DefaultUserModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_domain(row) -> DomainUser:
|
|
15
|
+
return DomainUser(
|
|
16
|
+
id=UserId(row.id),
|
|
17
|
+
keycloak_sub=row.keycloak_sub,
|
|
18
|
+
email=row.email,
|
|
19
|
+
full_name=row.full_name,
|
|
20
|
+
first_seen_at=row.first_seen_at,
|
|
21
|
+
last_seen_at=row.last_seen_at,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class DjangoUserRepository:
|
|
27
|
+
"""Django ORM implementation of UserRepository.
|
|
28
|
+
|
|
29
|
+
The ``model`` field accepts any concrete Django model class that
|
|
30
|
+
inherits from :class:`pkg_auth.authorization.adapters.django_orm.UserMixin`.
|
|
31
|
+
Defaults to the package's managed=False mirror model.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model: type = field(default=DefaultUserModel)
|
|
35
|
+
|
|
36
|
+
async def get_by_id(self, user_id: UserId) -> DomainUser | None:
|
|
37
|
+
try:
|
|
38
|
+
row = await self.model.objects.aget(id=user_id.value)
|
|
39
|
+
except self.model.DoesNotExist:
|
|
40
|
+
return None
|
|
41
|
+
return _to_domain(row)
|
|
42
|
+
|
|
43
|
+
async def get_by_keycloak_sub(self, sub: str) -> DomainUser | None:
|
|
44
|
+
try:
|
|
45
|
+
row = await self.model.objects.aget(keycloak_sub=sub)
|
|
46
|
+
except self.model.DoesNotExist:
|
|
47
|
+
return None
|
|
48
|
+
return _to_domain(row)
|
|
49
|
+
|
|
50
|
+
async def upsert_from_identity(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
sub: str,
|
|
54
|
+
email: str,
|
|
55
|
+
full_name: str | None,
|
|
56
|
+
) -> DomainUser:
|
|
57
|
+
now = datetime.now(timezone.utc)
|
|
58
|
+
try:
|
|
59
|
+
row = await self.model.objects.aget(keycloak_sub=sub)
|
|
60
|
+
row.email = email
|
|
61
|
+
row.full_name = full_name
|
|
62
|
+
row.last_seen_at = now
|
|
63
|
+
await row.asave(update_fields=["email", "full_name", "last_seen_at"])
|
|
64
|
+
except self.model.DoesNotExist:
|
|
65
|
+
try:
|
|
66
|
+
row = await self.model.objects.acreate(
|
|
67
|
+
keycloak_sub=sub,
|
|
68
|
+
email=email,
|
|
69
|
+
full_name=full_name,
|
|
70
|
+
first_seen_at=now,
|
|
71
|
+
last_seen_at=now,
|
|
72
|
+
created_at=now,
|
|
73
|
+
updated_at=now,
|
|
74
|
+
)
|
|
75
|
+
except IntegrityError:
|
|
76
|
+
row = await self.model.objects.aget(keycloak_sub=sub)
|
|
77
|
+
return _to_domain(row)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""SQLAlchemy adapter for the ACL tables.
|
|
2
|
+
|
|
3
|
+
Importing this module requires SQLAlchemy and asyncpg to be installed:
|
|
4
|
+
|
|
5
|
+
pip install pkg-auth[acl-sqlalchemy]
|
|
6
|
+
|
|
7
|
+
The module exposes ``MIGRATIONS_DIR`` so the source-of-truth service
|
|
8
|
+
can register the bundled version files via Alembic's
|
|
9
|
+
``version_locations`` mechanism as a starting point — Mode A services
|
|
10
|
+
typically evolve the schema further via their own migrations from
|
|
11
|
+
that point on. See ``docs/Authorization.md`` for the wiring pattern.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import sqlalchemy # noqa: F401
|
|
17
|
+
except ImportError as exc: # pragma: no cover
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"pkg_auth.authorization.adapters.sqlalchemy requires SQLAlchemy. "
|
|
20
|
+
"Install with: pip install pkg-auth[acl-sqlalchemy]"
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .base import AclBase, create_acl_base
|
|
26
|
+
from .mixins import (
|
|
27
|
+
MembershipMixin,
|
|
28
|
+
OrganizationMixin,
|
|
29
|
+
OrganizationServiceMixin,
|
|
30
|
+
PermissionMixin,
|
|
31
|
+
RoleMixin,
|
|
32
|
+
ServiceMixin,
|
|
33
|
+
UserMixin,
|
|
34
|
+
)
|
|
35
|
+
from .models import (
|
|
36
|
+
AuthAuditLogORM,
|
|
37
|
+
MembershipInvitationORM,
|
|
38
|
+
MembershipORM,
|
|
39
|
+
OrganizationORM,
|
|
40
|
+
OrganizationServiceORM,
|
|
41
|
+
PermissionORM,
|
|
42
|
+
RoleORM,
|
|
43
|
+
RolePermissionORM,
|
|
44
|
+
ServiceORM,
|
|
45
|
+
UserORM,
|
|
46
|
+
)
|
|
47
|
+
from .repositories.membership import SqlAlchemyMembershipRepository
|
|
48
|
+
from .repositories.organization import SqlAlchemyOrganizationRepository
|
|
49
|
+
from .repositories.organization_service import (
|
|
50
|
+
SqlAlchemyOrganizationServiceRepository,
|
|
51
|
+
)
|
|
52
|
+
from .repositories.permission_catalog import SqlAlchemyPermissionCatalogRepository
|
|
53
|
+
from .repositories.role import SqlAlchemyRoleRepository
|
|
54
|
+
from .repositories.service import SqlAlchemyServiceRepository
|
|
55
|
+
from .repositories.user import SqlAlchemyUserRepository
|
|
56
|
+
|
|
57
|
+
MIGRATIONS_DIR: str = str(Path(__file__).parent / "migrations" / "versions")
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"AclBase",
|
|
61
|
+
"create_acl_base",
|
|
62
|
+
"MIGRATIONS_DIR",
|
|
63
|
+
# Mixins (for services that extend)
|
|
64
|
+
"UserMixin",
|
|
65
|
+
"OrganizationMixin",
|
|
66
|
+
"PermissionMixin",
|
|
67
|
+
"RoleMixin",
|
|
68
|
+
"MembershipMixin",
|
|
69
|
+
"ServiceMixin",
|
|
70
|
+
"OrganizationServiceMixin",
|
|
71
|
+
# ORM models
|
|
72
|
+
"UserORM",
|
|
73
|
+
"OrganizationORM",
|
|
74
|
+
"PermissionORM",
|
|
75
|
+
"RoleORM",
|
|
76
|
+
"RolePermissionORM",
|
|
77
|
+
"MembershipORM",
|
|
78
|
+
"MembershipInvitationORM",
|
|
79
|
+
"AuthAuditLogORM",
|
|
80
|
+
"ServiceORM",
|
|
81
|
+
"OrganizationServiceORM",
|
|
82
|
+
# Repositories
|
|
83
|
+
"SqlAlchemyUserRepository",
|
|
84
|
+
"SqlAlchemyOrganizationRepository",
|
|
85
|
+
"SqlAlchemyRoleRepository",
|
|
86
|
+
"SqlAlchemyMembershipRepository",
|
|
87
|
+
"SqlAlchemyPermissionCatalogRepository",
|
|
88
|
+
"SqlAlchemyServiceRepository",
|
|
89
|
+
"SqlAlchemyOrganizationServiceRepository",
|
|
90
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""SQLAlchemy declarative base for the bundled ACL ORM.
|
|
2
|
+
|
|
3
|
+
.. warning::
|
|
4
|
+
**Mode B only.** This module is part of pkg_auth's *default
|
|
5
|
+
concrete ORM* — used by consuming services (Mode B) that point
|
|
6
|
+
their own sessionmaker at the shared ACL database and read the
|
|
7
|
+
tables via the bundled ``*ORM`` classes.
|
|
8
|
+
|
|
9
|
+
**Do NOT import ``AclBase`` from a Mode A service** (one that
|
|
10
|
+
extends the mixins to add service-specific columns, e.g.
|
|
11
|
+
``itq_users``). Mode A services own the ACL schema — they bring
|
|
12
|
+
their own ``DeclarativeBase`` and their own concrete ORM classes,
|
|
13
|
+
and run their own Alembic migrations. Importing ``AclBase`` into
|
|
14
|
+
a Mode A service splits the service's models across two metadata
|
|
15
|
+
objects and causes ``metadata.create_all`` / migration tooling to
|
|
16
|
+
misbehave.
|
|
17
|
+
|
|
18
|
+
Mode A services must:
|
|
19
|
+
|
|
20
|
+
- Import the abstract column mixins from
|
|
21
|
+
``pkg_auth.authorization.adapters.sqlalchemy.mixins`` (NOT from
|
|
22
|
+
this module or ``models.py``)
|
|
23
|
+
- Define their own concrete ``*ORM`` classes against their own
|
|
24
|
+
``DeclarativeBase``
|
|
25
|
+
- Write their own Alembic migrations
|
|
26
|
+
|
|
27
|
+
See ``docs/Django.md`` for the Mode A vs Mode B distinction.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from sqlalchemy import MetaData
|
|
32
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_acl_base(schema: str | None = None) -> type[DeclarativeBase]:
|
|
36
|
+
"""Build a ``DeclarativeBase`` for the bundled ACL ORM.
|
|
37
|
+
|
|
38
|
+
By default (``schema=None``), tables are emitted unqualified and
|
|
39
|
+
resolve via the database ``search_path`` — which means ``public``
|
|
40
|
+
on a standard Postgres setup. This matches where source-of-truth
|
|
41
|
+
services like ``itq_users`` put the ACL tables.
|
|
42
|
+
|
|
43
|
+
Pass an explicit schema name (``create_acl_base("custom")``) if
|
|
44
|
+
your source-of-truth service placed the tables in a non-default
|
|
45
|
+
Postgres schema.
|
|
46
|
+
"""
|
|
47
|
+
md = MetaData(schema=schema)
|
|
48
|
+
|
|
49
|
+
class _AclBase(DeclarativeBase):
|
|
50
|
+
metadata = md
|
|
51
|
+
|
|
52
|
+
return _AclBase
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
AclBase = create_acl_base()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Alembic migrations for the ACL schema."""
|
pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Initial ACL tables.
|
|
2
|
+
|
|
3
|
+
Revision ID: pkg_auth_acl_0001
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-04-10 00:00:00.000000
|
|
6
|
+
|
|
7
|
+
Creates the bundled ACL tables in the connection's default schema
|
|
8
|
+
(typically ``public``): ``users``, ``organizations``, ``permissions``,
|
|
9
|
+
``roles``, ``role_permissions``, ``memberships``,
|
|
10
|
+
``membership_invitations``, ``auth_audit_log``.
|
|
11
|
+
|
|
12
|
+
Uses an explicit, deterministic revision id (``pkg_auth_acl_0001``)
|
|
13
|
+
and a branch label (``pkg_auth_acl``) so source-of-truth services
|
|
14
|
+
can register this directory via Alembic ``version_locations`` and
|
|
15
|
+
run ``alembic upgrade pkg_auth_acl@head`` as a starting point.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Sequence, Union
|
|
20
|
+
|
|
21
|
+
import sqlalchemy as sa
|
|
22
|
+
from alembic import op
|
|
23
|
+
from sqlalchemy.dialects import postgresql
|
|
24
|
+
|
|
25
|
+
# revision identifiers, used by Alembic.
|
|
26
|
+
revision: str = "pkg_auth_acl_0001"
|
|
27
|
+
down_revision: Union[str, None] = None
|
|
28
|
+
branch_labels: Union[str, Sequence[str], None] = ("pkg_auth_acl",)
|
|
29
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def upgrade() -> None:
|
|
33
|
+
# ----- users ---------------------------------------------------------- #
|
|
34
|
+
op.create_table(
|
|
35
|
+
"users",
|
|
36
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
37
|
+
sa.Column("keycloak_sub", sa.String(64), nullable=False),
|
|
38
|
+
sa.Column("email", sa.String(255), nullable=False),
|
|
39
|
+
sa.Column("full_name", sa.String(255), nullable=True),
|
|
40
|
+
sa.Column(
|
|
41
|
+
"first_seen_at",
|
|
42
|
+
sa.DateTime(timezone=True),
|
|
43
|
+
server_default=sa.text("now()"),
|
|
44
|
+
nullable=False,
|
|
45
|
+
),
|
|
46
|
+
sa.Column(
|
|
47
|
+
"last_seen_at",
|
|
48
|
+
sa.DateTime(timezone=True),
|
|
49
|
+
server_default=sa.text("now()"),
|
|
50
|
+
nullable=False,
|
|
51
|
+
),
|
|
52
|
+
sa.Column(
|
|
53
|
+
"created_at",
|
|
54
|
+
sa.DateTime(timezone=True),
|
|
55
|
+
server_default=sa.text("now()"),
|
|
56
|
+
nullable=False,
|
|
57
|
+
),
|
|
58
|
+
sa.Column(
|
|
59
|
+
"updated_at",
|
|
60
|
+
sa.DateTime(timezone=True),
|
|
61
|
+
server_default=sa.text("now()"),
|
|
62
|
+
nullable=False,
|
|
63
|
+
),
|
|
64
|
+
sa.UniqueConstraint("keycloak_sub", name="uq_users_keycloak_sub"),
|
|
65
|
+
)
|
|
66
|
+
op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"])
|
|
67
|
+
op.create_index("ix_users_email", "users", ["email"])
|
|
68
|
+
|
|
69
|
+
# ----- organizations -------------------------------------------------- #
|
|
70
|
+
op.create_table(
|
|
71
|
+
"organizations",
|
|
72
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
73
|
+
sa.Column("slug", sa.String(255), nullable=False),
|
|
74
|
+
sa.Column("name", sa.String(255), nullable=False),
|
|
75
|
+
sa.Column(
|
|
76
|
+
"created_at",
|
|
77
|
+
sa.DateTime(timezone=True),
|
|
78
|
+
server_default=sa.text("now()"),
|
|
79
|
+
nullable=False,
|
|
80
|
+
),
|
|
81
|
+
sa.Column(
|
|
82
|
+
"updated_at",
|
|
83
|
+
sa.DateTime(timezone=True),
|
|
84
|
+
server_default=sa.text("now()"),
|
|
85
|
+
nullable=False,
|
|
86
|
+
),
|
|
87
|
+
sa.UniqueConstraint("slug", name="uq_organizations_slug"),
|
|
88
|
+
)
|
|
89
|
+
op.create_index("ix_organizations_slug", "organizations", ["slug"])
|
|
90
|
+
|
|
91
|
+
# ----- permissions ---------------------------------------------------- #
|
|
92
|
+
op.create_table(
|
|
93
|
+
"permissions",
|
|
94
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
95
|
+
sa.Column("key", sa.String(255), nullable=False),
|
|
96
|
+
sa.Column("service_name", sa.String(64), nullable=False),
|
|
97
|
+
sa.Column("description", sa.Text(), nullable=True),
|
|
98
|
+
sa.Column(
|
|
99
|
+
"registered_at",
|
|
100
|
+
sa.DateTime(timezone=True),
|
|
101
|
+
server_default=sa.text("now()"),
|
|
102
|
+
nullable=False,
|
|
103
|
+
),
|
|
104
|
+
sa.UniqueConstraint("key", name="uq_permissions_key"),
|
|
105
|
+
)
|
|
106
|
+
op.create_index("ix_permissions_key", "permissions", ["key"])
|
|
107
|
+
op.create_index(
|
|
108
|
+
"ix_permissions_service_name", "permissions", ["service_name"]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ----- roles ---------------------------------------------------------- #
|
|
112
|
+
op.create_table(
|
|
113
|
+
"roles",
|
|
114
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
115
|
+
sa.Column(
|
|
116
|
+
"organization_id",
|
|
117
|
+
sa.Uuid(as_uuid=True),
|
|
118
|
+
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
119
|
+
nullable=True,
|
|
120
|
+
),
|
|
121
|
+
sa.Column("name", sa.String(128), nullable=False),
|
|
122
|
+
sa.Column("description", sa.Text(), nullable=True),
|
|
123
|
+
sa.Column(
|
|
124
|
+
"created_at",
|
|
125
|
+
sa.DateTime(timezone=True),
|
|
126
|
+
server_default=sa.text("now()"),
|
|
127
|
+
nullable=False,
|
|
128
|
+
),
|
|
129
|
+
sa.Column(
|
|
130
|
+
"updated_at",
|
|
131
|
+
sa.DateTime(timezone=True),
|
|
132
|
+
server_default=sa.text("now()"),
|
|
133
|
+
nullable=False,
|
|
134
|
+
),
|
|
135
|
+
sa.UniqueConstraint("organization_id", "name", name="uq_roles_org_name"),
|
|
136
|
+
)
|
|
137
|
+
op.create_index("ix_roles_organization_id", "roles", ["organization_id"])
|
|
138
|
+
|
|
139
|
+
# ----- role_permissions ---------------------------------------------- #
|
|
140
|
+
op.create_table(
|
|
141
|
+
"role_permissions",
|
|
142
|
+
sa.Column(
|
|
143
|
+
"role_id",
|
|
144
|
+
sa.Uuid(as_uuid=True),
|
|
145
|
+
sa.ForeignKey("roles.id", ondelete="CASCADE"),
|
|
146
|
+
primary_key=True,
|
|
147
|
+
),
|
|
148
|
+
sa.Column(
|
|
149
|
+
"permission_id",
|
|
150
|
+
sa.Uuid(as_uuid=True),
|
|
151
|
+
sa.ForeignKey("permissions.id", ondelete="CASCADE"),
|
|
152
|
+
primary_key=True,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# ----- memberships --------------------------------------------------- #
|
|
157
|
+
op.create_table(
|
|
158
|
+
"memberships",
|
|
159
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
160
|
+
sa.Column(
|
|
161
|
+
"user_id",
|
|
162
|
+
sa.Uuid(as_uuid=True),
|
|
163
|
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
|
164
|
+
nullable=False,
|
|
165
|
+
),
|
|
166
|
+
sa.Column(
|
|
167
|
+
"organization_id",
|
|
168
|
+
sa.Uuid(as_uuid=True),
|
|
169
|
+
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
170
|
+
nullable=False,
|
|
171
|
+
),
|
|
172
|
+
sa.Column(
|
|
173
|
+
"role_id",
|
|
174
|
+
sa.Uuid(as_uuid=True),
|
|
175
|
+
sa.ForeignKey("roles.id", ondelete="RESTRICT"),
|
|
176
|
+
nullable=False,
|
|
177
|
+
),
|
|
178
|
+
sa.Column(
|
|
179
|
+
"status",
|
|
180
|
+
sa.String(32),
|
|
181
|
+
nullable=False,
|
|
182
|
+
server_default="active",
|
|
183
|
+
),
|
|
184
|
+
sa.Column(
|
|
185
|
+
"joined_at",
|
|
186
|
+
sa.DateTime(timezone=True),
|
|
187
|
+
server_default=sa.text("now()"),
|
|
188
|
+
nullable=False,
|
|
189
|
+
),
|
|
190
|
+
sa.Column(
|
|
191
|
+
"created_at",
|
|
192
|
+
sa.DateTime(timezone=True),
|
|
193
|
+
server_default=sa.text("now()"),
|
|
194
|
+
nullable=False,
|
|
195
|
+
),
|
|
196
|
+
sa.Column(
|
|
197
|
+
"updated_at",
|
|
198
|
+
sa.DateTime(timezone=True),
|
|
199
|
+
server_default=sa.text("now()"),
|
|
200
|
+
nullable=False,
|
|
201
|
+
),
|
|
202
|
+
sa.UniqueConstraint(
|
|
203
|
+
"user_id", "organization_id", "role_id", name="uq_memberships_user_org_role"
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
op.create_index("ix_memberships_user_id", "memberships", ["user_id"])
|
|
207
|
+
op.create_index(
|
|
208
|
+
"ix_memberships_organization_id", "memberships", ["organization_id"]
|
|
209
|
+
)
|
|
210
|
+
op.create_index("ix_memberships_role_id", "memberships", ["role_id"])
|
|
211
|
+
|
|
212
|
+
# ----- membership_invitations ---------------------------------------- #
|
|
213
|
+
op.create_table(
|
|
214
|
+
"membership_invitations",
|
|
215
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
216
|
+
sa.Column(
|
|
217
|
+
"organization_id",
|
|
218
|
+
sa.Uuid(as_uuid=True),
|
|
219
|
+
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
220
|
+
nullable=False,
|
|
221
|
+
),
|
|
222
|
+
sa.Column("email", sa.String(255), nullable=False),
|
|
223
|
+
sa.Column(
|
|
224
|
+
"role_id",
|
|
225
|
+
sa.Uuid(as_uuid=True),
|
|
226
|
+
sa.ForeignKey("roles.id", ondelete="RESTRICT"),
|
|
227
|
+
nullable=False,
|
|
228
|
+
),
|
|
229
|
+
sa.Column("token", sa.String(64), nullable=False),
|
|
230
|
+
sa.Column(
|
|
231
|
+
"invited_by_user_id",
|
|
232
|
+
sa.Uuid(as_uuid=True),
|
|
233
|
+
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
|
234
|
+
nullable=True,
|
|
235
|
+
),
|
|
236
|
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
237
|
+
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
|
|
238
|
+
sa.Column(
|
|
239
|
+
"created_at",
|
|
240
|
+
sa.DateTime(timezone=True),
|
|
241
|
+
server_default=sa.text("now()"),
|
|
242
|
+
nullable=False,
|
|
243
|
+
),
|
|
244
|
+
sa.UniqueConstraint("token", name="uq_membership_invitations_token"),
|
|
245
|
+
)
|
|
246
|
+
op.create_index(
|
|
247
|
+
"ix_membership_invitations_email", "membership_invitations", ["email"]
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# ----- auth_audit_log ------------------------------------------------ #
|
|
251
|
+
op.create_table(
|
|
252
|
+
"auth_audit_log",
|
|
253
|
+
sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
|
|
254
|
+
sa.Column(
|
|
255
|
+
"actor_user_id",
|
|
256
|
+
sa.Uuid(as_uuid=True),
|
|
257
|
+
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
|
258
|
+
nullable=True,
|
|
259
|
+
),
|
|
260
|
+
sa.Column("action", sa.String(128), nullable=False),
|
|
261
|
+
sa.Column("target_type", sa.String(64), nullable=False),
|
|
262
|
+
sa.Column("target_id", sa.String(64), nullable=False),
|
|
263
|
+
sa.Column(
|
|
264
|
+
"payload",
|
|
265
|
+
postgresql.JSONB(astext_type=sa.Text()),
|
|
266
|
+
nullable=False,
|
|
267
|
+
),
|
|
268
|
+
sa.Column(
|
|
269
|
+
"occurred_at",
|
|
270
|
+
sa.DateTime(timezone=True),
|
|
271
|
+
server_default=sa.text("now()"),
|
|
272
|
+
nullable=False,
|
|
273
|
+
),
|
|
274
|
+
sa.Column("request_id", sa.String(64), nullable=True),
|
|
275
|
+
)
|
|
276
|
+
op.create_index(
|
|
277
|
+
"ix_auth_audit_log_actor", "auth_audit_log", ["actor_user_id"]
|
|
278
|
+
)
|
|
279
|
+
op.create_index("ix_auth_audit_log_action", "auth_audit_log", ["action"])
|
|
280
|
+
op.create_index(
|
|
281
|
+
"ix_auth_audit_log_occurred_at", "auth_audit_log", ["occurred_at"]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def downgrade() -> None:
|
|
286
|
+
op.drop_table("auth_audit_log")
|
|
287
|
+
op.drop_table("membership_invitations")
|
|
288
|
+
op.drop_table("memberships")
|
|
289
|
+
op.drop_table("role_permissions")
|
|
290
|
+
op.drop_table("roles")
|
|
291
|
+
op.drop_table("permissions")
|
|
292
|
+
op.drop_table("organizations")
|
|
293
|
+
op.drop_table("users")
|