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,39 @@
1
+ """Add is_platform flag to permissions.
2
+
3
+ Revision ID: pkg_auth_acl_0002
4
+ Revises: pkg_auth_acl_0001
5
+ Create Date: 2026-04-12 00:00:00.000000
6
+
7
+ Adds the ``is_platform`` boolean column so the central ACL UI can
8
+ filter platform-only permissions out of org-scoped role builders.
9
+ Defaults to ``false`` so existing rows remain valid and backwards-
10
+ compatible 2-tuple registration calls keep working.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Sequence, Union
15
+
16
+ import sqlalchemy as sa
17
+ from alembic import op
18
+
19
+ # revision identifiers, used by Alembic.
20
+ revision: str = "pkg_auth_acl_0002"
21
+ down_revision: Union[str, None] = "pkg_auth_acl_0001"
22
+ branch_labels: Union[str, Sequence[str], None] = None
23
+ depends_on: Union[str, Sequence[str], None] = None
24
+
25
+
26
+ def upgrade() -> None:
27
+ op.add_column(
28
+ "permissions",
29
+ sa.Column(
30
+ "is_platform",
31
+ sa.Boolean(),
32
+ nullable=False,
33
+ server_default=sa.text("false"),
34
+ ),
35
+ )
36
+
37
+
38
+ def downgrade() -> None:
39
+ op.drop_column("permissions", "is_platform")
@@ -0,0 +1,65 @@
1
+ """Replace permissions.is_platform with a tri-state visibility column.
2
+
3
+ Revision ID: pkg_auth_acl_0003
4
+ Revises: pkg_auth_acl_0002
5
+ Create Date: 2026-06-20 00:00:00.000000
6
+
7
+ ``is_platform`` was a 2-state flag (platform-only vs everywhere). It is
8
+ replaced by a ``visibility`` enum-as-string:
9
+
10
+ - ``platform_only`` (was ``is_platform = true``)
11
+ - ``shared`` (was ``is_platform = false``, the default)
12
+ - ``tenant_only`` (new — hidden from the platform org)
13
+
14
+ Backfill maps the old boolean, then the old column is dropped.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Sequence, Union
19
+
20
+ import sqlalchemy as sa
21
+ from alembic import op
22
+
23
+ # revision identifiers, used by Alembic.
24
+ revision: str = "pkg_auth_acl_0003"
25
+ down_revision: Union[str, None] = "pkg_auth_acl_0002"
26
+ branch_labels: Union[str, Sequence[str], None] = None
27
+ depends_on: Union[str, Sequence[str], None] = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ op.add_column(
32
+ "permissions",
33
+ sa.Column(
34
+ "visibility",
35
+ sa.String(32),
36
+ nullable=False,
37
+ server_default=sa.text("'shared'"),
38
+ ),
39
+ )
40
+ op.execute(
41
+ "UPDATE permissions SET visibility = "
42
+ "CASE WHEN is_platform THEN 'platform_only' ELSE 'shared' END"
43
+ )
44
+ op.create_index(
45
+ "ix_permissions_visibility", "permissions", ["visibility"]
46
+ )
47
+ op.drop_column("permissions", "is_platform")
48
+
49
+
50
+ def downgrade() -> None:
51
+ op.add_column(
52
+ "permissions",
53
+ sa.Column(
54
+ "is_platform",
55
+ sa.Boolean(),
56
+ nullable=False,
57
+ server_default=sa.text("false"),
58
+ ),
59
+ )
60
+ op.execute(
61
+ "UPDATE permissions SET is_platform = "
62
+ "(visibility = 'platform_only')"
63
+ )
64
+ op.drop_index("ix_permissions_visibility", table_name="permissions")
65
+ op.drop_column("permissions", "visibility")
@@ -0,0 +1,52 @@
1
+ """Localize permissions.description: TEXT -> JSONB locale map.
2
+
3
+ Revision ID: pkg_auth_acl_0004
4
+ Revises: pkg_auth_acl_0003
5
+ Create Date: 2026-06-20 00:00:01.000000
6
+
7
+ ``description`` becomes a JSONB ``{locale: text}`` map. Existing plain-text
8
+ descriptions are backfilled under the default locale, read from the
9
+ ``ACL_DEFAULT_LOCALE`` env var at migration time (fallback ``"en"``).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from typing import Sequence, Union
16
+
17
+ from alembic import op
18
+
19
+ # revision identifiers, used by Alembic.
20
+ revision: str = "pkg_auth_acl_0004"
21
+ down_revision: Union[str, None] = "pkg_auth_acl_0003"
22
+ branch_labels: Union[str, Sequence[str], None] = None
23
+ depends_on: Union[str, Sequence[str], None] = None
24
+
25
+ _LOCALE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
26
+
27
+
28
+ def _default_locale() -> str:
29
+ loc = os.environ.get("ACL_DEFAULT_LOCALE") or "en"
30
+ if not _LOCALE_RE.match(loc):
31
+ raise ValueError(f"Invalid ACL_DEFAULT_LOCALE {loc!r}")
32
+ return loc
33
+
34
+
35
+ def upgrade() -> None:
36
+ locale = _default_locale()
37
+ op.execute(
38
+ f"ALTER TABLE permissions "
39
+ f"ALTER COLUMN description TYPE JSONB USING "
40
+ f"CASE WHEN description IS NULL OR description = '' THEN NULL "
41
+ f"ELSE jsonb_build_object('{locale}', description) END"
42
+ )
43
+
44
+
45
+ def downgrade() -> None:
46
+ locale = _default_locale()
47
+ op.execute(
48
+ f"ALTER TABLE permissions "
49
+ f"ALTER COLUMN description TYPE TEXT USING "
50
+ f"COALESCE(description ->> '{locale}', "
51
+ f"(SELECT value FROM jsonb_each_text(description) LIMIT 1))"
52
+ )
@@ -0,0 +1,116 @@
1
+ """Create services and organization_services tables (service guard).
2
+
3
+ Revision ID: pkg_auth_acl_0005
4
+ Revises: pkg_auth_acl_0004
5
+ Create Date: 2026-06-20 00:00:02.000000
6
+
7
+ ``services`` is the vendor-controlled service registry; ``auto_provision``
8
+ and ``saas_available`` are set only via ``pkg-auth-sync-services``.
9
+ ``organization_services`` is the per-org entitlement that drives the
10
+ default-deny service guard in ``ResolveAuthContextUseCase``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Sequence, Union
15
+
16
+ import sqlalchemy as sa
17
+ from alembic import op
18
+ from sqlalchemy.dialects.postgresql import JSONB
19
+
20
+ # revision identifiers, used by Alembic.
21
+ revision: str = "pkg_auth_acl_0005"
22
+ down_revision: Union[str, None] = "pkg_auth_acl_0004"
23
+ branch_labels: Union[str, Sequence[str], None] = None
24
+ depends_on: Union[str, Sequence[str], None] = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ op.create_table(
29
+ "services",
30
+ sa.Column(
31
+ "id",
32
+ sa.Uuid(as_uuid=True),
33
+ primary_key=True,
34
+ server_default=sa.text("gen_random_uuid()"),
35
+ ),
36
+ sa.Column("name", sa.String(64), nullable=False),
37
+ sa.Column("display_label", JSONB, nullable=True),
38
+ sa.Column(
39
+ "auto_provision",
40
+ sa.Boolean(),
41
+ nullable=False,
42
+ server_default=sa.text("false"),
43
+ ),
44
+ sa.Column(
45
+ "saas_available",
46
+ sa.Boolean(),
47
+ nullable=False,
48
+ server_default=sa.text("false"),
49
+ ),
50
+ sa.Column(
51
+ "created_at",
52
+ sa.DateTime(timezone=True),
53
+ server_default=sa.text("now()"),
54
+ nullable=False,
55
+ ),
56
+ sa.Column(
57
+ "updated_at",
58
+ sa.DateTime(timezone=True),
59
+ server_default=sa.text("now()"),
60
+ nullable=False,
61
+ ),
62
+ sa.UniqueConstraint("name", name="uq_services_name"),
63
+ )
64
+ op.create_index("ix_services_name", "services", ["name"])
65
+
66
+ op.create_table(
67
+ "organization_services",
68
+ sa.Column(
69
+ "id",
70
+ sa.Uuid(as_uuid=True),
71
+ primary_key=True,
72
+ server_default=sa.text("gen_random_uuid()"),
73
+ ),
74
+ sa.Column("organization_id", sa.Uuid(as_uuid=True), nullable=False),
75
+ sa.Column("service_name", sa.String(64), nullable=False),
76
+ sa.Column(
77
+ "enabled",
78
+ sa.Boolean(),
79
+ nullable=False,
80
+ server_default=sa.text("true"),
81
+ ),
82
+ sa.Column(
83
+ "source",
84
+ sa.String(16),
85
+ nullable=False,
86
+ server_default=sa.text("'manual'"),
87
+ ),
88
+ sa.Column(
89
+ "granted_at",
90
+ sa.DateTime(timezone=True),
91
+ server_default=sa.text("now()"),
92
+ nullable=False,
93
+ ),
94
+ sa.ForeignKeyConstraint(
95
+ ["organization_id"], ["organizations.id"], ondelete="CASCADE"
96
+ ),
97
+ sa.UniqueConstraint(
98
+ "organization_id", "service_name",
99
+ name="uq_org_services_org_service",
100
+ ),
101
+ )
102
+ op.create_index(
103
+ "ix_org_services_org_id",
104
+ "organization_services",
105
+ ["organization_id"],
106
+ )
107
+ op.create_index(
108
+ "ix_org_services_service_name",
109
+ "organization_services",
110
+ ["service_name"],
111
+ )
112
+
113
+
114
+ def downgrade() -> None:
115
+ op.drop_table("organization_services")
116
+ op.drop_table("services")
@@ -0,0 +1 @@
1
+ """Alembic migration version files (branch_labels=('pkg_auth_acl',))."""
@@ -0,0 +1,187 @@
1
+ """Abstract column mixins for the ACL ORM models.
2
+
3
+ These mixins provide the ACL-essential columns without defining ``id``,
4
+ ``__tablename__``, FK columns, or relationships. Consuming services
5
+ extend them by creating a concrete model class that inherits from both
6
+ their own ``DeclarativeBase`` and the appropriate mixin.
7
+
8
+ Example (service extends UserMixin)::
9
+
10
+ from pkg_auth.authorization.adapters.sqlalchemy.mixins import UserMixin
11
+
12
+ class OrmUser(Base, UserMixin):
13
+ __tablename__ = "users"
14
+ id: Mapped[UUID] = mapped_column(Uuid, primary_key=True)
15
+ # UserMixin columns inherited automatically
16
+ # Add service-specific columns:
17
+ username: Mapped[str] = mapped_column(String(255))
18
+ bio: Mapped[str | None] = mapped_column(Text)
19
+
20
+ Services that do NOT need to extend use the default concrete models in
21
+ ``models.py`` which already inherit these mixins.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from datetime import datetime
26
+
27
+ from sqlalchemy import Boolean, DateTime, String, Text, func, text
28
+ from sqlalchemy.dialects.postgresql import JSONB
29
+ from sqlalchemy.orm import Mapped, mapped_column
30
+
31
+
32
+ class UserMixin:
33
+ """ACL columns for the users table."""
34
+
35
+ keycloak_sub: Mapped[str] = mapped_column(
36
+ String(64), unique=True, index=True
37
+ )
38
+ email: Mapped[str] = mapped_column(String(255), index=True)
39
+ full_name: Mapped[str | None] = mapped_column(String(255))
40
+ first_seen_at: Mapped[datetime] = mapped_column(
41
+ DateTime(timezone=True), server_default=func.now()
42
+ )
43
+ last_seen_at: Mapped[datetime] = mapped_column(
44
+ DateTime(timezone=True),
45
+ server_default=func.now(),
46
+ onupdate=func.now(),
47
+ )
48
+ created_at: Mapped[datetime] = mapped_column(
49
+ DateTime(timezone=True), server_default=func.now()
50
+ )
51
+ updated_at: Mapped[datetime] = mapped_column(
52
+ DateTime(timezone=True),
53
+ server_default=func.now(),
54
+ onupdate=func.now(),
55
+ )
56
+
57
+
58
+ class OrganizationMixin:
59
+ """ACL columns for the organizations table."""
60
+
61
+ slug: Mapped[str] = mapped_column(String(255), unique=True, index=True)
62
+ name: Mapped[str] = mapped_column(String(255))
63
+ created_at: Mapped[datetime] = mapped_column(
64
+ DateTime(timezone=True), server_default=func.now()
65
+ )
66
+ updated_at: Mapped[datetime] = mapped_column(
67
+ DateTime(timezone=True),
68
+ server_default=func.now(),
69
+ onupdate=func.now(),
70
+ )
71
+
72
+
73
+ class PermissionMixin:
74
+ """ACL columns for the permissions (catalog) table.
75
+
76
+ ``visibility`` controls which role builders may see/use the permission:
77
+ ``platform_only`` (platform org only), ``shared`` (everywhere, default),
78
+ or ``tenant_only`` (normal orgs only — hidden from the platform org).
79
+ Consuming services declare it inline via :class:`CatalogEntry`; the
80
+ central ACL UI filters by it via the ``scope=`` argument on the catalog
81
+ repo. ``description`` is a localized JSONB map (``{locale: text}``).
82
+ """
83
+
84
+ key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
85
+ service_name: Mapped[str] = mapped_column(String(64), index=True)
86
+ description: Mapped[dict | None] = mapped_column(JSONB)
87
+ visibility: Mapped[str] = mapped_column(
88
+ String(32),
89
+ nullable=False,
90
+ default="shared",
91
+ server_default=text("'shared'"),
92
+ index=True,
93
+ )
94
+ registered_at: Mapped[datetime] = mapped_column(
95
+ DateTime(timezone=True),
96
+ server_default=func.now(),
97
+ onupdate=func.now(),
98
+ )
99
+
100
+
101
+ class ServiceMixin:
102
+ """ACL columns for the ``services`` table (the service registry).
103
+
104
+ ``auto_provision`` and ``saas_available`` are vendor-controlled and set
105
+ only via the ``pkg-auth-sync-services`` path. ``display_label`` is a
106
+ localized JSONB map.
107
+ """
108
+
109
+ name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
110
+ display_label: Mapped[dict | None] = mapped_column(JSONB)
111
+ auto_provision: Mapped[bool] = mapped_column(
112
+ Boolean,
113
+ nullable=False,
114
+ default=False,
115
+ server_default=text("false"),
116
+ )
117
+ saas_available: Mapped[bool] = mapped_column(
118
+ Boolean,
119
+ nullable=False,
120
+ default=False,
121
+ server_default=text("false"),
122
+ )
123
+ created_at: Mapped[datetime] = mapped_column(
124
+ DateTime(timezone=True), server_default=func.now()
125
+ )
126
+ updated_at: Mapped[datetime] = mapped_column(
127
+ DateTime(timezone=True),
128
+ server_default=func.now(),
129
+ onupdate=func.now(),
130
+ )
131
+
132
+
133
+ class OrganizationServiceMixin:
134
+ """ACL columns for the ``organization_services`` table (per-org service
135
+ entitlements). FK columns and the ``(organization_id, service_name)``
136
+ unique constraint live on the concrete model.
137
+ """
138
+
139
+ service_name: Mapped[str] = mapped_column(String(64), index=True)
140
+ enabled: Mapped[bool] = mapped_column(
141
+ Boolean,
142
+ nullable=False,
143
+ default=True,
144
+ server_default=text("true"),
145
+ )
146
+ source: Mapped[str] = mapped_column(
147
+ String(16), nullable=False, server_default=text("'manual'")
148
+ )
149
+ granted_at: Mapped[datetime] = mapped_column(
150
+ DateTime(timezone=True), server_default=func.now()
151
+ )
152
+
153
+
154
+ class RoleMixin:
155
+ """ACL columns for the roles table."""
156
+
157
+ name: Mapped[str] = mapped_column(String(128))
158
+ description: Mapped[str | None] = mapped_column(Text)
159
+ created_at: Mapped[datetime] = mapped_column(
160
+ DateTime(timezone=True), server_default=func.now()
161
+ )
162
+ updated_at: Mapped[datetime] = mapped_column(
163
+ DateTime(timezone=True),
164
+ server_default=func.now(),
165
+ onupdate=func.now(),
166
+ )
167
+
168
+
169
+ class MembershipMixin:
170
+ """ACL columns for the memberships table.
171
+
172
+ Does NOT include ``status`` — services define their own status type
173
+ (string, enum, etc.). Does NOT include FK columns or relationships
174
+ — those depend on the concrete model's schema and table names.
175
+ """
176
+
177
+ joined_at: Mapped[datetime] = mapped_column(
178
+ DateTime(timezone=True), server_default=func.now()
179
+ )
180
+ created_at: Mapped[datetime] = mapped_column(
181
+ DateTime(timezone=True), server_default=func.now()
182
+ )
183
+ updated_at: Mapped[datetime] = mapped_column(
184
+ DateTime(timezone=True),
185
+ server_default=func.now(),
186
+ onupdate=func.now(),
187
+ )