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.
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/PKG-INFO +29 -12
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/README.md +28 -11
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/pyproject.toml +1 -1
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/__init__.py +5 -1
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +28 -5
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +24 -5
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +39 -5
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +5 -1
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +21 -7
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/sync_catalog.py +49 -1
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/sync_services.py +14 -2
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/ports.py +26 -7
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/PKG-INFO +29 -12
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/setup.cfg +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/cli.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/client.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/env.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/helpers.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/provision_client.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/admin/settings.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/exceptions.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/config.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/entities.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/exceptions.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/platform.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/apps.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/install.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/auth.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/SOURCES.txt +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
- {pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
> **
|
|
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
|
|
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="
|
|
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, "
|
|
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 (
|
|
141
|
+
application/use_cases/ Business logic (use cases)
|
|
126
142
|
adapters/
|
|
127
|
-
sqlalchemy/ Canonical schema + Alembic
|
|
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
|
|
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
|
-
> **
|
|
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
|
|
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="
|
|
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, "
|
|
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 (
|
|
93
|
+
application/use_cases/ Business logic (use cases)
|
|
78
94
|
adapters/
|
|
79
|
-
sqlalchemy/ Canonical schema + Alembic
|
|
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
|
|
116
|
+
- [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
|
|
@@ -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
|
|
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.
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
58
|
-
|
|
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=
|
|
72
|
+
name=name,
|
|
73
|
+
display_label=label or None,
|
|
61
74
|
auto_provision=False,
|
|
62
75
|
saas_available=False,
|
|
63
76
|
)
|
|
64
|
-
|
|
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,
|
|
102
|
-
``
|
|
103
|
-
default-deny service guard does not strip
|
|
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.
|
|
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.
|
|
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
|
-
"""
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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=
|
|
93
|
+
pruned=would_prune,
|
|
82
94
|
dry_run=True,
|
|
83
95
|
)
|
|
84
96
|
|
|
85
97
|
await self.service_repo.upsert_many(services)
|
|
86
|
-
pruned =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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.
|
|
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
|
-
> **
|
|
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
|
|
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="
|
|
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, "
|
|
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 (
|
|
141
|
+
application/use_cases/ Business logic (use cases)
|
|
126
142
|
adapters/
|
|
127
|
-
sqlalchemy/ Canonical schema + Alembic
|
|
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
|
|
164
|
+
- [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py
RENAMED
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/__init__.py
RENAMED
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/__init__.py
RENAMED
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/create_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/authorization/application/use_cases/update_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.0.0 → pkg_auth-3.1.0}/src/pkg_auth/integrations/django/auth_context_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|