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,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."""
@@ -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")