pkg-auth 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pkg_auth/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|