pkg-auth 3.0.0__tar.gz → 3.2.0__tar.gz

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