pkg-auth 3.0.0__tar.gz → 3.2.0__tar.gz
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-3.0.0 → pkg_auth-3.2.0}/PKG-INFO +31 -14
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/README.md +28 -11
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/pyproject.toml +3 -3
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/__init__.py +1 -1
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/__init__.py +10 -2
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +28 -5
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +24 -5
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +39 -5
- pkg_auth-3.2.0/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +169 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +5 -1
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +21 -7
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/sync_catalog.py +49 -1
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/sync_services.py +14 -2
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/entities.py +7 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/exceptions.py +36 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/ports.py +26 -7
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +13 -1
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/__init__.py +4 -1
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +46 -6
- pkg_auth-3.2.0/src/pkg_auth/integrations/fastapi/current_user_dep.py +78 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/auth.py +14 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/PKG-INFO +31 -14
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/SOURCES.txt +2 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/requires.txt +2 -2
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/setup.cfg +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/cli.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/client.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/env.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/helpers.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/provision_client.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/settings.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/exceptions.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/config.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/platform.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/apps.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/install.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pkg-auth
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Clean-architecture auth core for multiple Python frameworks
|
|
5
5
|
Author-email: Fritill <info@fritill.ae>
|
|
6
6
|
License: MIT
|
|
@@ -19,7 +19,7 @@ Requires-Dist: types-requests<3.0.0.0,>=2.32.0.0; extra == "dev"
|
|
|
19
19
|
Requires-Dist: types-PyJWT<2.0.0,>=1.7.0; extra == "dev"
|
|
20
20
|
Requires-Dist: testcontainers[postgres,redis]>=4.0; extra == "dev"
|
|
21
21
|
Provides-Extra: acl-sqlalchemy
|
|
22
|
-
Requires-Dist: sqlalchemy<3.0,>=2.0; extra == "acl-sqlalchemy"
|
|
22
|
+
Requires-Dist: sqlalchemy[asyncio]<3.0,>=2.0; extra == "acl-sqlalchemy"
|
|
23
23
|
Requires-Dist: asyncpg>=0.29; extra == "acl-sqlalchemy"
|
|
24
24
|
Requires-Dist: alembic>=1.13; extra == "acl-sqlalchemy"
|
|
25
25
|
Provides-Extra: acl-django
|
|
@@ -41,7 +41,7 @@ Requires-Dist: starlette>=0.37; extra == "all"
|
|
|
41
41
|
Requires-Dist: django>=4.2; extra == "all"
|
|
42
42
|
Requires-Dist: psycopg[binary]>=3.1; extra == "all"
|
|
43
43
|
Requires-Dist: strawberry-graphql>=0.255; extra == "all"
|
|
44
|
-
Requires-Dist: sqlalchemy<3.0,>=2.0; extra == "all"
|
|
44
|
+
Requires-Dist: sqlalchemy[asyncio]<3.0,>=2.0; extra == "all"
|
|
45
45
|
Requires-Dist: asyncpg>=0.29; extra == "all"
|
|
46
46
|
Requires-Dist: alembic>=1.13; extra == "all"
|
|
47
47
|
Requires-Dist: redis>=5.0; extra == "all"
|
|
@@ -50,7 +50,7 @@ Requires-Dist: redis>=5.0; extra == "all"
|
|
|
50
50
|
|
|
51
51
|
Clean-architecture **identity + ACL** for multi-framework Python services. Handles JWT authentication (via Keycloak) and database-backed authorization (users, organizations, roles, permissions, memberships) in a single package with first-class support for **FastAPI**, **Django**, and **Strawberry GraphQL**.
|
|
52
52
|
|
|
53
|
-
> **
|
|
53
|
+
> **v3.0.0 is a breaking change.** The `permissions.is_platform` flag is replaced by a tri-state `visibility` enum (`platform_only`/`shared`/`tenant_only`); permission `description` is now a localized JSONB map; and a first-class **Service** model with a **default-deny service guard** is added. See [`docs/Upgrade-Service-Guard.md`](docs/Upgrade-Service-Guard.md) for the 3.0.0 upgrade guide. Earlier migrations: [`docs/MIGRATION_v1.md`](docs/MIGRATION_v1.md), [`docs/MIGRATION_v2.md`](docs/MIGRATION_v2.md).
|
|
54
54
|
|
|
55
55
|
## Install
|
|
56
56
|
|
|
@@ -58,16 +58,21 @@ Clean-architecture **identity + ACL** for multi-framework Python services. Handl
|
|
|
58
58
|
# Core (identity only — no DB deps)
|
|
59
59
|
pip install pkg-auth
|
|
60
60
|
|
|
61
|
-
# With ACL + FastAPI (most common for
|
|
62
|
-
pip install pkg-auth[acl-sqlalchemy,fastapi]
|
|
61
|
+
# With ACL + FastAPI (most common for fritill services)
|
|
62
|
+
pip install "pkg-auth[acl-sqlalchemy,fastapi]"
|
|
63
63
|
|
|
64
64
|
# With ACL + Django
|
|
65
|
-
pip install pkg-auth[acl-django,django]
|
|
65
|
+
pip install "pkg-auth[acl-django,django]"
|
|
66
66
|
|
|
67
67
|
# With optional Redis cache
|
|
68
|
-
pip install pkg-auth[cache-redis]
|
|
68
|
+
pip install "pkg-auth[cache-redis]"
|
|
69
|
+
|
|
70
|
+
# Everything
|
|
71
|
+
pip install "pkg-auth[all]"
|
|
69
72
|
```
|
|
70
73
|
|
|
74
|
+
Available extras: `acl-sqlalchemy`, `acl-django`, `cache-redis`, `fastapi`, `django`, `strawberry`, `all`.
|
|
75
|
+
|
|
71
76
|
## Quickstart (FastAPI)
|
|
72
77
|
|
|
73
78
|
```python
|
|
@@ -84,7 +89,7 @@ from pkg_auth.integrations.fastapi import (
|
|
|
84
89
|
|
|
85
90
|
auth = create_authentication(
|
|
86
91
|
keycloak_base_url="https://auth.example.com",
|
|
87
|
-
realm="
|
|
92
|
+
realm="fritill",
|
|
88
93
|
audience="courses-service",
|
|
89
94
|
)
|
|
90
95
|
|
|
@@ -110,11 +115,22 @@ async def get_course(
|
|
|
110
115
|
),
|
|
111
116
|
):
|
|
112
117
|
identity, auth_ctx = bundle
|
|
113
|
-
return {"course_id": id, "
|
|
118
|
+
return {"course_id": id, "roles": sorted(auth_ctx.role_names)}
|
|
114
119
|
```
|
|
115
120
|
|
|
116
121
|
See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complete working example.
|
|
117
122
|
|
|
123
|
+
## Features
|
|
124
|
+
|
|
125
|
+
- **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
|
|
126
|
+
- **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
|
|
127
|
+
- **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
|
|
128
|
+
- **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
|
|
129
|
+
- **Services & service guard** — first-class `Service` / `OrganizationService` model with a **default-deny** service guard; the platform org bypasses it.
|
|
130
|
+
- **Mode A / Mode B** — be the source of truth for the `users` table, or consume a shared ACL read-only.
|
|
131
|
+
- **Integrations** — FastAPI, Django, and Strawberry GraphQL.
|
|
132
|
+
- **CLIs** — `keycloak-init-client`, `pkg-auth-sync-catalog`, `pkg-auth-sync-services`.
|
|
133
|
+
|
|
118
134
|
## Architecture
|
|
119
135
|
|
|
120
136
|
```
|
|
@@ -122,9 +138,9 @@ pkg_auth/
|
|
|
122
138
|
authentication/ JWT validation → IdentityContext (identity only)
|
|
123
139
|
authorization/ Full ACL (users, orgs, roles, perms, memberships)
|
|
124
140
|
domain/ Pure entities, ports (Protocol), exceptions
|
|
125
|
-
application/use_cases/ Business logic (
|
|
141
|
+
application/use_cases/ Business logic (use cases)
|
|
126
142
|
adapters/
|
|
127
|
-
sqlalchemy/ Canonical schema + Alembic
|
|
143
|
+
sqlalchemy/ Canonical schema + Alembic migrations (0001–0005) + repos
|
|
128
144
|
django_orm/ Mirror models (managed=False) + repos
|
|
129
145
|
cache/ InMemoryTTLCache / RedisCache + decorator
|
|
130
146
|
integrations/
|
|
@@ -138,10 +154,11 @@ pkg_auth/
|
|
|
138
154
|
|
|
139
155
|
## Documentation
|
|
140
156
|
|
|
141
|
-
- [Authorization model](docs/Authorization.md) — schema, permission catalog, roles, memberships
|
|
157
|
+
- [Authorization model](docs/Authorization.md) — schema, permission catalog, roles, memberships, service guard
|
|
158
|
+
- [Upgrade to the Service Guard (3.0.0)](docs/Upgrade-Service-Guard.md)
|
|
142
159
|
- [Caching](docs/Caching.md) — InMemoryTTLCache, RedisCache, invalidation contract
|
|
143
160
|
- [FastAPI Integration](docs/FastAPI.md)
|
|
144
161
|
- [Django Integration](docs/Django.md)
|
|
145
162
|
- [Strawberry Integration](docs/Strawberry.md)
|
|
146
163
|
- [Keycloak Admin](docs/Keycloak-Admin.md)
|
|
147
|
-
- [Migration
|
|
164
|
+
- [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Clean-architecture **identity + ACL** for multi-framework Python services. Handles JWT authentication (via Keycloak) and database-backed authorization (users, organizations, roles, permissions, memberships) in a single package with first-class support for **FastAPI**, **Django**, and **Strawberry GraphQL**.
|
|
4
4
|
|
|
5
|
-
> **
|
|
5
|
+
> **v3.0.0 is a breaking change.** The `permissions.is_platform` flag is replaced by a tri-state `visibility` enum (`platform_only`/`shared`/`tenant_only`); permission `description` is now a localized JSONB map; and a first-class **Service** model with a **default-deny service guard** is added. See [`docs/Upgrade-Service-Guard.md`](docs/Upgrade-Service-Guard.md) for the 3.0.0 upgrade guide. Earlier migrations: [`docs/MIGRATION_v1.md`](docs/MIGRATION_v1.md), [`docs/MIGRATION_v2.md`](docs/MIGRATION_v2.md).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,16 +10,21 @@ Clean-architecture **identity + ACL** for multi-framework Python services. Handl
|
|
|
10
10
|
# Core (identity only — no DB deps)
|
|
11
11
|
pip install pkg-auth
|
|
12
12
|
|
|
13
|
-
# With ACL + FastAPI (most common for
|
|
14
|
-
pip install pkg-auth[acl-sqlalchemy,fastapi]
|
|
13
|
+
# With ACL + FastAPI (most common for fritill services)
|
|
14
|
+
pip install "pkg-auth[acl-sqlalchemy,fastapi]"
|
|
15
15
|
|
|
16
16
|
# With ACL + Django
|
|
17
|
-
pip install pkg-auth[acl-django,django]
|
|
17
|
+
pip install "pkg-auth[acl-django,django]"
|
|
18
18
|
|
|
19
19
|
# With optional Redis cache
|
|
20
|
-
pip install pkg-auth[cache-redis]
|
|
20
|
+
pip install "pkg-auth[cache-redis]"
|
|
21
|
+
|
|
22
|
+
# Everything
|
|
23
|
+
pip install "pkg-auth[all]"
|
|
21
24
|
```
|
|
22
25
|
|
|
26
|
+
Available extras: `acl-sqlalchemy`, `acl-django`, `cache-redis`, `fastapi`, `django`, `strawberry`, `all`.
|
|
27
|
+
|
|
23
28
|
## Quickstart (FastAPI)
|
|
24
29
|
|
|
25
30
|
```python
|
|
@@ -36,7 +41,7 @@ from pkg_auth.integrations.fastapi import (
|
|
|
36
41
|
|
|
37
42
|
auth = create_authentication(
|
|
38
43
|
keycloak_base_url="https://auth.example.com",
|
|
39
|
-
realm="
|
|
44
|
+
realm="fritill",
|
|
40
45
|
audience="courses-service",
|
|
41
46
|
)
|
|
42
47
|
|
|
@@ -62,11 +67,22 @@ async def get_course(
|
|
|
62
67
|
),
|
|
63
68
|
):
|
|
64
69
|
identity, auth_ctx = bundle
|
|
65
|
-
return {"course_id": id, "
|
|
70
|
+
return {"course_id": id, "roles": sorted(auth_ctx.role_names)}
|
|
66
71
|
```
|
|
67
72
|
|
|
68
73
|
See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complete working example.
|
|
69
74
|
|
|
75
|
+
## Features
|
|
76
|
+
|
|
77
|
+
- **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
|
|
78
|
+
- **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
|
|
79
|
+
- **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
|
|
80
|
+
- **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
|
|
81
|
+
- **Services & service guard** — first-class `Service` / `OrganizationService` model with a **default-deny** service guard; the platform org bypasses it.
|
|
82
|
+
- **Mode A / Mode B** — be the source of truth for the `users` table, or consume a shared ACL read-only.
|
|
83
|
+
- **Integrations** — FastAPI, Django, and Strawberry GraphQL.
|
|
84
|
+
- **CLIs** — `keycloak-init-client`, `pkg-auth-sync-catalog`, `pkg-auth-sync-services`.
|
|
85
|
+
|
|
70
86
|
## Architecture
|
|
71
87
|
|
|
72
88
|
```
|
|
@@ -74,9 +90,9 @@ pkg_auth/
|
|
|
74
90
|
authentication/ JWT validation → IdentityContext (identity only)
|
|
75
91
|
authorization/ Full ACL (users, orgs, roles, perms, memberships)
|
|
76
92
|
domain/ Pure entities, ports (Protocol), exceptions
|
|
77
|
-
application/use_cases/ Business logic (
|
|
93
|
+
application/use_cases/ Business logic (use cases)
|
|
78
94
|
adapters/
|
|
79
|
-
sqlalchemy/ Canonical schema + Alembic
|
|
95
|
+
sqlalchemy/ Canonical schema + Alembic migrations (0001–0005) + repos
|
|
80
96
|
django_orm/ Mirror models (managed=False) + repos
|
|
81
97
|
cache/ InMemoryTTLCache / RedisCache + decorator
|
|
82
98
|
integrations/
|
|
@@ -90,10 +106,11 @@ pkg_auth/
|
|
|
90
106
|
|
|
91
107
|
## Documentation
|
|
92
108
|
|
|
93
|
-
- [Authorization model](docs/Authorization.md) — schema, permission catalog, roles, memberships
|
|
109
|
+
- [Authorization model](docs/Authorization.md) — schema, permission catalog, roles, memberships, service guard
|
|
110
|
+
- [Upgrade to the Service Guard (3.0.0)](docs/Upgrade-Service-Guard.md)
|
|
94
111
|
- [Caching](docs/Caching.md) — InMemoryTTLCache, RedisCache, invalidation contract
|
|
95
112
|
- [FastAPI Integration](docs/FastAPI.md)
|
|
96
113
|
- [Django Integration](docs/Django.md)
|
|
97
114
|
- [Strawberry Integration](docs/Strawberry.md)
|
|
98
115
|
- [Keycloak Admin](docs/Keycloak-Admin.md)
|
|
99
|
-
- [Migration
|
|
116
|
+
- [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pkg-auth"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.2.0"
|
|
8
8
|
description = "Clean-architecture auth core for multiple Python frameworks"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -33,7 +33,7 @@ dev = [
|
|
|
33
33
|
|
|
34
34
|
# ACL storage backends (v1.0)
|
|
35
35
|
acl-sqlalchemy = [
|
|
36
|
-
"sqlalchemy>=2.0,<3.0",
|
|
36
|
+
"sqlalchemy[asyncio]>=2.0,<3.0",
|
|
37
37
|
"asyncpg>=0.29",
|
|
38
38
|
"alembic>=1.13",
|
|
39
39
|
]
|
|
@@ -67,7 +67,7 @@ all = [
|
|
|
67
67
|
"django>=4.2",
|
|
68
68
|
"psycopg[binary]>=3.1",
|
|
69
69
|
"strawberry-graphql>=0.255",
|
|
70
|
-
"sqlalchemy>=2.0,<3.0",
|
|
70
|
+
"sqlalchemy[asyncio]>=2.0,<3.0",
|
|
71
71
|
"asyncpg>=0.29",
|
|
72
72
|
"alembic>=1.13",
|
|
73
73
|
"redis>=5.0",
|
|
@@ -8,14 +8,17 @@ Public API:
|
|
|
8
8
|
MembershipRepository, PermissionCatalogRepository
|
|
9
9
|
Exceptions: AuthorizationError, NotAMember, MissingPermission,
|
|
10
10
|
UnknownOrganization, UnknownUser, UnknownRole,
|
|
11
|
-
UserNotProvisioned
|
|
11
|
+
UserNotProvisioned, DelegationRefused, DelegationRefusal
|
|
12
12
|
|
|
13
13
|
The application layer (use cases) is added in M3; SQLAlchemy / Django ORM
|
|
14
14
|
adapters in M4 / M6; cache layer in M5; framework integrations in M7-M9.
|
|
15
15
|
"""
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
-
from .application.use_cases.register_permission_catalog import
|
|
18
|
+
from .application.use_cases.register_permission_catalog import (
|
|
19
|
+
CatalogEntry,
|
|
20
|
+
ServiceManifest,
|
|
21
|
+
)
|
|
19
22
|
from .application.use_cases.sync_service_catalog import ServiceSpec
|
|
20
23
|
from .config import default_locale
|
|
21
24
|
from .domain.entities import (
|
|
@@ -31,6 +34,8 @@ from .domain.entities import (
|
|
|
31
34
|
from .platform import is_platform_context
|
|
32
35
|
from .domain.exceptions import (
|
|
33
36
|
AuthorizationError,
|
|
37
|
+
DelegationRefusal,
|
|
38
|
+
DelegationRefused,
|
|
34
39
|
MissingPermission,
|
|
35
40
|
NotAMember,
|
|
36
41
|
PermissionVisibilityConflict,
|
|
@@ -96,6 +101,7 @@ __all__ = [
|
|
|
96
101
|
# Application DTOs
|
|
97
102
|
"CatalogEntry",
|
|
98
103
|
"ServiceSpec",
|
|
104
|
+
"ServiceManifest",
|
|
99
105
|
"PermissionScope",
|
|
100
106
|
# Config
|
|
101
107
|
"default_locale",
|
|
@@ -114,4 +120,6 @@ __all__ = [
|
|
|
114
120
|
"ServiceNotEnabled",
|
|
115
121
|
"PermissionVisibilityConflict",
|
|
116
122
|
"UserNotProvisioned",
|
|
123
|
+
"DelegationRefused",
|
|
124
|
+
"DelegationRefusal",
|
|
117
125
|
]
|
|
@@ -25,8 +25,14 @@ class DjangoServiceRepository:
|
|
|
25
25
|
model: type = field(default=DefaultServiceModel)
|
|
26
26
|
|
|
27
27
|
async def upsert_many(self, services: Sequence[ServiceSpec]) -> None:
|
|
28
|
+
"""Vendor flag overlay: set flags, seed label only on create.
|
|
29
|
+
|
|
30
|
+
On conflict the existing (service-owned) ``display_label`` is preserved
|
|
31
|
+
— only the vendor flags update. Written without ``create_defaults`` so
|
|
32
|
+
it works on Django 4.2.
|
|
33
|
+
"""
|
|
28
34
|
for s in services:
|
|
29
|
-
await self.model.objects.
|
|
35
|
+
obj, created = await self.model.objects.aget_or_create(
|
|
30
36
|
name=str(s.name),
|
|
31
37
|
defaults={
|
|
32
38
|
"display_label": s.display_label.as_dict() or None,
|
|
@@ -34,12 +40,29 @@ class DjangoServiceRepository:
|
|
|
34
40
|
"saas_available": s.saas_available,
|
|
35
41
|
},
|
|
36
42
|
)
|
|
43
|
+
if not created:
|
|
44
|
+
obj.auto_provision = s.auto_provision
|
|
45
|
+
obj.saas_available = s.saas_available
|
|
46
|
+
await obj.asave(
|
|
47
|
+
update_fields=["auto_provision", "saas_available"]
|
|
48
|
+
)
|
|
37
49
|
|
|
38
|
-
async def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
async def register_identity(
|
|
51
|
+
self, *, name: str, display_label: LocalizedText | None = None
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Self-register identity; never overwrite vendor flags."""
|
|
54
|
+
label = display_label.as_dict() if display_label is not None else None
|
|
55
|
+
obj, created = await self.model.objects.aget_or_create(
|
|
56
|
+
name=name,
|
|
57
|
+
defaults={
|
|
58
|
+
"display_label": label or None,
|
|
59
|
+
"auto_provision": False,
|
|
60
|
+
"saas_available": False,
|
|
61
|
+
},
|
|
42
62
|
)
|
|
63
|
+
if not created and label:
|
|
64
|
+
obj.display_label = label
|
|
65
|
+
await obj.asave(update_fields=["display_label"])
|
|
43
66
|
|
|
44
67
|
async def get(self, name: ServiceName) -> Service | None:
|
|
45
68
|
row = await self.model.objects.filter(name=str(name)).afirst()
|
|
@@ -30,6 +30,11 @@ class SqlAlchemyServiceRepository:
|
|
|
30
30
|
model: type = field(default=DefaultServiceORM)
|
|
31
31
|
|
|
32
32
|
async def upsert_many(self, services: Sequence[ServiceSpec]) -> None:
|
|
33
|
+
"""Vendor flag overlay: set ``auto_provision`` / ``saas_available``.
|
|
34
|
+
|
|
35
|
+
On insert the spec's ``display_label`` seeds the row; on conflict the
|
|
36
|
+
existing (service-owned) label is preserved — only the flags update.
|
|
37
|
+
"""
|
|
33
38
|
if not services:
|
|
34
39
|
return
|
|
35
40
|
rows = [
|
|
@@ -45,7 +50,6 @@ class SqlAlchemyServiceRepository:
|
|
|
45
50
|
stmt = stmt.on_conflict_do_update(
|
|
46
51
|
index_elements=["name"],
|
|
47
52
|
set_={
|
|
48
|
-
"display_label": stmt.excluded.display_label,
|
|
49
53
|
"auto_provision": stmt.excluded.auto_provision,
|
|
50
54
|
"saas_available": stmt.excluded.saas_available,
|
|
51
55
|
},
|
|
@@ -54,14 +58,29 @@ class SqlAlchemyServiceRepository:
|
|
|
54
58
|
await session.execute(stmt)
|
|
55
59
|
await session.commit()
|
|
56
60
|
|
|
57
|
-
async def
|
|
58
|
-
|
|
61
|
+
async def register_identity(
|
|
62
|
+
self, *, name: str, display_label: LocalizedText | None = None
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Self-register a service's identity; never overwrite vendor flags.
|
|
65
|
+
|
|
66
|
+
Inserts a row with safe default flags if absent. If a non-empty
|
|
67
|
+
``display_label`` is given, it is set on insert and on conflict
|
|
68
|
+
(identity is service-owned); the flags are left untouched.
|
|
69
|
+
"""
|
|
70
|
+
label = display_label.as_dict() if display_label is not None else None
|
|
59
71
|
stmt = pg_insert(self.model).values(
|
|
60
|
-
name=
|
|
72
|
+
name=name,
|
|
73
|
+
display_label=label or None,
|
|
61
74
|
auto_provision=False,
|
|
62
75
|
saas_available=False,
|
|
63
76
|
)
|
|
64
|
-
|
|
77
|
+
if label:
|
|
78
|
+
stmt = stmt.on_conflict_do_update(
|
|
79
|
+
index_elements=["name"],
|
|
80
|
+
set_={"display_label": stmt.excluded.display_label},
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
stmt = stmt.on_conflict_do_nothing(index_elements=["name"])
|
|
65
84
|
async with self.session_factory() as session:
|
|
66
85
|
await session.execute(stmt)
|
|
67
86
|
await session.commit()
|
|
@@ -10,9 +10,40 @@ from ...domain.value_objects import (
|
|
|
10
10
|
LocalizedText,
|
|
11
11
|
PermissionKey,
|
|
12
12
|
PermissionVisibility,
|
|
13
|
+
ServiceName,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ServiceManifest:
|
|
19
|
+
"""A service's self-declared identity for the service registry.
|
|
20
|
+
|
|
21
|
+
A service ships this alongside its permission catalog so it registers
|
|
22
|
+
*itself* (name + localized ``display_label``) when ``pkg-auth-sync-catalog``
|
|
23
|
+
runs. It deliberately carries no vendor flags (``auto_provision`` /
|
|
24
|
+
``saas_available``) — those stay vendor-owned via ``pkg-auth-sync-services``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: ServiceName
|
|
28
|
+
display_label: LocalizedText = field(default_factory=lambda: LocalizedText({}))
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def make(
|
|
32
|
+
cls,
|
|
33
|
+
name: ServiceName | str,
|
|
34
|
+
display_label: "LocalizedText | Mapping[str, str] | str | None" = None,
|
|
35
|
+
*,
|
|
36
|
+
default_locale_: str | None = None,
|
|
37
|
+
) -> "ServiceManifest":
|
|
38
|
+
loc = default_locale_ or default_locale()
|
|
39
|
+
return cls(
|
|
40
|
+
name=name if isinstance(name, ServiceName) else ServiceName(name),
|
|
41
|
+
display_label=LocalizedText.from_input(
|
|
42
|
+
display_label, default_locale=loc
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
16
47
|
@dataclass(frozen=True, slots=True)
|
|
17
48
|
class CatalogEntry:
|
|
18
49
|
"""One row a service registers into the central permission catalog.
|
|
@@ -98,10 +129,10 @@ class RegisterPermissionCatalogUseCase:
|
|
|
98
129
|
restart is safe and converges. Re-registering the same key with a
|
|
99
130
|
different ``visibility`` flips it.
|
|
100
131
|
|
|
101
|
-
When a ``service_repo`` is wired,
|
|
102
|
-
``
|
|
103
|
-
default-deny service guard does not strip
|
|
104
|
-
vendor configures it.
|
|
132
|
+
When a ``service_repo`` is wired, the service registers its own identity
|
|
133
|
+
row (``name`` + optional ``display_label``) with safe default flags, never
|
|
134
|
+
overwriting vendor flags, so the default-deny service guard does not strip
|
|
135
|
+
the service's perms before the vendor configures it.
|
|
105
136
|
"""
|
|
106
137
|
|
|
107
138
|
catalog_repo: PermissionCatalogRepository
|
|
@@ -112,10 +143,13 @@ class RegisterPermissionCatalogUseCase:
|
|
|
112
143
|
*,
|
|
113
144
|
service_name: str,
|
|
114
145
|
entries: Sequence[CatalogEntryInput],
|
|
146
|
+
display_label: LocalizedText | None = None,
|
|
115
147
|
) -> None:
|
|
116
148
|
normalized = [_normalize_entry(e) for e in entries]
|
|
117
149
|
if self.service_repo is not None:
|
|
118
|
-
await self.service_repo.
|
|
150
|
+
await self.service_repo.register_identity(
|
|
151
|
+
name=service_name, display_label=display_label
|
|
152
|
+
)
|
|
119
153
|
await self.catalog_repo.register_many(
|
|
120
154
|
service_name=service_name,
|
|
121
155
|
entries=normalized,
|
pkg_auth-3.2.0/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Resolve a delegated (on-behalf-of) :class:`AuthContext` (``auth:AD-1``)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field, replace
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from ...domain.entities import AuthContext, Organization, User
|
|
11
|
+
from ...domain.exceptions import (
|
|
12
|
+
DelegationRefusal,
|
|
13
|
+
DelegationRefused,
|
|
14
|
+
NotAMember,
|
|
15
|
+
)
|
|
16
|
+
from ...domain.ports import OrganizationRepository, UserRepository
|
|
17
|
+
from ...domain.value_objects import OrgId
|
|
18
|
+
from .resolve_auth_context import ResolveAuthContextUseCase
|
|
19
|
+
|
|
20
|
+
Delegators = Mapping[str, frozenset[OrgId]]
|
|
21
|
+
|
|
22
|
+
ON_BEHALF_OF_HEADER = "X-On-Behalf-Of"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def find_organization(
|
|
26
|
+
repo: OrganizationRepository, raw: str
|
|
27
|
+
) -> Organization | None:
|
|
28
|
+
"""Look up an org from a raw ``X-Organization-Id`` value.
|
|
29
|
+
|
|
30
|
+
Tries ``raw`` as a UUID first and falls back to a slug lookup. The one
|
|
31
|
+
lookup rule shared by the FastAPI dependency and the delegated use case,
|
|
32
|
+
so the two cannot drift.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
org_id = OrgId(UUID(raw))
|
|
36
|
+
except ValueError:
|
|
37
|
+
return await repo.get_by_slug(raw)
|
|
38
|
+
return await repo.get(org_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _to_org_id(raw: Any, sub: str) -> OrgId:
|
|
42
|
+
if isinstance(raw, OrgId):
|
|
43
|
+
return raw
|
|
44
|
+
if isinstance(raw, UUID):
|
|
45
|
+
return OrgId(raw)
|
|
46
|
+
if isinstance(raw, str):
|
|
47
|
+
try:
|
|
48
|
+
return OrgId(UUID(raw))
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"delegators[{sub!r}]: organization {raw!r} is not a UUID"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_delegators(
|
|
57
|
+
raw: Mapping[str, Iterable[Any]] | None,
|
|
58
|
+
) -> MappingProxyType[str, frozenset[OrgId]]:
|
|
59
|
+
"""Normalize a ``delegators`` config into an immutable allowlist.
|
|
60
|
+
|
|
61
|
+
Keys are Keycloak service-account ``sub`` strings, kept exactly as
|
|
62
|
+
given. Values are iterables of org ids (``OrgId``, ``UUID`` or UUID
|
|
63
|
+
strings). ``None`` or an empty mapping yields an empty map, so nobody
|
|
64
|
+
delegates (fail closed); a ``sub`` with no orgs is dropped, so it is
|
|
65
|
+
refused as not a delegator before any lookup.
|
|
66
|
+
|
|
67
|
+
Raises :class:`ValueError` when ``raw`` is a bare ``str`` or not a
|
|
68
|
+
mapping, a ``sub`` is not a non-blank, unpadded ``str``, a value is a bare
|
|
69
|
+
``str`` or not iterable, or an org is not a UUID.
|
|
70
|
+
"""
|
|
71
|
+
if raw is None:
|
|
72
|
+
return MappingProxyType({})
|
|
73
|
+
if isinstance(raw, (str, bytes)) or not isinstance(raw, Mapping):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"delegators must be a mapping of sub -> org ids, got {type(raw).__name__}"
|
|
76
|
+
)
|
|
77
|
+
normalized: dict[str, frozenset[OrgId]] = {}
|
|
78
|
+
for sub, orgs in raw.items():
|
|
79
|
+
if not isinstance(sub, str) or not sub.strip() or sub != sub.strip():
|
|
80
|
+
raise ValueError(f"delegators: blank, padded or non-string sub {sub!r}")
|
|
81
|
+
if isinstance(orgs, (str, bytes)):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"delegators[{sub!r}] must be a collection of org ids, not a bare string"
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
items = list(orgs)
|
|
87
|
+
except TypeError as exc:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"delegators[{sub!r}] must be a collection of org ids"
|
|
90
|
+
) from exc
|
|
91
|
+
org_ids = frozenset(_to_org_id(o, sub) for o in items)
|
|
92
|
+
if org_ids: # an agent with no orgs is not a delegator at all
|
|
93
|
+
normalized[sub] = org_ids
|
|
94
|
+
return MappingProxyType(normalized)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class ResolveDelegatedAuthContextUseCase:
|
|
99
|
+
"""Decide a delegated request and return the target's :class:`AuthContext`.
|
|
100
|
+
|
|
101
|
+
The single framework-free place that holds ``delegators`` (``auth:AD-1``
|
|
102
|
+
Placement). Integrations pass the raw ``X-On-Behalf-Of`` values and the
|
|
103
|
+
raw ``X-Organization-Id`` value; they never see the allowlist.
|
|
104
|
+
|
|
105
|
+
Checks run in ``auth:AD-1``'s order (rows 3–9), first match wins, and
|
|
106
|
+
every refusal is a :class:`DelegationRefused`. The target is looked up
|
|
107
|
+
read-only through ``UserRepository.get_by_keycloak_sub`` — delegation
|
|
108
|
+
never creates a user. On success the result is the target's own
|
|
109
|
+
context from ``resolve_use_case`` (same service guard, same cache
|
|
110
|
+
entry) with ``acted_by`` attached afterwards; the caller's perms are
|
|
111
|
+
never used.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
resolve_use_case: ResolveAuthContextUseCase
|
|
115
|
+
user_repo: UserRepository
|
|
116
|
+
organization_repo: OrganizationRepository
|
|
117
|
+
delegators: Delegators | None = field(default=None)
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
self.delegators = normalize_delegators(self.delegators)
|
|
121
|
+
|
|
122
|
+
async def execute(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
caller: User,
|
|
126
|
+
on_behalf_of: Sequence[str],
|
|
127
|
+
organization: str,
|
|
128
|
+
) -> AuthContext:
|
|
129
|
+
if isinstance(on_behalf_of, (str, bytes)):
|
|
130
|
+
raise TypeError("on_behalf_of must be the header's list of values")
|
|
131
|
+
if len(on_behalf_of) == 0:
|
|
132
|
+
raise ValueError("on_behalf_of is empty; only call when the header is present")
|
|
133
|
+
|
|
134
|
+
# Row 3: repeated header.
|
|
135
|
+
if len(on_behalf_of) > 1:
|
|
136
|
+
raise DelegationRefused(DelegationRefusal.REPEATED)
|
|
137
|
+
|
|
138
|
+
# Row 4: caller not a delegator — before any lookup.
|
|
139
|
+
assert self.delegators is not None
|
|
140
|
+
allowed_orgs = self.delegators.get(caller.keycloak_sub)
|
|
141
|
+
if allowed_orgs is None:
|
|
142
|
+
raise DelegationRefused(DelegationRefusal.NOT_A_DELEGATOR)
|
|
143
|
+
|
|
144
|
+
# Row 5: empty value.
|
|
145
|
+
target_sub = on_behalf_of[0]
|
|
146
|
+
if not target_sub.strip():
|
|
147
|
+
raise DelegationRefused(DelegationRefusal.EMPTY_TARGET)
|
|
148
|
+
|
|
149
|
+
# Row 6: unknown org (UUID first, then slug).
|
|
150
|
+
org = await find_organization(self.organization_repo, organization)
|
|
151
|
+
if org is None:
|
|
152
|
+
raise DelegationRefused(DelegationRefusal.UNKNOWN_ORGANIZATION)
|
|
153
|
+
|
|
154
|
+
# Row 7: org not on this agent's list (no platform-org bypass).
|
|
155
|
+
if org.id not in allowed_orgs:
|
|
156
|
+
raise DelegationRefused(DelegationRefusal.ORGANIZATION_NOT_ALLOWED)
|
|
157
|
+
|
|
158
|
+
# Row 8: target not provisioned — read-only lookup, never upsert.
|
|
159
|
+
target = await self.user_repo.get_by_keycloak_sub(target_sub)
|
|
160
|
+
if target is None:
|
|
161
|
+
raise DelegationRefused(DelegationRefusal.TARGET_NOT_PROVISIONED)
|
|
162
|
+
|
|
163
|
+
# Row 9: target not a member.
|
|
164
|
+
try:
|
|
165
|
+
ctx = await self.resolve_use_case.execute(target.id, org.id)
|
|
166
|
+
except NotAMember as exc:
|
|
167
|
+
raise DelegationRefused(DelegationRefusal.TARGET_NOT_A_MEMBER) from exc
|
|
168
|
+
|
|
169
|
+
return replace(ctx, acted_by=caller.id)
|
|
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
from typing import Sequence
|
|
12
12
|
|
|
13
13
|
from ...domain.ports import PermissionCatalogRepository, ServiceRepository
|
|
14
|
+
from ...domain.value_objects import LocalizedText
|
|
14
15
|
from .register_permission_catalog import (
|
|
15
16
|
CatalogEntry,
|
|
16
17
|
CatalogEntryInput,
|
|
@@ -51,6 +52,7 @@ class SyncPermissionCatalogUseCase:
|
|
|
51
52
|
*,
|
|
52
53
|
service_name: str,
|
|
53
54
|
entries: Sequence[CatalogEntryInput],
|
|
55
|
+
display_label: LocalizedText | None = None,
|
|
54
56
|
dry_run: bool = False,
|
|
55
57
|
) -> SyncResult:
|
|
56
58
|
normalized: list[CatalogEntry] = [_normalize_entry(e) for e in entries]
|
|
@@ -70,7 +72,9 @@ class SyncPermissionCatalogUseCase:
|
|
|
70
72
|
)
|
|
71
73
|
|
|
72
74
|
if self.service_repo is not None:
|
|
73
|
-
await self.service_repo.
|
|
75
|
+
await self.service_repo.register_identity(
|
|
76
|
+
name=service_name, display_label=display_label
|
|
77
|
+
)
|
|
74
78
|
await self.catalog_repo.register_many(
|
|
75
79
|
service_name=service_name,
|
|
76
80
|
entries=normalized,
|