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,21 @@
1
+ """Remove a membership row."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.ports import MembershipRepository
7
+ from ...domain.value_objects import OrgId, UserId
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class DeleteMembershipUseCase:
12
+ """Idempotently remove a user's membership in an organization.
13
+
14
+ Calling on a non-existent membership is a no-op (does not raise),
15
+ so this can be safely retried.
16
+ """
17
+
18
+ membership_repo: MembershipRepository
19
+
20
+ async def execute(self, *, user_id: UserId, org_id: OrgId) -> None:
21
+ await self.membership_repo.delete(user_id, org_id)
@@ -0,0 +1,21 @@
1
+ """Delete an organization (and cascade memberships, roles)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.ports import OrganizationRepository
7
+ from ...domain.value_objects import OrgId
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class DeleteOrganizationUseCase:
12
+ """Idempotently delete an organization.
13
+
14
+ Cascades to memberships and org-scoped roles via DB-level
15
+ ``ON DELETE CASCADE``. Calling on a non-existent org is a no-op.
16
+ """
17
+
18
+ organization_repo: OrganizationRepository
19
+
20
+ async def execute(self, org_id: OrgId) -> None:
21
+ await self.organization_repo.delete(org_id)
@@ -0,0 +1,23 @@
1
+ """Delete a role (and cascade-detach any memberships)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.ports import RoleRepository
7
+ from ...domain.value_objects import RoleId
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class DeleteRoleUseCase:
12
+ """Idempotently delete a role.
13
+
14
+ The DB schema uses ``ON DELETE RESTRICT`` for membership FKs to the
15
+ role; if any memberships still reference this role, the repository
16
+ raises a conflict error which the integration layer can map to
17
+ HTTP 409. Callers must reassign or remove memberships first.
18
+ """
19
+
20
+ role_repo: RoleRepository
21
+
22
+ async def execute(self, role_id: RoleId) -> None:
23
+ await self.role_repo.delete(role_id)
@@ -0,0 +1,21 @@
1
+ """List the organizations a user belongs to."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import Organization
7
+ from ...domain.ports import OrganizationRepository
8
+ from ...domain.value_objects import UserId
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ListUserOrganizationsUseCase:
13
+ """Return the organizations a user has any membership in.
14
+
15
+ Used by "switch organization" UIs in client apps.
16
+ """
17
+
18
+ organization_repo: OrganizationRepository
19
+
20
+ async def execute(self, user_id: UserId) -> list[Organization]:
21
+ return await self.organization_repo.list_for_user(user_id)
@@ -0,0 +1,38 @@
1
+ """Provision the default (auto-provision) services for an organization."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.ports import (
7
+ OrganizationServiceRepository,
8
+ ServiceRepository,
9
+ )
10
+ from ...domain.value_objects import OrgId
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ProvisionDefaultServicesUseCase:
15
+ """Enable every ``auto_provision`` service for an organization.
16
+
17
+ Called on organization creation (wired into pkg_auth's own
18
+ :class:`CreateOrganizationUseCase`; Mode A services call it from their
19
+ own org-creation flow). Idempotent — re-running only re-enables the same
20
+ default set with ``source="auto"``.
21
+
22
+ Under the default-deny service guard, an org that never runs this (and is
23
+ never granted services manually) resolves to zero permissions for normal
24
+ members. Mark core services (e.g. ``users``) ``auto_provision=True`` so
25
+ every org gets them.
26
+ """
27
+
28
+ service_repo: ServiceRepository
29
+ org_service_repo: OrganizationServiceRepository
30
+
31
+ async def execute(self, *, org_id: OrgId) -> list[str]:
32
+ services = await self.service_repo.list_all()
33
+ names = [s.name for s in services if s.auto_provision]
34
+ if names:
35
+ await self.org_service_repo.bulk_enable(
36
+ org_id, names, source="auto"
37
+ )
38
+ return [str(n) for n in names]
@@ -0,0 +1,122 @@
1
+ """Register a service's permission catalog at startup."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Mapping, Sequence, Union
6
+
7
+ from ...config import default_locale
8
+ from ...domain.ports import PermissionCatalogRepository, ServiceRepository
9
+ from ...domain.value_objects import (
10
+ LocalizedText,
11
+ PermissionKey,
12
+ PermissionVisibility,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class CatalogEntry:
18
+ """One row a service registers into the central permission catalog.
19
+
20
+ ``visibility`` controls which role builders may see/use the permission:
21
+
22
+ - ``SHARED`` (default) — usable everywhere.
23
+ - ``PLATFORM_ONLY`` — only the platform org (e.g. ``organizations:create``).
24
+ - ``TENANT_ONLY`` — only normal orgs; hidden from the platform org.
25
+
26
+ ``description`` is a :class:`LocalizedText` locale→text map. Build entries
27
+ with :meth:`make` to accept a plain string (stored under the configured
28
+ default locale) or a ``{locale: text}`` dict.
29
+ """
30
+
31
+ key: PermissionKey
32
+ description: LocalizedText = field(default_factory=lambda: LocalizedText({}))
33
+ visibility: PermissionVisibility = PermissionVisibility.SHARED
34
+
35
+ def __post_init__(self) -> None:
36
+ # Coerce a plain string / dict / None description into LocalizedText
37
+ # so positional ``CatalogEntry(key, "text")`` stays ergonomic.
38
+ if not isinstance(self.description, LocalizedText):
39
+ object.__setattr__(
40
+ self,
41
+ "description",
42
+ LocalizedText.from_input(
43
+ self.description, default_locale=default_locale()
44
+ ),
45
+ )
46
+
47
+ @classmethod
48
+ def make(
49
+ cls,
50
+ key: PermissionKey,
51
+ description: "LocalizedText | Mapping[str, str] | str | None" = None,
52
+ visibility: PermissionVisibility = PermissionVisibility.SHARED,
53
+ *,
54
+ default_locale_: str | None = None,
55
+ ) -> "CatalogEntry":
56
+ """Ergonomic constructor that coerces ``description`` into a
57
+ :class:`LocalizedText` using the configured default locale.
58
+ """
59
+ loc = default_locale_ or default_locale()
60
+ return cls(
61
+ key=key,
62
+ description=LocalizedText.from_input(description, default_locale=loc),
63
+ visibility=visibility,
64
+ )
65
+
66
+
67
+ CatalogEntryInput = Union[
68
+ CatalogEntry,
69
+ tuple[PermissionKey, "LocalizedText | Mapping[str, str] | str | None"],
70
+ tuple[
71
+ PermissionKey,
72
+ "LocalizedText | Mapping[str, str] | str | None",
73
+ PermissionVisibility,
74
+ ],
75
+ ]
76
+
77
+
78
+ def _normalize_entry(entry: CatalogEntryInput) -> CatalogEntry:
79
+ if isinstance(entry, CatalogEntry):
80
+ return entry
81
+ if isinstance(entry, tuple):
82
+ if len(entry) == 2:
83
+ key, description = entry
84
+ return CatalogEntry.make(key, description)
85
+ if len(entry) == 3:
86
+ key, description, visibility = entry
87
+ return CatalogEntry.make(key, description, visibility)
88
+ raise ValueError(f"Invalid catalog entry tuple length: {len(entry)}")
89
+ raise TypeError(f"Unsupported catalog entry type: {type(entry).__name__}")
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class RegisterPermissionCatalogUseCase:
94
+ """Idempotently register the permission keys a service knows about.
95
+
96
+ Each consuming service calls this on boot with its static perm
97
+ list. The repository upserts by ``key`` so calling it on every
98
+ restart is safe and converges. Re-registering the same key with a
99
+ different ``visibility`` flips it.
100
+
101
+ When a ``service_repo`` is wired, a bare ``services`` row is ensured for
102
+ ``service_name`` (safe defaults, never overwriting vendor flags) so the
103
+ default-deny service guard does not strip the service's perms before the
104
+ vendor configures it.
105
+ """
106
+
107
+ catalog_repo: PermissionCatalogRepository
108
+ service_repo: ServiceRepository | None = None
109
+
110
+ async def execute(
111
+ self,
112
+ *,
113
+ service_name: str,
114
+ entries: Sequence[CatalogEntryInput],
115
+ ) -> None:
116
+ normalized = [_normalize_entry(e) for e in entries]
117
+ if self.service_repo is not None:
118
+ await self.service_repo.ensure_exists(service_name=service_name)
119
+ await self.catalog_repo.register_many(
120
+ service_name=service_name,
121
+ entries=normalized,
122
+ )
@@ -0,0 +1,70 @@
1
+ """Resolve an :class:`AuthContext` for a (user, organization) pair."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import AuthContext
7
+ from ...domain.exceptions import NotAMember
8
+ from ...domain.ports import (
9
+ MembershipRepository,
10
+ OrganizationServiceRepository,
11
+ PermissionCatalogRepository,
12
+ )
13
+ from ...domain.value_objects import OrgId, UserId
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class ResolveAuthContextUseCase:
18
+ """Hot-path use case: load the AuthContext for a request.
19
+
20
+ Called once per protected request by the FastAPI / Django / Strawberry
21
+ deps. The membership repository is responsible for joining the role
22
+ and its permissions in a single query so this is a single network
23
+ round-trip to Postgres (or a hit on the cache decorator).
24
+
25
+ **Service guard.** When ``org_service_repo`` and ``catalog_repo`` are
26
+ wired, the resolved permissions are filtered down to the services the
27
+ organization actually has enabled (**default-deny**: a perm whose owning
28
+ service is not enabled for the org is dropped). The platform org — when
29
+ its id is passed as ``platform_org_id`` — bypasses the guard entirely so
30
+ platform admins keep all permissions. Leaving the guard repos unset
31
+ preserves the pre-guard behavior (no filtering), which lets services
32
+ adopt the guard incrementally.
33
+ """
34
+
35
+ membership_repo: MembershipRepository
36
+ org_service_repo: OrganizationServiceRepository | None = None
37
+ catalog_repo: PermissionCatalogRepository | None = None
38
+ platform_org_id: OrgId | None = None
39
+
40
+ async def execute(self, user_id: UserId, org_id: OrgId) -> AuthContext:
41
+ ctx = await self.membership_repo.load_auth_context(user_id, org_id)
42
+ if ctx is None:
43
+ raise NotAMember(
44
+ f"user {user_id} is not a member of org {org_id}"
45
+ )
46
+ return await self._apply_service_guard(ctx, org_id)
47
+
48
+ async def _apply_service_guard(
49
+ self, ctx: AuthContext, org_id: OrgId
50
+ ) -> AuthContext:
51
+ if self.org_service_repo is None or self.catalog_repo is None:
52
+ return ctx # guard not wired
53
+ if self.platform_org_id is not None and org_id == self.platform_org_id:
54
+ return ctx # platform admins bypass the guard
55
+ if not ctx.perms:
56
+ return ctx
57
+
58
+ enabled = await self.org_service_repo.list_enabled_service_names(org_id)
59
+ service_map = await self.catalog_repo.get_service_map()
60
+ allowed = frozenset(
61
+ perm for perm in ctx.perms if service_map.get(perm) in enabled
62
+ )
63
+ if allowed == ctx.perms:
64
+ return ctx
65
+ return AuthContext(
66
+ user_id=ctx.user_id,
67
+ organization_id=ctx.organization_id,
68
+ role_names=ctx.role_names,
69
+ perms=allowed,
70
+ )
@@ -0,0 +1,34 @@
1
+ """Resolve a local user from a Keycloak JWT identity (read-only, Mode B)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import User
7
+ from ...domain.exceptions import UserNotProvisioned
8
+ from ...domain.ports import UserRepository
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class ResolveUserFromJwtUseCase:
13
+ """Look up a local ``users`` row by Keycloak ``sub`` without writing.
14
+
15
+ Use this in Mode B (consuming) services whose ``ACL_DATABASE_URL``
16
+ points at a Mode A-owned database. Writing the ``users`` table is
17
+ the source-of-truth service's job; a Mode B consumer that has never
18
+ seen a given ``sub`` means the SoT hasn't provisioned them yet —
19
+ which is a 403, not a signal to insert.
20
+
21
+ Raises :class:`UserNotProvisioned` when no row exists for ``sub``.
22
+ Integration layers map that to HTTP 403.
23
+ """
24
+
25
+ user_repo: UserRepository
26
+
27
+ async def execute(self, *, sub: str) -> User:
28
+ user = await self.user_repo.get_by_keycloak_sub(sub)
29
+ if user is None:
30
+ raise UserNotProvisioned(
31
+ f"No local user for Keycloak sub {sub!r}; "
32
+ "source-of-truth service has not provisioned this user yet."
33
+ )
34
+ return user
@@ -0,0 +1,50 @@
1
+ """Enable/disable a service for an organization (SaaS governance)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import OrganizationService
7
+ from ...domain.exceptions import ServiceNotSaaSAvailable, UnknownService
8
+ from ...domain.ports import (
9
+ OrganizationServiceRepository,
10
+ ServiceRepository,
11
+ )
12
+ from ...domain.value_objects import OrgId, ServiceName
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class SetOrganizationServiceUseCase:
17
+ """Toggle a service for an organization, enforcing vendor SaaS policy.
18
+
19
+ This is what a platform-admin API endpoint calls. Enabling is rejected
20
+ with :class:`ServiceNotSaaSAvailable` unless the service is marked
21
+ ``saas_available`` by the vendor (via ``pkg-auth-sync-services``), which
22
+ is how the package owner keeps the client from offering arbitrary
23
+ services as SaaS. Disabling is always allowed.
24
+ """
25
+
26
+ service_repo: ServiceRepository
27
+ org_service_repo: OrganizationServiceRepository
28
+
29
+ async def execute(
30
+ self,
31
+ *,
32
+ org_id: OrgId,
33
+ service_name: ServiceName,
34
+ enabled: bool,
35
+ ) -> OrganizationService | None:
36
+ service = await self.service_repo.get(service_name)
37
+ if service is None:
38
+ raise UnknownService(f"service {service_name} is not registered")
39
+
40
+ if not enabled:
41
+ await self.org_service_repo.disable(org_id, service_name)
42
+ return None
43
+
44
+ if not service.saas_available:
45
+ raise ServiceNotSaaSAvailable(
46
+ f"service {service_name} is not available to offer as SaaS"
47
+ )
48
+ return await self.org_service_repo.enable(
49
+ org_id, service_name, source="manual"
50
+ )
@@ -0,0 +1,86 @@
1
+ """Sync a service's permission catalog — upsert declared keys, prune absent ones.
2
+
3
+ Use from a deploy-time init container with a DB credential that has
4
+ ``INSERT, UPDATE, DELETE`` on ``permissions``. Runtime service credentials
5
+ should remain SELECT-only and use :class:`RegisterPermissionCatalogUseCase`
6
+ (or nothing at all) instead.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Sequence
12
+
13
+ from ...domain.ports import PermissionCatalogRepository, ServiceRepository
14
+ from .register_permission_catalog import (
15
+ CatalogEntry,
16
+ CatalogEntryInput,
17
+ _normalize_entry,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class SyncResult:
23
+ """Outcome of a catalog sync run.
24
+
25
+ For non-dry-runs, ``upserted`` is the number of declared entries
26
+ (all of them are UPSERTed unconditionally by ``register_many``) and
27
+ ``pruned`` is the number of DB rows deleted. For dry-runs, both
28
+ numbers are the *would-be* counts computed from a diff against the
29
+ current DB state; no writes happen.
30
+ """
31
+
32
+ upserted: int
33
+ pruned: int
34
+ dry_run: bool
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class SyncPermissionCatalogUseCase:
39
+ """Upsert declared entries then delete anything for the service that is
40
+ no longer declared.
41
+
42
+ Scoped by ``service_name`` so running sync for ``courses`` will never
43
+ touch permissions owned by other services (e.g. ``users:*``).
44
+ """
45
+
46
+ catalog_repo: PermissionCatalogRepository
47
+ service_repo: ServiceRepository | None = None
48
+
49
+ async def execute(
50
+ self,
51
+ *,
52
+ service_name: str,
53
+ entries: Sequence[CatalogEntryInput],
54
+ dry_run: bool = False,
55
+ ) -> SyncResult:
56
+ normalized: list[CatalogEntry] = [_normalize_entry(e) for e in entries]
57
+ keep_keys = [e.key for e in normalized]
58
+
59
+ if dry_run:
60
+ existing = await self.catalog_repo.list_for_service(
61
+ service_name, scope="all"
62
+ )
63
+ existing_keys = {str(p.key) for p in existing}
64
+ declared_keys = {str(k) for k in keep_keys}
65
+ would_prune = existing_keys - declared_keys
66
+ return SyncResult(
67
+ upserted=len(normalized),
68
+ pruned=len(would_prune),
69
+ dry_run=True,
70
+ )
71
+
72
+ if self.service_repo is not None:
73
+ await self.service_repo.ensure_exists(service_name=service_name)
74
+ await self.catalog_repo.register_many(
75
+ service_name=service_name,
76
+ entries=normalized,
77
+ )
78
+ pruned = await self.catalog_repo.prune_absent(
79
+ service_name=service_name,
80
+ keep_keys=keep_keys,
81
+ )
82
+ return SyncResult(
83
+ upserted=len(normalized),
84
+ pruned=pruned,
85
+ dry_run=False,
86
+ )
@@ -0,0 +1,91 @@
1
+ """Sync the vendor-declared service registry.
2
+
3
+ This is the **only** path that sets the vendor-controlled flags
4
+ ``auto_provision`` and ``saas_available`` on a service. Run it from a
5
+ deploy-time init container / CLI (``pkg-auth-sync-services``) with a DB
6
+ credential that may write the ``services`` table — never from a runtime
7
+ API. Runtime service enablement for an org goes through
8
+ :class:`SetOrganizationServiceUseCase`, which can only toggle services
9
+ already marked ``saas_available`` here.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Mapping, Sequence, Union
15
+
16
+ from ...config import default_locale
17
+ from ...domain.ports import ServiceRepository
18
+ from ...domain.value_objects import LocalizedText, ServiceName
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ServiceSpec:
23
+ """One row the vendor declares into the service registry."""
24
+
25
+ name: ServiceName
26
+ display_label: LocalizedText = field(
27
+ default_factory=lambda: LocalizedText({})
28
+ )
29
+ auto_provision: bool = False
30
+ saas_available: bool = False
31
+
32
+ @classmethod
33
+ def make(
34
+ cls,
35
+ name: ServiceName | str,
36
+ display_label: "LocalizedText | Mapping[str, str] | str | None" = None,
37
+ *,
38
+ auto_provision: bool = False,
39
+ saas_available: bool = False,
40
+ default_locale_: str | None = None,
41
+ ) -> "ServiceSpec":
42
+ loc = default_locale_ or default_locale()
43
+ return cls(
44
+ name=name if isinstance(name, ServiceName) else ServiceName(name),
45
+ display_label=LocalizedText.from_input(
46
+ display_label, default_locale=loc
47
+ ),
48
+ auto_provision=auto_provision,
49
+ saas_available=saas_available,
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ServiceSyncResult:
55
+ upserted: int
56
+ pruned: int
57
+ dry_run: bool
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class SyncServiceCatalogUseCase:
62
+ """Upsert declared services then prune services no longer declared."""
63
+
64
+ service_repo: ServiceRepository
65
+
66
+ async def execute(
67
+ self,
68
+ *,
69
+ services: Sequence[ServiceSpec],
70
+ dry_run: bool = False,
71
+ ) -> ServiceSyncResult:
72
+ keep = [s.name for s in services]
73
+
74
+ if dry_run:
75
+ existing = await self.service_repo.list_all()
76
+ existing_names = {str(s.name) for s in existing}
77
+ declared_names = {str(n) for n in keep}
78
+ would_prune = existing_names - declared_names
79
+ return ServiceSyncResult(
80
+ upserted=len(services),
81
+ pruned=len(would_prune),
82
+ dry_run=True,
83
+ )
84
+
85
+ await self.service_repo.upsert_many(services)
86
+ pruned = await self.service_repo.prune_absent(keep=keep)
87
+ return ServiceSyncResult(
88
+ upserted=len(services),
89
+ pruned=pruned,
90
+ dry_run=False,
91
+ )
@@ -0,0 +1,32 @@
1
+ """Sync a user record from a Keycloak JWT identity (lazy upsert)."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import User
7
+ from ...domain.ports import UserRepository
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class SyncUserFromJwtUseCase:
12
+ """Upsert a row in ``acl.users`` from JWT identity claims.
13
+
14
+ Called by integration deps the first time they see a JWT for a given
15
+ Keycloak ``sub``. Idempotent: subsequent calls update ``email`` /
16
+ ``full_name`` / ``last_seen_at`` but never change the user ``id``.
17
+ """
18
+
19
+ user_repo: UserRepository
20
+
21
+ async def execute(
22
+ self,
23
+ *,
24
+ sub: str,
25
+ email: str,
26
+ full_name: str | None,
27
+ ) -> User:
28
+ return await self.user_repo.upsert_from_identity(
29
+ sub=sub,
30
+ email=email,
31
+ full_name=full_name,
32
+ )
@@ -0,0 +1,31 @@
1
+ """Update an organization's metadata."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ...domain.entities import Organization
7
+ from ...domain.exceptions import UnknownOrganization
8
+ from ...domain.ports import OrganizationRepository
9
+ from ...domain.value_objects import OrgId
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class UpdateOrganizationUseCase:
14
+ """Update an organization's display name.
15
+
16
+ The slug is intentionally immutable (changing it would break URLs
17
+ and ``X-Organization-Id`` headers in flight). Pass ``name=None`` to
18
+ leave the name unchanged.
19
+ """
20
+
21
+ organization_repo: OrganizationRepository
22
+
23
+ async def execute(
24
+ self,
25
+ org_id: OrgId,
26
+ *,
27
+ name: str | None = None,
28
+ ) -> Organization:
29
+ if await self.organization_repo.get(org_id) is None:
30
+ raise UnknownOrganization(f"organization {org_id} not found")
31
+ return await self.organization_repo.update(org_id, name=name)