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,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
|