pkg-auth 3.0.0__tar.gz → 3.1.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 (115) hide show
  1. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/PKG-INFO +29 -12
  2. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/README.md +28 -11
  3. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/pyproject.toml +1 -1
  4. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/__init__.py +5 -1
  5. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +28 -5
  6. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +24 -5
  7. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +39 -5
  8. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +5 -1
  9. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +21 -7
  10. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/sync_catalog.py +49 -1
  11. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/sync_services.py +14 -2
  12. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/ports.py +26 -7
  13. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/PKG-INFO +29 -12
  14. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/setup.cfg +0 -0
  15. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/__init__.py +0 -0
  16. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/__init__.py +0 -0
  17. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/cli.py +0 -0
  18. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/client.py +0 -0
  19. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/env.py +0 -0
  20. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/helpers.py +0 -0
  21. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/provision_client.py +0 -0
  22. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/settings.py +0 -0
  23. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/__init__.py +0 -0
  24. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
  25. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -0
  26. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
  27. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
  28. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
  29. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
  30. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
  31. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
  32. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/exceptions.py +0 -0
  33. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
  34. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
  35. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
  36. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
  37. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
  38. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
  39. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
  40. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
  41. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
  42. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
  43. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
  44. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
  45. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
  46. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
  47. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
  48. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
  49. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
  50. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
  51. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
  52. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
  53. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
  54. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
  55. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +0 -0
  56. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +0 -0
  57. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +0 -0
  58. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +0 -0
  59. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +0 -0
  60. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
  61. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
  62. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
  63. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
  64. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
  65. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
  66. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
  67. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
  68. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
  69. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
  70. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
  71. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
  72. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
  73. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
  74. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
  75. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
  76. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
  77. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
  78. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
  79. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
  80. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
  81. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
  82. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
  83. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
  84. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
  85. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
  86. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
  87. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
  88. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
  89. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/config.py +0 -0
  90. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
  91. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/entities.py +0 -0
  92. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/exceptions.py +0 -0
  93. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
  94. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/platform.py +0 -0
  95. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/__init__.py +0 -0
  96. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
  97. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/apps.py +0 -0
  98. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +0 -0
  99. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
  100. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/install.py +0 -0
  101. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
  102. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/__init__.py +0 -0
  103. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +0 -0
  104. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
  105. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
  106. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
  107. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
  108. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
  109. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/auth.py +0 -0
  110. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
  111. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/SOURCES.txt +0 -0
  112. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
  113. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
  114. {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/requires.txt +0 -0
  115. {pkg_auth-3.0.0 → pkg_auth-3.1.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.1.0
4
4
  Summary: Clean-architecture auth core for multiple Python frameworks
5
5
  Author-email: Fritill <info@fritill.ae>
6
6
  License: MIT
@@ -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.1.0"
8
8
  description = "Clean-architecture auth core for multiple Python frameworks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -15,7 +15,10 @@ 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 (
@@ -96,6 +99,7 @@ __all__ = [
96
99
  # Application DTOs
97
100
  "CatalogEntry",
98
101
  "ServiceSpec",
102
+ "ServiceManifest",
99
103
  "PermissionScope",
100
104
  # Config
101
105
  "default_locale",
@@ -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,
@@ -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,
@@ -59,7 +59,16 @@ class ServiceSyncResult:
59
59
 
60
60
  @dataclass(slots=True)
61
61
  class SyncServiceCatalogUseCase:
62
- """Upsert declared services then prune services no longer declared."""
62
+ """Apply vendor flags to declared services (the overlay model).
63
+
64
+ Upserts each declared service's vendor flags (``auto_provision`` /
65
+ ``saas_available``) without clobbering a service-owned ``display_label``.
66
+ Because each service registers its own identity row (via
67
+ ``pkg-auth-sync-catalog``), this does **not** prune by default — a service
68
+ the vendor list omits is simply left ungoverned, not deleted. Pass
69
+ ``prune=True`` for an explicit, central cleanup of services no longer
70
+ declared anywhere.
71
+ """
63
72
 
64
73
  service_repo: ServiceRepository
65
74
 
@@ -67,23 +76,28 @@ class SyncServiceCatalogUseCase:
67
76
  self,
68
77
  *,
69
78
  services: Sequence[ServiceSpec],
79
+ prune: bool = False,
70
80
  dry_run: bool = False,
71
81
  ) -> ServiceSyncResult:
72
82
  keep = [s.name for s in services]
73
83
 
74
84
  if dry_run:
75
- existing = await self.service_repo.list_all()
76
- existing_names = {str(s.name) for s in existing}
77
- declared_names = {str(n) for n in keep}
78
- would_prune = existing_names - declared_names
85
+ would_prune = 0
86
+ if prune:
87
+ existing = await self.service_repo.list_all()
88
+ existing_names = {str(s.name) for s in existing}
89
+ declared_names = {str(n) for n in keep}
90
+ would_prune = len(existing_names - declared_names)
79
91
  return ServiceSyncResult(
80
92
  upserted=len(services),
81
- pruned=len(would_prune),
93
+ pruned=would_prune,
82
94
  dry_run=True,
83
95
  )
84
96
 
85
97
  await self.service_repo.upsert_many(services)
86
- pruned = await self.service_repo.prune_absent(keep=keep)
98
+ pruned = 0
99
+ if prune:
100
+ pruned = await self.service_repo.prune_absent(keep=keep)
87
101
  return ServiceSyncResult(
88
102
  upserted=len(services),
89
103
  pruned=pruned,
@@ -28,7 +28,10 @@ import os
28
28
  import sys
29
29
  from typing import Awaitable, Callable, Sequence
30
30
 
31
- from ..application.use_cases.register_permission_catalog import CatalogEntry
31
+ from ..application.use_cases.register_permission_catalog import (
32
+ CatalogEntry,
33
+ ServiceManifest,
34
+ )
32
35
  from ..application.use_cases.sync_permission_catalog import (
33
36
  SyncPermissionCatalogUseCase,
34
37
  SyncResult,
@@ -36,6 +39,7 @@ from ..application.use_cases.sync_permission_catalog import (
36
39
  from ..domain.ports import PermissionCatalogRepository, ServiceRepository
37
40
 
38
41
  CatalogLoader = Callable[[str], Sequence[CatalogEntry]]
42
+ ManifestLoader = Callable[[str], ServiceManifest]
39
43
 
40
44
 
41
45
  def build_arg_parser() -> argparse.ArgumentParser:
@@ -60,6 +64,17 @@ def build_arg_parser() -> argparse.ArgumentParser:
60
64
  "'courses.domain.permissions:CATALOG'."
61
65
  ),
62
66
  )
67
+ parser.add_argument(
68
+ "--service-manifest",
69
+ default=None,
70
+ help=(
71
+ "Optional dotted path to a ServiceManifest the service registers "
72
+ "for itself, e.g. 'courses.domain.permissions:SERVICE'. Its name "
73
+ "must match --service; its display_label seeds/updates the "
74
+ "service's identity row. Vendor flags stay owned by "
75
+ "pkg-auth-sync-services."
76
+ ),
77
+ )
63
78
  parser.add_argument(
64
79
  "--db-url",
65
80
  default=os.environ.get("ACL_DATABASE_URL"),
@@ -95,6 +110,26 @@ def load_catalog(dotted: str) -> list[CatalogEntry]:
95
110
  return list(value)
96
111
 
97
112
 
113
+ def load_manifest(dotted: str) -> ServiceManifest:
114
+ """Resolve ``module.path:ATTR`` to a :class:`ServiceManifest`."""
115
+ if ":" not in dotted:
116
+ raise ValueError(f"Expected 'module.path:ATTR', got {dotted!r}")
117
+ module_path, attr = dotted.split(":", 1)
118
+ module = importlib.import_module(module_path)
119
+ try:
120
+ value = getattr(module, attr)
121
+ except AttributeError as exc:
122
+ raise ValueError(
123
+ f"Module {module_path!r} has no attribute {attr!r}"
124
+ ) from exc
125
+ if not isinstance(value, ServiceManifest):
126
+ raise ValueError(
127
+ f"{dotted!r} is not a ServiceManifest (got "
128
+ f"{type(value).__name__})"
129
+ )
130
+ return value
131
+
132
+
98
133
  async def run(
99
134
  args: argparse.Namespace,
100
135
  *,
@@ -102,6 +137,7 @@ async def run(
102
137
  service_repo: ServiceRepository | None = None,
103
138
  session_factory: object | None = None,
104
139
  catalog_loader: CatalogLoader = load_catalog,
140
+ manifest_loader: ManifestLoader = load_manifest,
105
141
  ) -> SyncResult:
106
142
  dispose: Callable[[], Awaitable[None]] | None = None
107
143
  if repo is None:
@@ -134,6 +170,17 @@ async def run(
134
170
  )
135
171
 
136
172
  entries = catalog_loader(args.catalog)
173
+
174
+ display_label = None
175
+ if args.service_manifest:
176
+ manifest = manifest_loader(args.service_manifest)
177
+ if str(manifest.name) != args.service:
178
+ raise SystemExit(
179
+ f"manifest name {str(manifest.name)!r} does not match "
180
+ f"--service {args.service!r}"
181
+ )
182
+ display_label = manifest.display_label
183
+
137
184
  use_case = SyncPermissionCatalogUseCase(
138
185
  catalog_repo=repo, service_repo=service_repo
139
186
  )
@@ -152,6 +199,7 @@ async def run(
152
199
  result = await use_case.execute(
153
200
  service_name=args.service,
154
201
  entries=entries,
202
+ display_label=display_label,
155
203
  dry_run=args.dry_run,
156
204
  )
157
205
  finally:
@@ -63,6 +63,15 @@ def build_arg_parser() -> argparse.ArgumentParser:
63
63
  "Falls back to the ACL_DATABASE_URL env var."
64
64
  ),
65
65
  )
66
+ parser.add_argument(
67
+ "--prune",
68
+ action="store_true",
69
+ help=(
70
+ "Also DELETE services not in this list. Off by default (overlay "
71
+ "model: services own their own identity rows). Use only for an "
72
+ "explicit central cleanup."
73
+ ),
74
+ )
66
75
  parser.add_argument(
67
76
  "--dry-run",
68
77
  action="store_true",
@@ -121,10 +130,13 @@ async def run(
121
130
  existing = {str(s.name) for s in await repo.list_all()}
122
131
  declared = {str(s.name) for s in services}
123
132
  print(f"[dry-run] to add: {sorted(declared - existing)}")
124
- print(f"[dry-run] to prune: {sorted(existing - declared)}")
133
+ if args.prune:
134
+ print(f"[dry-run] to prune: {sorted(existing - declared)}")
125
135
 
126
136
  try:
127
- result = await use_case.execute(services=services, dry_run=args.dry_run)
137
+ result = await use_case.execute(
138
+ services=services, prune=args.prune, dry_run=args.dry_run
139
+ )
128
140
  finally:
129
141
  if dispose is not None:
130
142
  await dispose()
@@ -20,6 +20,7 @@ from .entities import (
20
20
  User,
21
21
  )
22
22
  from .value_objects import (
23
+ LocalizedText,
23
24
  OrgId,
24
25
  PermissionKey,
25
26
  RoleId,
@@ -176,16 +177,34 @@ class PermissionCatalogRepository(Protocol):
176
177
  class ServiceRepository(Protocol):
177
178
  """Read/write access to the ``services`` table (the service registry).
178
179
 
179
- Vendor-controlled flags (``auto_provision``, ``saas_available``) are
180
- written only via :meth:`upsert_many` (the ``pkg-auth-sync-services``
181
- path). :meth:`ensure_exists` is called during permission-catalog
182
- registration to create a bare row with safe defaults so the
183
- default-deny guard does not strip a newly-registered service's perms
184
- before the vendor configures it; it must NOT overwrite existing flags.
180
+ Ownership is split into two non-overlapping concerns so services can be
181
+ rolled out independently (the *overlay* model):
182
+
183
+ - **Identity** (``name`` + ``display_label``) is owned by each service and
184
+ written via :meth:`register_identity` from its own catalog sync
185
+ (``pkg-auth-sync-catalog``). A service registers itself.
186
+ - **Vendor flags** (``auto_provision``, ``saas_available``) are owned by the
187
+ vendor and written via :meth:`upsert_many` (``pkg-auth-sync-services``).
188
+
189
+ Neither path clobbers the other's fields: :meth:`register_identity` never
190
+ touches the flags, and :meth:`upsert_many` never overwrites an existing
191
+ row's ``display_label``. Because each service owns its own row,
192
+ ``pkg-auth-sync-services`` does NOT prune by default; :meth:`prune_absent`
193
+ remains available for an explicit, opt-in cleanup.
185
194
  """
186
195
 
187
196
  async def upsert_many(self, services: Sequence["ServiceSpec"]) -> None: ...
188
- async def ensure_exists(self, *, service_name: str) -> None: ...
197
+ async def register_identity(
198
+ self, *, name: str, display_label: LocalizedText | None = None
199
+ ) -> None:
200
+ """Self-register a service's identity (idempotent).
201
+
202
+ Insert a row for ``name`` if absent (flags default ``False`` so the
203
+ default-deny guard does not strip the service's perms before the vendor
204
+ configures it). If the row exists, update ``display_label`` only when a
205
+ non-empty label is given; never touch the vendor flags.
206
+ """
207
+ ...
189
208
  async def get(self, name: ServiceName) -> Service | None: ...
190
209
  async def list_all(self) -> list[Service]: ...
191
210
  async def prune_absent(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pkg-auth
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: Clean-architecture auth core for multiple Python frameworks
5
5
  Author-email: Fritill <info@fritill.ae>
6
6
  License: MIT
@@ -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)
File without changes