pkg-auth 3.1.0__tar.gz → 3.3.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.1.0 → pkg_auth-3.3.0}/PKG-INFO +6 -4
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/README.md +3 -1
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/pyproject.toml +3 -3
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/__init__.py +1 -1
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/__init__.py +3 -1
- pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +12 -0
- pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/service_token.py +210 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/exceptions.py +4 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/__init__.py +5 -1
- pkg_auth-3.3.0/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +169 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/entities.py +7 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/exceptions.py +36 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +13 -1
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/__init__.py +4 -1
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +46 -6
- pkg_auth-3.3.0/src/pkg_auth/integrations/fastapi/current_user_dep.py +78 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/auth.py +14 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/PKG-INFO +6 -4
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/SOURCES.txt +3 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/requires.txt +2 -2
- pkg_auth-3.1.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -6
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/setup.cfg +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/cli.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/client.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/env.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/helpers.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/provision_client.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/settings.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_catalog.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_services.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/config.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/ports.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/platform.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/apps.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/install.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
- {pkg_auth-3.1.0 → pkg_auth-3.3.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.3.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"
|
|
@@ -123,6 +123,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
|
|
|
123
123
|
## Features
|
|
124
124
|
|
|
125
125
|
- **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
|
|
126
|
+
- **Service token** — a service's own Keycloak token (client credentials), cached and refreshed, for calling other services.
|
|
126
127
|
- **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
|
|
127
128
|
- **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
|
|
128
129
|
- **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
|
|
@@ -135,7 +136,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
|
|
|
135
136
|
|
|
136
137
|
```
|
|
137
138
|
pkg_auth/
|
|
138
|
-
authentication/ JWT validation → IdentityContext
|
|
139
|
+
authentication/ JWT validation → IdentityContext; the service's own token
|
|
139
140
|
authorization/ Full ACL (users, orgs, roles, perms, memberships)
|
|
140
141
|
domain/ Pure entities, ports (Protocol), exceptions
|
|
141
142
|
application/use_cases/ Business logic (use cases)
|
|
@@ -160,5 +161,6 @@ pkg_auth/
|
|
|
160
161
|
- [FastAPI Integration](docs/FastAPI.md)
|
|
161
162
|
- [Django Integration](docs/Django.md)
|
|
162
163
|
- [Strawberry Integration](docs/Strawberry.md)
|
|
164
|
+
- [Service Token](docs/Service-Token.md) — call another service as this service
|
|
163
165
|
- [Keycloak Admin](docs/Keycloak-Admin.md)
|
|
164
166
|
- [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
|
|
@@ -75,6 +75,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
|
|
|
75
75
|
## Features
|
|
76
76
|
|
|
77
77
|
- **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
|
|
78
|
+
- **Service token** — a service's own Keycloak token (client credentials), cached and refreshed, for calling other services.
|
|
78
79
|
- **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
|
|
79
80
|
- **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
|
|
80
81
|
- **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
|
|
@@ -87,7 +88,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
|
|
|
87
88
|
|
|
88
89
|
```
|
|
89
90
|
pkg_auth/
|
|
90
|
-
authentication/ JWT validation → IdentityContext
|
|
91
|
+
authentication/ JWT validation → IdentityContext; the service's own token
|
|
91
92
|
authorization/ Full ACL (users, orgs, roles, perms, memberships)
|
|
92
93
|
domain/ Pure entities, ports (Protocol), exceptions
|
|
93
94
|
application/use_cases/ Business logic (use cases)
|
|
@@ -112,5 +113,6 @@ pkg_auth/
|
|
|
112
113
|
- [FastAPI Integration](docs/FastAPI.md)
|
|
113
114
|
- [Django Integration](docs/Django.md)
|
|
114
115
|
- [Strawberry Integration](docs/Strawberry.md)
|
|
116
|
+
- [Service Token](docs/Service-Token.md) — call another service as this service
|
|
115
117
|
- [Keycloak Admin](docs/Keycloak-Admin.md)
|
|
116
118
|
- [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.3.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",
|
|
@@ -6,7 +6,7 @@ Public API:
|
|
|
6
6
|
Subject, EmailAddress, RealmName — identity value objects
|
|
7
7
|
TokenDecoder — Protocol port for token verification
|
|
8
8
|
AuthenticateTokenUseCase — token → IdentityContext
|
|
9
|
-
AuthenticationError, TokenExpiredError, InvalidTokenError
|
|
9
|
+
AuthenticationError, TokenExpiredError, InvalidTokenError, ServiceTokenError
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
@@ -15,6 +15,7 @@ from .domain.entities import IdentityContext
|
|
|
15
15
|
from .domain.exceptions import (
|
|
16
16
|
AuthenticationError,
|
|
17
17
|
InvalidTokenError,
|
|
18
|
+
ServiceTokenError,
|
|
18
19
|
TokenExpiredError,
|
|
19
20
|
)
|
|
20
21
|
from .domain.ports import TokenDecoder
|
|
@@ -30,4 +31,5 @@ __all__ = [
|
|
|
30
31
|
"AuthenticationError",
|
|
31
32
|
"TokenExpiredError",
|
|
32
33
|
"InvalidTokenError",
|
|
34
|
+
"ServiceTokenError",
|
|
33
35
|
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Keycloak adapters: JWT verification and this service's own token."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .jwt_decoder import JWTTokenDecoder
|
|
5
|
+
from .service_token import ServiceCredentials, ServiceTokenAuth, ServiceTokenProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"JWTTokenDecoder",
|
|
9
|
+
"ServiceCredentials",
|
|
10
|
+
"ServiceTokenAuth",
|
|
11
|
+
"ServiceTokenProvider",
|
|
12
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""This service's own Keycloak token (client-credentials grant), for calling other services."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, AsyncGenerator, Callable, Generator, Mapping, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ...domain.exceptions import ServiceTokenError
|
|
14
|
+
|
|
15
|
+
_ENV_NAMES = (
|
|
16
|
+
"KEYCLOAK_BASE_URL",
|
|
17
|
+
"KEYCLOAK_REALM",
|
|
18
|
+
"KEYCLOAK_CLIENT_ID",
|
|
19
|
+
"KEYCLOAK_CLIENT_SECRET",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ServiceCredentials:
|
|
25
|
+
"""Where and as whom a service asks Keycloak for its own token."""
|
|
26
|
+
|
|
27
|
+
token_url: str
|
|
28
|
+
client_id: str
|
|
29
|
+
client_secret: str = field(repr=False)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def for_realm(
|
|
33
|
+
cls, *, base_url: str, realm: str, client_id: str, client_secret: str
|
|
34
|
+
) -> "ServiceCredentials":
|
|
35
|
+
token_url = f"{base_url.rstrip('/')}/realms/{realm}/protocol/openid-connect/token"
|
|
36
|
+
return cls(token_url=token_url, client_id=client_id, client_secret=client_secret)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_env(cls, environ: Optional[Mapping[str, str]] = None) -> "ServiceCredentials":
|
|
40
|
+
"""Read ``KEYCLOAK_BASE_URL``, ``KEYCLOAK_REALM``, ``KEYCLOAK_CLIENT_ID``
|
|
41
|
+
and ``KEYCLOAK_CLIENT_SECRET``; raise ``ValueError`` naming every one unset."""
|
|
42
|
+
env = os.environ if environ is None else environ
|
|
43
|
+
values = {name: (env.get(name) or "").strip() for name in _ENV_NAMES}
|
|
44
|
+
missing = [name for name, value in values.items() if not value]
|
|
45
|
+
if missing:
|
|
46
|
+
raise ValueError(f"service token needs {', '.join(missing)}")
|
|
47
|
+
return cls.for_realm(
|
|
48
|
+
base_url=values["KEYCLOAK_BASE_URL"],
|
|
49
|
+
realm=values["KEYCLOAK_REALM"],
|
|
50
|
+
client_id=values["KEYCLOAK_CLIENT_ID"],
|
|
51
|
+
client_secret=values["KEYCLOAK_CLIENT_SECRET"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ServiceTokenProvider:
|
|
56
|
+
"""Fetches, caches and refreshes this service's token.
|
|
57
|
+
|
|
58
|
+
One instance per process, shared by sync and async callers. A cached
|
|
59
|
+
token is reused until ``refresh_margin_seconds`` before it expires (or
|
|
60
|
+
half its lifetime, for tokens shorter than twice the margin), so a call
|
|
61
|
+
never goes out with a token that dies in flight.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
credentials: ServiceCredentials,
|
|
67
|
+
*,
|
|
68
|
+
refresh_margin_seconds: float = 30.0,
|
|
69
|
+
timeout_seconds: float = 10.0,
|
|
70
|
+
clock: Callable[[], float] = time.monotonic,
|
|
71
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
72
|
+
async_transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self._credentials = credentials
|
|
75
|
+
self._margin = refresh_margin_seconds
|
|
76
|
+
self._timeout = timeout_seconds
|
|
77
|
+
self._clock = clock
|
|
78
|
+
self._transport = transport
|
|
79
|
+
self._async_transport = async_transport
|
|
80
|
+
|
|
81
|
+
self._token: Optional[str] = None
|
|
82
|
+
self._refresh_at = 0.0
|
|
83
|
+
self._sync_lock = threading.Lock()
|
|
84
|
+
self._async_lock: Optional[asyncio.Lock] = None
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_env(
|
|
88
|
+
cls, environ: Optional[Mapping[str, str]] = None, **kwargs: Any
|
|
89
|
+
) -> "ServiceTokenProvider":
|
|
90
|
+
return cls(ServiceCredentials.from_env(environ), **kwargs)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def client_id(self) -> str:
|
|
94
|
+
return self._credentials.client_id
|
|
95
|
+
|
|
96
|
+
def get_token(self) -> str:
|
|
97
|
+
token = self._cached()
|
|
98
|
+
if token is not None:
|
|
99
|
+
return token
|
|
100
|
+
with self._sync_lock:
|
|
101
|
+
token = self._cached()
|
|
102
|
+
if token is not None:
|
|
103
|
+
return token
|
|
104
|
+
with httpx.Client(timeout=self._timeout, transport=self._transport) as client:
|
|
105
|
+
try:
|
|
106
|
+
response = client.post(**self._request())
|
|
107
|
+
except httpx.HTTPError as exc:
|
|
108
|
+
raise self._unreachable(exc) from exc
|
|
109
|
+
return self._store(response)
|
|
110
|
+
|
|
111
|
+
async def aget_token(self) -> str:
|
|
112
|
+
token = self._cached()
|
|
113
|
+
if token is not None:
|
|
114
|
+
return token
|
|
115
|
+
if self._async_lock is None:
|
|
116
|
+
self._async_lock = asyncio.Lock()
|
|
117
|
+
async with self._async_lock:
|
|
118
|
+
token = self._cached()
|
|
119
|
+
if token is not None:
|
|
120
|
+
return token
|
|
121
|
+
async with httpx.AsyncClient(
|
|
122
|
+
timeout=self._timeout, transport=self._async_transport
|
|
123
|
+
) as client:
|
|
124
|
+
try:
|
|
125
|
+
response = await client.post(**self._request())
|
|
126
|
+
except httpx.HTTPError as exc:
|
|
127
|
+
raise self._unreachable(exc) from exc
|
|
128
|
+
return self._store(response)
|
|
129
|
+
|
|
130
|
+
def invalidate(self) -> None:
|
|
131
|
+
"""Drop the cached token; the next call fetches a new one."""
|
|
132
|
+
self._token = None
|
|
133
|
+
self._refresh_at = 0.0
|
|
134
|
+
|
|
135
|
+
def _cached(self) -> Optional[str]:
|
|
136
|
+
token = self._token
|
|
137
|
+
if token is not None and self._clock() < self._refresh_at:
|
|
138
|
+
return token
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _request(self) -> dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"url": self._credentials.token_url,
|
|
144
|
+
"data": {
|
|
145
|
+
"grant_type": "client_credentials",
|
|
146
|
+
"client_id": self._credentials.client_id,
|
|
147
|
+
"client_secret": self._credentials.client_secret,
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def _unreachable(self, exc: Exception) -> ServiceTokenError:
|
|
152
|
+
return ServiceTokenError(
|
|
153
|
+
f"could not reach Keycloak for client {self._credentials.client_id!r}: {exc}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _store(self, response: httpx.Response) -> str:
|
|
157
|
+
token, expires_in = self._parse(response)
|
|
158
|
+
lifetime = max(float(expires_in), 0.0)
|
|
159
|
+
self._token = token
|
|
160
|
+
self._refresh_at = self._clock() + lifetime - min(self._margin, lifetime / 2)
|
|
161
|
+
return token
|
|
162
|
+
|
|
163
|
+
def _parse(self, response: httpx.Response) -> Tuple[str, Any]:
|
|
164
|
+
client_id = self._credentials.client_id
|
|
165
|
+
try:
|
|
166
|
+
body = response.json()
|
|
167
|
+
except ValueError:
|
|
168
|
+
body = {}
|
|
169
|
+
if not isinstance(body, dict):
|
|
170
|
+
body = {}
|
|
171
|
+
if response.status_code != 200:
|
|
172
|
+
reason = body.get("error_description") or body.get("error") or response.text[:200]
|
|
173
|
+
raise ServiceTokenError(
|
|
174
|
+
f"Keycloak refused a token for client {client_id!r}: "
|
|
175
|
+
f"HTTP {response.status_code} {reason}"
|
|
176
|
+
)
|
|
177
|
+
token = body.get("access_token")
|
|
178
|
+
if not isinstance(token, str) or not token:
|
|
179
|
+
raise ServiceTokenError(f"Keycloak returned no access_token for client {client_id!r}")
|
|
180
|
+
return token, body.get("expires_in", 60)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ServiceTokenAuth(httpx.Auth):
|
|
184
|
+
"""``httpx`` auth that sends the service token and, on a 401, fetches a
|
|
185
|
+
fresh one and retries once (the cached token may have been revoked)."""
|
|
186
|
+
|
|
187
|
+
requires_request_body = True
|
|
188
|
+
|
|
189
|
+
def __init__(self, provider: ServiceTokenProvider) -> None:
|
|
190
|
+
self._provider = provider
|
|
191
|
+
|
|
192
|
+
def sync_auth_flow(
|
|
193
|
+
self, request: httpx.Request
|
|
194
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
195
|
+
request.headers["Authorization"] = f"Bearer {self._provider.get_token()}"
|
|
196
|
+
response = yield request
|
|
197
|
+
if response.status_code == 401:
|
|
198
|
+
self._provider.invalidate()
|
|
199
|
+
request.headers["Authorization"] = f"Bearer {self._provider.get_token()}"
|
|
200
|
+
yield request
|
|
201
|
+
|
|
202
|
+
async def async_auth_flow(
|
|
203
|
+
self, request: httpx.Request
|
|
204
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
205
|
+
request.headers["Authorization"] = f"Bearer {await self._provider.aget_token()}"
|
|
206
|
+
response = yield request
|
|
207
|
+
if response.status_code == 401:
|
|
208
|
+
self._provider.invalidate()
|
|
209
|
+
request.headers["Authorization"] = f"Bearer {await self._provider.aget_token()}"
|
|
210
|
+
yield request
|
|
@@ -16,3 +16,7 @@ class TokenExpiredError(AuthenticationError):
|
|
|
16
16
|
|
|
17
17
|
class InvalidTokenError(AuthenticationError):
|
|
18
18
|
"""Token is malformed, has an invalid signature, or fails verification."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServiceTokenError(AuthenticationError):
|
|
22
|
+
"""Keycloak did not issue this service's own (client-credentials) token."""
|
|
@@ -8,7 +8,7 @@ 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.
|
|
@@ -34,6 +34,8 @@ from .domain.entities import (
|
|
|
34
34
|
from .platform import is_platform_context
|
|
35
35
|
from .domain.exceptions import (
|
|
36
36
|
AuthorizationError,
|
|
37
|
+
DelegationRefusal,
|
|
38
|
+
DelegationRefused,
|
|
37
39
|
MissingPermission,
|
|
38
40
|
NotAMember,
|
|
39
41
|
PermissionVisibilityConflict,
|
|
@@ -118,4 +120,6 @@ __all__ = [
|
|
|
118
120
|
"ServiceNotEnabled",
|
|
119
121
|
"PermissionVisibilityConflict",
|
|
120
122
|
"UserNotProvisioned",
|
|
123
|
+
"DelegationRefused",
|
|
124
|
+
"DelegationRefusal",
|
|
121
125
|
]
|
pkg_auth-3.3.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)
|
|
@@ -163,12 +163,19 @@ class AuthContext:
|
|
|
163
163
|
services cache their platform org's id at startup and call
|
|
164
164
|
:func:`pkg_auth.authorization.is_platform_context` to compare against
|
|
165
165
|
``self.organization_id``. See the package docs for the pattern.
|
|
166
|
+
|
|
167
|
+
``acted_by`` is **attribution only**. On a delegated (on-behalf-of)
|
|
168
|
+
request ``user_id`` is the target user — the acting principal for
|
|
169
|
+
authorization and ownership — and ``acted_by`` is the caller's ACL id;
|
|
170
|
+
it is ``None`` otherwise. It never grants or removes a permission, and
|
|
171
|
+
the cache serializer deliberately excludes it (``auth:AD-1``).
|
|
166
172
|
"""
|
|
167
173
|
|
|
168
174
|
user_id: UserId
|
|
169
175
|
organization_id: OrgId
|
|
170
176
|
role_names: frozenset[str]
|
|
171
177
|
perms: frozenset[str]
|
|
178
|
+
acted_by: UserId | None = None
|
|
172
179
|
|
|
173
180
|
def has(self, perm: str) -> bool:
|
|
174
181
|
"""Return ``True`` if any of the user's roles grant ``perm``."""
|
|
@@ -7,6 +7,8 @@ each to the right HTTP status.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
10
12
|
|
|
11
13
|
class AuthorizationError(Exception):
|
|
12
14
|
"""Base for all authorization (ACL) failures."""
|
|
@@ -66,3 +68,37 @@ class ServiceNotSaaSAvailable(AuthorizationError):
|
|
|
66
68
|
|
|
67
69
|
class ServiceNotEnabled(AuthorizationError):
|
|
68
70
|
"""The organization does not have the required service enabled."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DelegationRefusal(str, Enum):
|
|
74
|
+
"""Why a delegated (on-behalf-of) request was refused.
|
|
75
|
+
|
|
76
|
+
One member per row 3–9 of ``auth:AD-1``'s order of checks. Integration
|
|
77
|
+
layers map ``REPEATED`` and ``EMPTY_TARGET`` to 400,
|
|
78
|
+
``UNKNOWN_ORGANIZATION`` to 404, and every other reason to 403.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
REPEATED = "repeated"
|
|
82
|
+
NOT_A_DELEGATOR = "not_a_delegator"
|
|
83
|
+
EMPTY_TARGET = "empty_target"
|
|
84
|
+
UNKNOWN_ORGANIZATION = "unknown_organization"
|
|
85
|
+
ORGANIZATION_NOT_ALLOWED = "organization_not_allowed"
|
|
86
|
+
TARGET_NOT_PROVISIONED = "target_not_provisioned"
|
|
87
|
+
TARGET_NOT_A_MEMBER = "target_not_a_member"
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
return self.value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DelegationRefused(AuthorizationError):
|
|
94
|
+
"""A delegated request was refused; ``reason`` says which check failed.
|
|
95
|
+
|
|
96
|
+
The only error :class:`ResolveDelegatedAuthContextUseCase` raises for a
|
|
97
|
+
refusal — never :class:`NotAMember` or :class:`UserNotProvisioned`, so
|
|
98
|
+
no integration can mistake a refused delegation for the caller's own
|
|
99
|
+
failure and fall back to the caller's context.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, reason: DelegationRefusal, detail: str | None = None) -> None:
|
|
103
|
+
self.reason = reason
|
|
104
|
+
super().__init__(detail or f"delegation refused: {reason.value}")
|
{pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py
RENAMED
|
@@ -13,6 +13,9 @@ from ...authorization import (
|
|
|
13
13
|
UnknownOrganization,
|
|
14
14
|
UserNotProvisioned,
|
|
15
15
|
)
|
|
16
|
+
from ...authorization.application.use_cases.resolve_delegated_auth_context import (
|
|
17
|
+
ON_BEHALF_OF_HEADER,
|
|
18
|
+
)
|
|
16
19
|
from ...authorization.domain.entities import User
|
|
17
20
|
from .install import get_registry
|
|
18
21
|
|
|
@@ -23,6 +26,9 @@ class AuthContextMiddleware:
|
|
|
23
26
|
Requires ``IdentityMiddleware`` to run first so ``request.identity``
|
|
24
27
|
is populated. Behavior:
|
|
25
28
|
|
|
29
|
+
- ``X-On-Behalf-Of`` present (any value, with or without the org
|
|
30
|
+
header or an identity) → 403; this middleware cannot delegate
|
|
31
|
+
(``auth:AD-1``)
|
|
26
32
|
- no header → ``request.auth_context = None`` (route decides)
|
|
27
33
|
- identity is None and header present → 401
|
|
28
34
|
- org not found → 404
|
|
@@ -53,8 +59,14 @@ class AuthContextMiddleware:
|
|
|
53
59
|
)
|
|
54
60
|
|
|
55
61
|
async def __call__(self, request: HttpRequest) -> HttpResponse:
|
|
56
|
-
registry = get_registry()
|
|
57
62
|
request.auth_context = None # type: ignore[attr-defined]
|
|
63
|
+
if request.headers.get(ON_BEHALF_OF_HEADER) is not None:
|
|
64
|
+
return JsonResponse(
|
|
65
|
+
{"detail": f"{ON_BEHALF_OF_HEADER} is not accepted here"},
|
|
66
|
+
status=403,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
registry = get_registry()
|
|
58
70
|
|
|
59
71
|
raw = request.headers.get(registry.header_name)
|
|
60
72
|
if raw is None:
|