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.
Files changed (119) hide show
  1. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/PKG-INFO +6 -4
  2. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/README.md +3 -1
  3. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/pyproject.toml +3 -3
  4. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/__init__.py +1 -1
  5. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/__init__.py +3 -1
  6. pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +12 -0
  7. pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/service_token.py +210 -0
  8. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/exceptions.py +4 -0
  9. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/__init__.py +5 -1
  10. pkg_auth-3.3.0/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +169 -0
  11. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/entities.py +7 -0
  12. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/exceptions.py +36 -0
  13. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +13 -1
  14. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/__init__.py +4 -1
  15. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +46 -6
  16. pkg_auth-3.3.0/src/pkg_auth/integrations/fastapi/current_user_dep.py +78 -0
  17. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/auth.py +14 -0
  18. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/PKG-INFO +6 -4
  19. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/SOURCES.txt +3 -0
  20. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/requires.txt +2 -2
  21. pkg_auth-3.1.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -6
  22. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/setup.cfg +0 -0
  23. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/__init__.py +0 -0
  24. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/cli.py +0 -0
  25. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/client.py +0 -0
  26. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/env.py +0 -0
  27. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/helpers.py +0 -0
  28. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/provision_client.py +0 -0
  29. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/settings.py +0 -0
  30. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
  31. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
  32. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
  33. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
  34. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
  35. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
  36. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
  37. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
  38. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
  39. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
  40. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
  41. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
  42. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
  43. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
  44. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
  45. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
  46. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
  47. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
  48. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
  49. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
  50. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
  51. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
  52. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
  53. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
  54. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
  55. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +0 -0
  56. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
  57. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
  58. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
  59. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
  60. {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
  61. {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
  62. {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
  63. {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
  64. {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
  65. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
  66. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
  67. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
  68. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
  69. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
  70. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
  71. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
  72. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
  73. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
  74. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +0 -0
  75. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
  76. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
  77. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
  78. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
  79. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
  80. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
  81. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
  82. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
  83. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
  84. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
  85. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
  86. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
  87. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +0 -0
  88. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
  89. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
  90. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
  91. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +0 -0
  92. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +0 -0
  93. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
  94. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
  95. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
  96. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
  97. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
  98. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_catalog.py +0 -0
  99. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_services.py +0 -0
  100. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/config.py +0 -0
  101. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
  102. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/ports.py +0 -0
  103. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
  104. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/platform.py +0 -0
  105. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/__init__.py +0 -0
  106. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
  107. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/apps.py +0 -0
  108. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
  109. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/install.py +0 -0
  110. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
  111. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
  112. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
  113. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
  114. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
  115. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
  116. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
  117. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
  118. {pkg_auth-3.1.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
  119. {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.1.0
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 (identity only)
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 (identity only)
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.1.0"
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",
@@ -10,6 +10,6 @@ Authorization (ACL) and framework integrations (FastAPI, Django,
10
10
  Strawberry) are available via sub-packages.
11
11
  """
12
12
 
13
- __version__ = "2.1.0"
13
+ __version__ = "3.2.0"
14
14
 
15
15
  __all__ = ["__version__"]
@@ -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
  ]
@@ -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}")
@@ -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: