pkg-auth 3.1.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.
Files changed (117) hide show
  1. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/PKG-INFO +3 -3
  2. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/pyproject.toml +3 -3
  3. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/__init__.py +1 -1
  4. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/__init__.py +5 -1
  5. pkg_auth-3.2.0/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +169 -0
  6. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/entities.py +7 -0
  7. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/exceptions.py +36 -0
  8. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +13 -1
  9. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/__init__.py +4 -1
  10. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +46 -6
  11. pkg_auth-3.2.0/src/pkg_auth/integrations/fastapi/current_user_dep.py +78 -0
  12. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/auth.py +14 -0
  13. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/PKG-INFO +3 -3
  14. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/SOURCES.txt +2 -0
  15. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/requires.txt +2 -2
  16. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/README.md +0 -0
  17. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/setup.cfg +0 -0
  18. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/__init__.py +0 -0
  19. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/cli.py +0 -0
  20. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/client.py +0 -0
  21. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/env.py +0 -0
  22. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/helpers.py +0 -0
  23. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/provision_client.py +0 -0
  24. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/admin/settings.py +0 -0
  25. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/__init__.py +0 -0
  26. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
  27. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -0
  28. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
  29. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
  30. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
  31. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
  32. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
  33. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
  34. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/exceptions.py +0 -0
  35. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
  36. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
  37. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
  38. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
  39. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
  40. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
  41. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
  42. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
  43. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
  44. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
  45. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
  46. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
  47. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
  48. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
  49. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
  50. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
  51. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
  52. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
  53. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +0 -0
  54. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
  55. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
  56. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
  57. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
  58. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +0 -0
  59. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +0 -0
  60. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +0 -0
  61. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +0 -0
  62. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +0 -0
  63. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
  64. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
  65. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
  66. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
  67. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
  68. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
  69. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
  70. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
  71. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
  72. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +0 -0
  73. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
  74. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
  75. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
  76. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
  77. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
  78. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
  79. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
  80. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
  81. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
  82. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
  83. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
  84. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
  85. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +0 -0
  86. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
  87. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
  88. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
  89. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +0 -0
  90. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +0 -0
  91. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
  92. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
  93. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
  94. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
  95. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
  96. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/sync_catalog.py +0 -0
  97. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/cli/sync_services.py +0 -0
  98. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/config.py +0 -0
  99. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
  100. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/ports.py +0 -0
  101. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
  102. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/authorization/platform.py +0 -0
  103. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/__init__.py +0 -0
  104. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
  105. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/apps.py +0 -0
  106. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
  107. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/install.py +0 -0
  108. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
  109. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
  110. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
  111. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
  112. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
  113. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
  114. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
  115. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
  116. {pkg_auth-3.1.0 → pkg_auth-3.2.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
  117. {pkg_auth-3.1.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.1.0
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"
@@ -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.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",
@@ -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__"]
@@ -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:
@@ -10,7 +10,8 @@ except ImportError as exc: # pragma: no cover
10
10
  ) from exc
11
11
 
12
12
  from .auth_factory import Authentication, create_authentication
13
- from .auth_context_dep import make_get_auth_context
13
+ from .auth_context_dep import ON_BEHALF_OF_HEADER, make_get_auth_context
14
+ from .current_user_dep import make_get_current_user
14
15
  from .decorators import require_permission
15
16
  from .errors import install_exception_handlers
16
17
  from .identity_dep import bearer_scheme, extract_token_from_request
@@ -19,6 +20,8 @@ __all__ = [
19
20
  "Authentication",
20
21
  "create_authentication",
21
22
  "make_get_auth_context",
23
+ "make_get_current_user",
24
+ "ON_BEHALF_OF_HEADER",
22
25
  "require_permission",
23
26
  "install_exception_handlers",
24
27
  "bearer_scheme",
@@ -31,21 +31,26 @@ pattern.
31
31
  from __future__ import annotations
32
32
 
33
33
  from typing import Awaitable, Callable
34
- from uuid import UUID
35
34
 
36
35
  from fastapi import Depends, HTTPException, Request, status
37
36
 
38
37
  from ...authentication import IdentityContext
39
38
  from ...authorization import (
40
39
  AuthContext,
40
+ DelegationRefusal,
41
+ DelegationRefused,
41
42
  NotAMember,
42
- OrgId,
43
43
  UnknownOrganization,
44
44
  UserNotProvisioned,
45
45
  )
46
46
  from ...authorization.application.use_cases.resolve_auth_context import (
47
47
  ResolveAuthContextUseCase,
48
48
  )
49
+ from ...authorization.application.use_cases.resolve_delegated_auth_context import (
50
+ ON_BEHALF_OF_HEADER,
51
+ ResolveDelegatedAuthContextUseCase,
52
+ find_organization,
53
+ )
49
54
  from ...authorization.application.use_cases.resolve_user_from_jwt import (
50
55
  ResolveUserFromJwtUseCase,
51
56
  )
@@ -57,6 +62,13 @@ from ...authorization.domain.ports import OrganizationRepository
57
62
 
58
63
  DEFAULT_HEADER_NAME = "X-Organization-Id"
59
64
 
65
+ # Unlisted reasons fall through to 403, so a new reason fails closed.
66
+ _REFUSAL_STATUS: dict[DelegationRefusal, int] = {
67
+ DelegationRefusal.REPEATED: status.HTTP_400_BAD_REQUEST,
68
+ DelegationRefusal.EMPTY_TARGET: status.HTTP_400_BAD_REQUEST,
69
+ DelegationRefusal.UNKNOWN_ORGANIZATION: status.HTTP_404_NOT_FOUND,
70
+ }
71
+
60
72
 
61
73
  def make_get_auth_context(
62
74
  *,
@@ -66,6 +78,7 @@ def make_get_auth_context(
66
78
  sync_user_use_case: SyncUserFromJwtUseCase | None = None,
67
79
  resolve_user_use_case: ResolveUserFromJwtUseCase | None = None,
68
80
  header_name: str = DEFAULT_HEADER_NAME,
81
+ delegation: ResolveDelegatedAuthContextUseCase | None = None,
69
82
  ) -> Callable[..., Awaitable[tuple[IdentityContext, AuthContext]]]:
70
83
  """Build a FastAPI dependency returning ``(IdentityContext, AuthContext)``.
71
84
 
@@ -80,11 +93,21 @@ def make_get_auth_context(
80
93
  UUID first, falling back to a slug lookup.
81
94
  4. Resolves the user's auth context for that organization.
82
95
 
96
+ If the request carries ``X-On-Behalf-Of`` (checked only after steps 1
97
+ and 2, ``auth:AD-1``), steps 3–4 are replaced by ``delegation``: the
98
+ result is ``(caller's IdentityContext, target's AuthContext)`` with
99
+ ``acted_by`` set to the caller's ACL id. Without ``delegation`` the
100
+ header is refused with 403. A refusal never falls back to the
101
+ caller's own context.
102
+
83
103
  Errors map to:
84
104
  - missing header → 400
85
105
  - unknown organization → 404
86
106
  - user not a member → 403
87
107
  - user not provisioned (Mode B) → 403
108
+ - ``X-On-Behalf-Of`` without ``delegation`` → 403
109
+ - delegation refused: repeated header or empty value → 400,
110
+ unknown organization → 404, any other reason → 403
88
111
  """
89
112
  if (sync_user_use_case is None) == (resolve_user_use_case is None):
90
113
  raise ValueError(
@@ -122,10 +145,27 @@ def make_get_auth_context(
122
145
  detail=str(exc),
123
146
  ) from exc
124
147
 
125
- try:
126
- org = await organization_repo.get(OrgId(UUID(raw)))
127
- except (ValueError, AttributeError):
128
- org = await organization_repo.get_by_slug(raw)
148
+ on_behalf_of = request.headers.getlist(ON_BEHALF_OF_HEADER)
149
+ if on_behalf_of:
150
+ if delegation is None:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_403_FORBIDDEN,
153
+ detail=f"{ON_BEHALF_OF_HEADER} is not accepted here",
154
+ )
155
+ try:
156
+ auth_ctx = await delegation.execute(
157
+ caller=user, on_behalf_of=on_behalf_of, organization=raw
158
+ )
159
+ except DelegationRefused as exc:
160
+ raise HTTPException(
161
+ status_code=_REFUSAL_STATUS.get(
162
+ exc.reason, status.HTTP_403_FORBIDDEN
163
+ ),
164
+ detail=str(exc),
165
+ ) from exc
166
+ return identity, auth_ctx
167
+
168
+ org = await find_organization(organization_repo, raw)
129
169
  if org is None:
130
170
  raise HTTPException(
131
171
  status_code=status.HTTP_404_NOT_FOUND,
@@ -0,0 +1,78 @@
1
+ """``make_get_current_user`` factory — the org-less ACL user dependency.
2
+
3
+ Resolves the caller's ACL :class:`User` from the token alone, with no
4
+ ``X-Organization-Id``. It cannot delegate, so it refuses ``X-On-Behalf-Of``
5
+ with 403 before any user work (``auth:AD-1`` Coverage).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Awaitable, Callable
10
+
11
+ from fastapi import Depends, HTTPException, Request, status
12
+
13
+ from ...authentication import IdentityContext
14
+ from ...authorization import UserNotProvisioned
15
+ from ...authorization.application.use_cases.resolve_delegated_auth_context import (
16
+ ON_BEHALF_OF_HEADER,
17
+ )
18
+ from ...authorization.application.use_cases.resolve_user_from_jwt import (
19
+ ResolveUserFromJwtUseCase,
20
+ )
21
+ from ...authorization.application.use_cases.sync_user_from_jwt import (
22
+ SyncUserFromJwtUseCase,
23
+ )
24
+ from ...authorization.domain.entities import User
25
+
26
+
27
+ async def _refuse_on_behalf_of(request: Request) -> None:
28
+ # Declared before ``get_identity`` so FastAPI resolves it first: the
29
+ # header is refused even without a valid token.
30
+ if request.headers.getlist(ON_BEHALF_OF_HEADER):
31
+ raise HTTPException(
32
+ status_code=status.HTTP_403_FORBIDDEN,
33
+ detail=f"{ON_BEHALF_OF_HEADER} is not accepted here",
34
+ )
35
+
36
+
37
+ def make_get_current_user(
38
+ *,
39
+ get_identity: Callable[..., Awaitable[IdentityContext]],
40
+ sync_user_use_case: SyncUserFromJwtUseCase | None = None,
41
+ resolve_user_use_case: ResolveUserFromJwtUseCase | None = None,
42
+ ) -> Callable[..., Awaitable[User]]:
43
+ """Build a FastAPI dependency returning the caller's ACL ``User``.
44
+
45
+ Exactly one of ``sync_user_use_case`` (Mode A — upserts the caller)
46
+ or ``resolve_user_use_case`` (Mode B — read-through) must be supplied.
47
+
48
+ Errors map to:
49
+ - ``X-On-Behalf-Of`` present (any value, any token) → 403
50
+ - no or invalid token → 401 (from ``get_identity``)
51
+ - user not provisioned (Mode B) → 403
52
+ """
53
+ if (sync_user_use_case is None) == (resolve_user_use_case is None):
54
+ raise ValueError(
55
+ "make_get_current_user: pass exactly one of "
56
+ "sync_user_use_case (Mode A) or resolve_user_use_case (Mode B)."
57
+ )
58
+
59
+ async def dependency(
60
+ _: None = Depends(_refuse_on_behalf_of),
61
+ identity: IdentityContext = Depends(get_identity),
62
+ ) -> User:
63
+ try:
64
+ if sync_user_use_case is not None:
65
+ return await sync_user_use_case.execute(
66
+ sub=identity.subject_str,
67
+ email=identity.email_str or "",
68
+ full_name=identity.full_name,
69
+ )
70
+ assert resolve_user_use_case is not None
71
+ return await resolve_user_use_case.execute(sub=identity.subject_str)
72
+ except UserNotProvisioned as exc:
73
+ raise HTTPException(
74
+ status_code=status.HTTP_403_FORBIDDEN,
75
+ detail=str(exc),
76
+ ) from exc
77
+
78
+ return dependency
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
5
5
  from typing import Awaitable, Callable
6
6
  from uuid import UUID
7
7
 
8
+ from starlette.exceptions import HTTPException
8
9
  from starlette.requests import Request
9
10
 
10
11
  from ...authentication import (
@@ -23,6 +24,9 @@ from ...authorization import (
23
24
  from ...authorization.application.use_cases.resolve_auth_context import (
24
25
  ResolveAuthContextUseCase,
25
26
  )
27
+ from ...authorization.application.use_cases.resolve_delegated_auth_context import (
28
+ ON_BEHALF_OF_HEADER,
29
+ )
26
30
  from ...authorization.application.use_cases.resolve_user_from_jwt import (
27
31
  ResolveUserFromJwtUseCase,
28
32
  )
@@ -80,6 +84,10 @@ def make_context_getter(
80
84
  rather than raising. Permission classes (``IsAuthenticated``,
81
85
  ``RequirePermission``) are responsible for rejecting under-privileged
82
86
  queries.
87
+
88
+ The one non-permissive case is ``X-On-Behalf-Of``: this integration
89
+ cannot delegate, so a request carrying it (any value, any token
90
+ state) is refused with HTTP 403 before any user work (``auth:AD-1``).
83
91
  """
84
92
  if (sync_user_use_case is None) == (resolve_user_use_case is None):
85
93
  raise ValueError(
@@ -88,6 +96,12 @@ def make_context_getter(
88
96
  )
89
97
 
90
98
  async def _context_getter(request: Request) -> StrawberryContext:
99
+ if request.headers.getlist(ON_BEHALF_OF_HEADER):
100
+ raise HTTPException(
101
+ status_code=403,
102
+ detail=f"{ON_BEHALF_OF_HEADER} is not accepted here",
103
+ )
104
+
91
105
  ctx = StrawberryContext(request=request)
92
106
 
93
107
  token = _extract_token(request, cookie_name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pkg-auth
3
- Version: 3.1.0
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"
@@ -79,6 +79,7 @@ src/pkg_auth/authorization/application/use_cases/list_user_organizations.py
79
79
  src/pkg_auth/authorization/application/use_cases/provision_default_services.py
80
80
  src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py
81
81
  src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py
82
+ src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py
82
83
  src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py
83
84
  src/pkg_auth/authorization/application/use_cases/set_organization_service.py
84
85
  src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py
@@ -105,6 +106,7 @@ src/pkg_auth/integrations/django/middleware.py
105
106
  src/pkg_auth/integrations/fastapi/__init__.py
106
107
  src/pkg_auth/integrations/fastapi/auth_context_dep.py
107
108
  src/pkg_auth/integrations/fastapi/auth_factory.py
109
+ src/pkg_auth/integrations/fastapi/current_user_dep.py
108
110
  src/pkg_auth/integrations/fastapi/decorators.py
109
111
  src/pkg_auth/integrations/fastapi/errors.py
110
112
  src/pkg_auth/integrations/fastapi/identity_dep.py
@@ -7,7 +7,7 @@ django>=4.2
7
7
  psycopg[binary]>=3.1
8
8
 
9
9
  [acl-sqlalchemy]
10
- sqlalchemy<3.0,>=2.0
10
+ sqlalchemy[asyncio]<3.0,>=2.0
11
11
  asyncpg>=0.29
12
12
  alembic>=1.13
13
13
 
@@ -18,7 +18,7 @@ starlette>=0.37
18
18
  django>=4.2
19
19
  psycopg[binary]>=3.1
20
20
  strawberry-graphql>=0.255
21
- sqlalchemy<3.0,>=2.0
21
+ sqlalchemy[asyncio]<3.0,>=2.0
22
22
  asyncpg>=0.29
23
23
  alembic>=1.13
24
24
  redis>=5.0
File without changes
File without changes