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,61 @@
1
+ """Update a role's name, description, or permission set."""
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 UnknownRole
9
+ from ...domain.ports import PermissionCatalogRepository, RoleRepository
10
+ from ...domain.value_objects import OrgId, PermissionKey, RoleId, RoleName
11
+ from ._helpers import validate_permission_keys_for_role
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class UpdateRoleUseCase:
16
+ """Update an existing role.
17
+
18
+ Pass ``None`` for any field to leave it unchanged. When
19
+ ``permission_keys`` is provided, every referenced key must already
20
+ exist in the permission catalog.
21
+
22
+ Note on cache invalidation: services that wrap their
23
+ ``MembershipRepository`` with ``CachedMembershipRepository`` should
24
+ invalidate the cache prefix after this call returns. The package
25
+ documents the convention but does not auto-invalidate.
26
+ """
27
+
28
+ role_repo: RoleRepository
29
+ catalog_repo: PermissionCatalogRepository
30
+ platform_org_id: OrgId | None = None
31
+
32
+ async def execute(
33
+ self,
34
+ role_id: RoleId,
35
+ *,
36
+ name: RoleName | None = None,
37
+ description: str | None = None,
38
+ permission_keys: Sequence[PermissionKey] | None = None,
39
+ ) -> Role:
40
+ existing = await self.role_repo.get(role_id)
41
+ if existing is None:
42
+ raise UnknownRole(f"role {role_id} not found")
43
+
44
+ if permission_keys is not None:
45
+ await validate_permission_keys_for_role(
46
+ self.catalog_repo,
47
+ permission_keys,
48
+ is_platform_org=self._is_platform_org(existing.organization_id),
49
+ )
50
+
51
+ return await self.role_repo.update(
52
+ role_id,
53
+ name=name,
54
+ description=description,
55
+ permission_keys=permission_keys,
56
+ )
57
+
58
+ def _is_platform_org(self, org_id: OrgId | None) -> bool | None:
59
+ if org_id is None or self.platform_org_id is None:
60
+ return None
61
+ return org_id == self.platform_org_id
@@ -0,0 +1,54 @@
1
+ """Create or update a membership row."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import Membership
7
+ from ...domain.exceptions import (
8
+ UnknownOrganization,
9
+ UnknownRole,
10
+ UnknownUser,
11
+ )
12
+ from ...domain.ports import (
13
+ MembershipRepository,
14
+ OrganizationRepository,
15
+ RoleRepository,
16
+ UserRepository,
17
+ )
18
+ from ...domain.value_objects import OrgId, RoleId, UserId
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class UpsertMembershipUseCase:
23
+ """Idempotently grant a user a role in an organization.
24
+
25
+ Pre-flights the user, organization, and role to surface clean
26
+ domain exceptions instead of leaking adapter-level FK violations.
27
+ """
28
+
29
+ user_repo: UserRepository
30
+ organization_repo: OrganizationRepository
31
+ role_repo: RoleRepository
32
+ membership_repo: MembershipRepository
33
+
34
+ async def execute(
35
+ self,
36
+ *,
37
+ user_id: UserId,
38
+ org_id: OrgId,
39
+ role_id: RoleId,
40
+ status: str = "active",
41
+ ) -> Membership:
42
+ if await self.user_repo.get_by_id(user_id) is None:
43
+ raise UnknownUser(f"user {user_id} not found")
44
+ if await self.organization_repo.get(org_id) is None:
45
+ raise UnknownOrganization(f"organization {org_id} not found")
46
+ if await self.role_repo.get(role_id) is None:
47
+ raise UnknownRole(f"role {role_id} not found")
48
+
49
+ return await self.membership_repo.upsert(
50
+ user_id=user_id,
51
+ org_id=org_id,
52
+ role_id=role_id,
53
+ status=status,
54
+ )
@@ -0,0 +1 @@
1
+ """CLI entrypoints for the authorization package."""
@@ -0,0 +1,180 @@
1
+ """``pkg-auth-sync-catalog`` — deploy-time permission catalog sync.
2
+
3
+ Intended for an init container that runs with a DB credential holding
4
+ ``INSERT, UPDATE, DELETE`` on ``permissions`` only. The long-running
5
+ service should keep a separate SELECT-only credential and never call
6
+ this CLI.
7
+
8
+ Public surface is factored so services that want to customize something
9
+ (extra flags, a different catalog loader, a pre-built session factory)
10
+ can import the pieces and assemble their own entrypoint:
11
+
12
+ from pkg_auth.authorization.cli.sync_catalog import (
13
+ build_arg_parser, load_catalog, run,
14
+ )
15
+
16
+ def main() -> None:
17
+ parser = build_arg_parser()
18
+ parser.add_argument("--skip-legacy", action="store_true")
19
+ args = parser.parse_args()
20
+ asyncio.run(run(args, catalog_loader=my_loader))
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import importlib
27
+ import os
28
+ import sys
29
+ from typing import Awaitable, Callable, Sequence
30
+
31
+ from ..application.use_cases.register_permission_catalog import CatalogEntry
32
+ from ..application.use_cases.sync_permission_catalog import (
33
+ SyncPermissionCatalogUseCase,
34
+ SyncResult,
35
+ )
36
+ from ..domain.ports import PermissionCatalogRepository, ServiceRepository
37
+
38
+ CatalogLoader = Callable[[str], Sequence[CatalogEntry]]
39
+
40
+
41
+ def build_arg_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="pkg-auth-sync-catalog",
44
+ description=(
45
+ "Sync a service's permission catalog against the ACL database: "
46
+ "UPSERT declared entries, DELETE anything for the service that "
47
+ "is no longer declared."
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "--service",
52
+ required=True,
53
+ help="Service name whose catalog rows are managed by this run.",
54
+ )
55
+ parser.add_argument(
56
+ "--catalog",
57
+ required=True,
58
+ help=(
59
+ "Dotted path to the catalog iterable, e.g. "
60
+ "'courses.domain.permissions:CATALOG'."
61
+ ),
62
+ )
63
+ parser.add_argument(
64
+ "--db-url",
65
+ default=os.environ.get("ACL_DATABASE_URL"),
66
+ help=(
67
+ "SQLAlchemy async DB URL for the ACL database. "
68
+ "Falls back to the ACL_DATABASE_URL env var."
69
+ ),
70
+ )
71
+ parser.add_argument(
72
+ "--dry-run",
73
+ action="store_true",
74
+ help=(
75
+ "Do not write. Print what would be upserted / pruned and exit 0."
76
+ ),
77
+ )
78
+ return parser
79
+
80
+
81
+ def load_catalog(dotted: str) -> list[CatalogEntry]:
82
+ """Resolve ``module.path:ATTR`` to a list of :class:`CatalogEntry`."""
83
+ if ":" not in dotted:
84
+ raise ValueError(
85
+ f"Expected 'module.path:ATTR', got {dotted!r}"
86
+ )
87
+ module_path, attr = dotted.split(":", 1)
88
+ module = importlib.import_module(module_path)
89
+ try:
90
+ value = getattr(module, attr)
91
+ except AttributeError as exc:
92
+ raise ValueError(
93
+ f"Module {module_path!r} has no attribute {attr!r}"
94
+ ) from exc
95
+ return list(value)
96
+
97
+
98
+ async def run(
99
+ args: argparse.Namespace,
100
+ *,
101
+ repo: PermissionCatalogRepository | None = None,
102
+ service_repo: ServiceRepository | None = None,
103
+ session_factory: object | None = None,
104
+ catalog_loader: CatalogLoader = load_catalog,
105
+ ) -> SyncResult:
106
+ dispose: Callable[[], Awaitable[None]] | None = None
107
+ if repo is None:
108
+ from sqlalchemy.ext.asyncio import ( # noqa: PLC0415
109
+ async_sessionmaker,
110
+ create_async_engine,
111
+ )
112
+
113
+ from ..adapters.sqlalchemy.repositories.permission_catalog import ( # noqa: PLC0415
114
+ SqlAlchemyPermissionCatalogRepository,
115
+ )
116
+ from ..adapters.sqlalchemy.repositories.service import ( # noqa: PLC0415
117
+ SqlAlchemyServiceRepository,
118
+ )
119
+
120
+ if session_factory is None:
121
+ if not args.db_url:
122
+ raise SystemExit(
123
+ "--db-url is required (or set ACL_DATABASE_URL)"
124
+ )
125
+ engine = create_async_engine(args.db_url, future=True)
126
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
127
+ dispose = engine.dispose
128
+ repo = SqlAlchemyPermissionCatalogRepository(
129
+ session_factory=session_factory
130
+ )
131
+ if service_repo is None:
132
+ service_repo = SqlAlchemyServiceRepository(
133
+ session_factory=session_factory
134
+ )
135
+
136
+ entries = catalog_loader(args.catalog)
137
+ use_case = SyncPermissionCatalogUseCase(
138
+ catalog_repo=repo, service_repo=service_repo
139
+ )
140
+
141
+ if args.dry_run:
142
+ existing = await repo.list_for_service(args.service, scope="all")
143
+ existing_keys = {str(p.key) for p in existing}
144
+ declared_keys = {str(e.key) for e in entries}
145
+ to_add = sorted(declared_keys - existing_keys)
146
+ to_prune = sorted(existing_keys - declared_keys)
147
+ print(f"[dry-run] service={args.service}")
148
+ print(f"[dry-run] to add: {to_add}")
149
+ print(f"[dry-run] to prune: {to_prune}")
150
+
151
+ try:
152
+ result = await use_case.execute(
153
+ service_name=args.service,
154
+ entries=entries,
155
+ dry_run=args.dry_run,
156
+ )
157
+ finally:
158
+ if dispose is not None:
159
+ await dispose()
160
+ return result
161
+
162
+
163
+ def main(argv: Sequence[str] | None = None) -> None:
164
+ parser = build_arg_parser()
165
+ args = parser.parse_args(argv)
166
+ try:
167
+ result = asyncio.run(run(args))
168
+ except SystemExit:
169
+ raise
170
+ except Exception as exc:
171
+ print(f"pkg-auth-sync-catalog: {exc}", file=sys.stderr)
172
+ raise SystemExit(1) from exc
173
+ print(
174
+ f"sync: service={args.service} upserted={result.upserted} "
175
+ f"pruned={result.pruned} dry_run={result.dry_run}"
176
+ )
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,151 @@
1
+ """``pkg-auth-sync-services`` — deploy-time service registry sync.
2
+
3
+ The **vendor-only** path that declares which services exist and sets their
4
+ ``auto_provision`` / ``saas_available`` flags. Run from an init container
5
+ with a DB credential that may write the ``services`` table. The runtime
6
+ SaaS-toggle endpoint (:class:`SetOrganizationServiceUseCase`) can only
7
+ enable services this CLI has marked ``saas_available``.
8
+
9
+ The declared config is a dotted ``module:ATTR`` resolving to a sequence of
10
+ :class:`ServiceSpec` (build them with ``ServiceSpec.make`` for ergonomic
11
+ localized labels):
12
+
13
+ SERVICES = [
14
+ ServiceSpec.make("users", {"en": "Users"}, auto_provision=True),
15
+ ServiceSpec.make("assessments", {"en": "Assessments"},
16
+ saas_available=True),
17
+ ]
18
+
19
+ Composable pieces (``build_arg_parser``, ``load_services``, ``run``) mirror
20
+ ``sync_catalog`` so services can assemble custom entrypoints.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import asyncio
26
+ import importlib
27
+ import os
28
+ import sys
29
+ from typing import Awaitable, Callable, Sequence
30
+
31
+ from ..application.use_cases.sync_service_catalog import (
32
+ ServiceSpec,
33
+ ServiceSyncResult,
34
+ SyncServiceCatalogUseCase,
35
+ )
36
+ from ..domain.ports import ServiceRepository
37
+
38
+ ServicesLoader = Callable[[str], Sequence[ServiceSpec]]
39
+
40
+
41
+ def build_arg_parser() -> argparse.ArgumentParser:
42
+ parser = argparse.ArgumentParser(
43
+ prog="pkg-auth-sync-services",
44
+ description=(
45
+ "Sync the vendor service registry against the ACL database: "
46
+ "UPSERT declared services (name, label, auto_provision, "
47
+ "saas_available), DELETE services no longer declared."
48
+ ),
49
+ )
50
+ parser.add_argument(
51
+ "--services",
52
+ required=True,
53
+ help=(
54
+ "Dotted path to the service-spec iterable, e.g. "
55
+ "'platform.services:SERVICES'."
56
+ ),
57
+ )
58
+ parser.add_argument(
59
+ "--db-url",
60
+ default=os.environ.get("ACL_DATABASE_URL"),
61
+ help=(
62
+ "SQLAlchemy async DB URL for the ACL database. "
63
+ "Falls back to the ACL_DATABASE_URL env var."
64
+ ),
65
+ )
66
+ parser.add_argument(
67
+ "--dry-run",
68
+ action="store_true",
69
+ help="Do not write. Print what would be upserted / pruned and exit 0.",
70
+ )
71
+ return parser
72
+
73
+
74
+ def load_services(dotted: str) -> list[ServiceSpec]:
75
+ """Resolve ``module.path:ATTR`` to a list of :class:`ServiceSpec`."""
76
+ if ":" not in dotted:
77
+ raise ValueError(f"Expected 'module.path:ATTR', got {dotted!r}")
78
+ module_path, attr = dotted.split(":", 1)
79
+ module = importlib.import_module(module_path)
80
+ try:
81
+ value = getattr(module, attr)
82
+ except AttributeError as exc:
83
+ raise ValueError(
84
+ f"Module {module_path!r} has no attribute {attr!r}"
85
+ ) from exc
86
+ return list(value)
87
+
88
+
89
+ async def run(
90
+ args: argparse.Namespace,
91
+ *,
92
+ repo: ServiceRepository | None = None,
93
+ session_factory: object | None = None,
94
+ services_loader: ServicesLoader = load_services,
95
+ ) -> ServiceSyncResult:
96
+ dispose: Callable[[], Awaitable[None]] | None = None
97
+ if repo is None:
98
+ from sqlalchemy.ext.asyncio import ( # noqa: PLC0415
99
+ async_sessionmaker,
100
+ create_async_engine,
101
+ )
102
+
103
+ from ..adapters.sqlalchemy.repositories.service import ( # noqa: PLC0415
104
+ SqlAlchemyServiceRepository,
105
+ )
106
+
107
+ if session_factory is None:
108
+ if not args.db_url:
109
+ raise SystemExit(
110
+ "--db-url is required (or set ACL_DATABASE_URL)"
111
+ )
112
+ engine = create_async_engine(args.db_url, future=True)
113
+ session_factory = async_sessionmaker(engine, expire_on_commit=False)
114
+ dispose = engine.dispose
115
+ repo = SqlAlchemyServiceRepository(session_factory=session_factory)
116
+
117
+ services = services_loader(args.services)
118
+ use_case = SyncServiceCatalogUseCase(service_repo=repo)
119
+
120
+ if args.dry_run:
121
+ existing = {str(s.name) for s in await repo.list_all()}
122
+ declared = {str(s.name) for s in services}
123
+ print(f"[dry-run] to add: {sorted(declared - existing)}")
124
+ print(f"[dry-run] to prune: {sorted(existing - declared)}")
125
+
126
+ try:
127
+ result = await use_case.execute(services=services, dry_run=args.dry_run)
128
+ finally:
129
+ if dispose is not None:
130
+ await dispose()
131
+ return result
132
+
133
+
134
+ def main(argv: Sequence[str] | None = None) -> None:
135
+ parser = build_arg_parser()
136
+ args = parser.parse_args(argv)
137
+ try:
138
+ result = asyncio.run(run(args))
139
+ except SystemExit:
140
+ raise
141
+ except Exception as exc:
142
+ print(f"pkg-auth-sync-services: {exc}", file=sys.stderr)
143
+ raise SystemExit(1) from exc
144
+ print(
145
+ f"sync-services: upserted={result.upserted} "
146
+ f"pruned={result.pruned} dry_run={result.dry_run}"
147
+ )
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,21 @@
1
+ """Process-level authorization config read from the environment.
2
+
3
+ Kept out of the (pure) domain layer so value objects stay free of I/O.
4
+ The application/adapter/CLI layers call :func:`default_locale` when they
5
+ need to coerce a bare-string description into a :class:`LocalizedText`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ DEFAULT_LOCALE_ENV = "ACL_DEFAULT_LOCALE"
12
+ FALLBACK_LOCALE = "en"
13
+
14
+
15
+ def default_locale() -> str:
16
+ """Return the configured default/fallback locale.
17
+
18
+ Reads ``ACL_DEFAULT_LOCALE`` from the environment, falling back to
19
+ ``"en"`` when unset or empty.
20
+ """
21
+ return os.environ.get(DEFAULT_LOCALE_ENV) or FALLBACK_LOCALE
@@ -0,0 +1 @@
1
+ """Authorization domain layer (entities, value objects, ports, exceptions)."""
@@ -0,0 +1,192 @@
1
+ """Authorization domain entities.
2
+
3
+ All entities are frozen dataclasses with slots. They are loaded from the
4
+ central ACL database by the SQLAlchemy / Django ORM repositories and
5
+ treated as immutable snapshots. The entities are schema-agnostic — the
6
+ concrete ``db_table`` / ``__tablename__`` values live in the adapter
7
+ layer, and source-of-truth services that extend the ACL tables (Mode A)
8
+ pick their own schema and table names via the mixin pattern.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from uuid import UUID
15
+
16
+ from .exceptions import MissingPermission
17
+ from .value_objects import (
18
+ LocalizedText,
19
+ OrgId,
20
+ PermissionId,
21
+ PermissionKey,
22
+ PermissionVisibility,
23
+ RoleId,
24
+ RoleName,
25
+ ServiceName,
26
+ UserId,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class User:
32
+ """A row from the ``users`` table.
33
+
34
+ The package owns the users table; rows are upserted lazily on the
35
+ first JWT seen by ``SyncUserFromJwtUseCase``. ``keycloak_sub`` is
36
+ the unique link to the IdP identity.
37
+ """
38
+
39
+ id: UserId
40
+ keycloak_sub: str
41
+ email: str
42
+ full_name: str | None
43
+ first_seen_at: datetime
44
+ last_seen_at: datetime
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class Organization:
49
+ """A row from the ``organizations`` table."""
50
+
51
+ id: OrgId
52
+ slug: str
53
+ name: str
54
+ created_at: datetime
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Permission:
59
+ """A row from the ``permissions`` table (the global permission catalog).
60
+
61
+ Each downstream service registers its own permission keys on boot
62
+ via ``RegisterPermissionCatalogUseCase``. ``visibility`` controls
63
+ which role builders may see/use the permission (platform-only,
64
+ shared, or tenant-only — hidden from the platform org). ``description``
65
+ is a localized text map (``{"en": ..., "ar": ...}``) for the central
66
+ role-editor UI.
67
+ """
68
+
69
+ id: PermissionId
70
+ key: PermissionKey
71
+ service_name: str
72
+ description: LocalizedText
73
+ visibility: PermissionVisibility = PermissionVisibility.SHARED
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class Service:
78
+ """A row from the ``services`` table — a deployable product surface
79
+ (e.g. ``assessments``, ``courses``) that an organization may be granted.
80
+
81
+ ``auto_provision`` services are enabled automatically for every new
82
+ organization. ``saas_available`` marks a service the **vendor** has
83
+ greenlit to be offered as SaaS; only those may be enabled for an org
84
+ through the runtime API. Both flags are vendor-controlled and set via
85
+ the ``pkg-auth-sync-services`` CLI / config — never a runtime endpoint.
86
+ """
87
+
88
+ name: ServiceName
89
+ display_label: LocalizedText
90
+ auto_provision: bool = False
91
+ saas_available: bool = False
92
+ created_at: datetime | None = None
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class OrganizationService:
97
+ """A row from the ``organization_services`` table — the entitlement
98
+ linking an organization to a service it may use.
99
+
100
+ ``source`` is ``"auto"`` (granted by default-service provisioning) or
101
+ ``"manual"`` (toggled by a platform admin). The service guard in
102
+ :class:`ResolveAuthContextUseCase` drops any permission whose
103
+ ``service_name`` is not enabled for the org.
104
+ """
105
+
106
+ organization_id: OrgId
107
+ service_name: ServiceName
108
+ enabled: bool = True
109
+ source: str = "manual"
110
+ granted_at: datetime | None = None
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class Role:
115
+ """A row from the ``roles`` table.
116
+
117
+ ``organization_id`` is ``None`` for global role templates that can
118
+ be reused across organizations. ``permission_keys`` is the
119
+ denormalized set of permission strings the role grants — fast for
120
+ in-memory ``.has(perm)`` checks at the call site.
121
+ """
122
+
123
+ id: RoleId
124
+ organization_id: OrgId | None
125
+ name: RoleName
126
+ description: str | None
127
+ permission_keys: frozenset[str] = field(default_factory=frozenset)
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class Membership:
132
+ """A row from the ``memberships`` table.
133
+
134
+ ``role_name`` is denormalized from the joined role for cheap
135
+ construction of an :class:`AuthContext` without re-querying. A user
136
+ can hold multiple memberships in the same organization — one row per
137
+ role — and the schema enforces uniqueness on
138
+ ``(user_id, organization_id, role_id)``.
139
+ """
140
+
141
+ id: UUID
142
+ user_id: UserId
143
+ organization_id: OrgId
144
+ role_id: RoleId
145
+ role_name: RoleName
146
+ status: str
147
+ joined_at: datetime
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class AuthContext:
152
+ """Hot-path authorization context for a (user, organization) request.
153
+
154
+ Built once per request by ``ResolveAuthContextUseCase`` and passed
155
+ through to handlers. A user can have **multiple roles** in an org;
156
+ ``perms`` is the **union** of all active roles' permissions.
157
+
158
+ Frozen because handler code must not mutate the perms set after the
159
+ dependency layer has built it.
160
+
161
+ pkg_auth deliberately does NOT carry a ``is_platform`` flag here.
162
+ Platform-admin detection is a *service-level* policy: consuming
163
+ services cache their platform org's id at startup and call
164
+ :func:`pkg_auth.authorization.is_platform_context` to compare against
165
+ ``self.organization_id``. See the package docs for the pattern.
166
+ """
167
+
168
+ user_id: UserId
169
+ organization_id: OrgId
170
+ role_names: frozenset[str]
171
+ perms: frozenset[str]
172
+
173
+ def has(self, perm: str) -> bool:
174
+ """Return ``True`` if any of the user's roles grant ``perm``."""
175
+ return perm in self.perms
176
+
177
+ def require(self, perm: str) -> None:
178
+ """Raise :class:`MissingPermission` if ``perm`` is not granted.
179
+
180
+ Equivalent to ``if not ctx.has(perm): raise MissingPermission(...)``
181
+ but exported as a method for ergonomic ``ctx.require("course:edit")``
182
+ call sites.
183
+ """
184
+ if perm not in self.perms:
185
+ raise MissingPermission(
186
+ f"permission {perm!r} required on org {self.organization_id} "
187
+ f"(roles {sorted(self.role_names)})"
188
+ )
189
+
190
+ def has_role(self, role: str) -> bool:
191
+ """Return ``True`` if the user has the named role in this org."""
192
+ return role in self.role_names