pkg-auth 3.0.0__py3-none-any.whl
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/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
pkg_auth/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pkg_auth: clean-architecture identity + ACL for Python services.
|
|
2
|
+
|
|
3
|
+
Importing this top-level package gives you only the version. Reach for
|
|
4
|
+
specific surfaces via the sub-packages:
|
|
5
|
+
|
|
6
|
+
from pkg_auth.authentication import IdentityContext, AuthenticateTokenUseCase
|
|
7
|
+
from pkg_auth.authentication.adapters.keycloak import JWTTokenDecoder
|
|
8
|
+
|
|
9
|
+
Authorization (ACL) and framework integrations (FastAPI, Django,
|
|
10
|
+
Strawberry) are available via sub-packages.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "2.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pkg_auth.admin.keycloak
|
|
3
|
+
|
|
4
|
+
Async Keycloak admin utilities:
|
|
5
|
+
|
|
6
|
+
- KCAdminSettings: configuration for Keycloak admin connection.
|
|
7
|
+
- KeycloakAdminClient: minimal async admin client (httpx-based).
|
|
8
|
+
- provision_keycloak_client: high-level async helper to:
|
|
9
|
+
* ensure API client exists (bearer-only)
|
|
10
|
+
* ensure client roles match your permission list
|
|
11
|
+
* ensure audience + roles mappers on frontend clients
|
|
12
|
+
- settings_from_env / ensure_keycloak_client_from_env:
|
|
13
|
+
convenience wrappers for env-driven CLI / initContainers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .client import KeycloakAdminClient
|
|
19
|
+
from .env import settings_from_env, ensure_keycloak_client_from_env
|
|
20
|
+
from .helpers import (
|
|
21
|
+
_ensure_api_client,
|
|
22
|
+
_ensure_roles,
|
|
23
|
+
_ensure_frontend_mappers,
|
|
24
|
+
_remove_frontend_mappers,
|
|
25
|
+
)
|
|
26
|
+
from .provision_client import provision_keycloak_client
|
|
27
|
+
from .settings import KCAdminSettings
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"KCAdminSettings",
|
|
31
|
+
"KeycloakAdminClient",
|
|
32
|
+
"settings_from_env",
|
|
33
|
+
"ensure_keycloak_client_from_env",
|
|
34
|
+
"provision_keycloak_client"
|
|
35
|
+
]
|
pkg_auth/admin/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# src/pkg_auth/keycloak_admin/__main__.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Sequence
|
|
10
|
+
|
|
11
|
+
from .env import settings_from_env
|
|
12
|
+
from .provision_client import provision_keycloak_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
description="Provision Keycloak API client, roles and audience mappers",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--client-id",
|
|
22
|
+
help="Override API clientId (default: {APP_NAME|SERVICE_NAME}-api)",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--permissions",
|
|
26
|
+
"-P",
|
|
27
|
+
nargs="*",
|
|
28
|
+
help="Explicit list of permission/role names "
|
|
29
|
+
"(if omitted, your caller can pass them programmatically).",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--frontend-client-ids",
|
|
33
|
+
"-F",
|
|
34
|
+
nargs="*",
|
|
35
|
+
help="Frontend clientIds to grant audience and client-roles mappers to "
|
|
36
|
+
"(defaults from env KEYCLOAK_FRONTEND_CLIENT_IDS).",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--remove-frontend-client-ids",
|
|
40
|
+
"-R",
|
|
41
|
+
nargs="*",
|
|
42
|
+
help="Frontend clientIds to remove audience + roles mappers from "
|
|
43
|
+
"(effective only with --strict-audience).",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--strict-roles",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Reconcile roles strictly: create missing and delete extra roles.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--strict-audience",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Reconcile audience + roles mappers strictly and remove mappers "
|
|
54
|
+
"from --remove-frontend-client-ids.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return parser.parse_args(args=argv)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _run(args: argparse.Namespace) -> dict[str, Any]:
|
|
61
|
+
settings = settings_from_env()
|
|
62
|
+
return await provision_keycloak_client(
|
|
63
|
+
settings=settings,
|
|
64
|
+
client_id=args.client_id,
|
|
65
|
+
permissions=list(args.permissions or []),
|
|
66
|
+
frontend_client_ids=list(args.frontend_client_ids or []),
|
|
67
|
+
remove_frontend_client_ids=list(args.remove_frontend_client_ids or []),
|
|
68
|
+
strict_roles=bool(args.strict_roles),
|
|
69
|
+
strict_audience=bool(args.strict_audience),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
74
|
+
args = _parse_args(argv)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
summary = asyncio.run(_run(args))
|
|
78
|
+
json.dump({"ok": True, **summary}, sys.stdout, indent=2)
|
|
79
|
+
sys.stdout.write("\n")
|
|
80
|
+
except Exception as exc: # noqa: BLE001
|
|
81
|
+
json.dump({"ok": False, "error": str(exc)}, sys.stdout, indent=2)
|
|
82
|
+
sys.stdout.write("\n")
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
pkg_auth/admin/client.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from typing import Any, Optional, Dict, List
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .settings import KCAdminSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KeycloakAdminClient:
|
|
14
|
+
"""
|
|
15
|
+
Minimal async Keycloak Admin wrapper.
|
|
16
|
+
|
|
17
|
+
- obtains admin tokens
|
|
18
|
+
- retries once on 401
|
|
19
|
+
- exposes helpers for clients, roles, and protocol mappers
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, settings: KCAdminSettings, client: Optional[httpx.AsyncClient] = None):
|
|
23
|
+
self.s = settings
|
|
24
|
+
self._client = client or httpx.AsyncClient(verify=self.s.verify_ssl, timeout=30.0)
|
|
25
|
+
self._token: Optional[str] = None
|
|
26
|
+
self._token_exp: float = 0.0
|
|
27
|
+
self._lock = asyncio.Lock()
|
|
28
|
+
|
|
29
|
+
async def close(self) -> None:
|
|
30
|
+
await self._client.aclose()
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ #
|
|
33
|
+
# token management
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
|
|
36
|
+
async def _get_token(self) -> str:
|
|
37
|
+
# reuse cached token if still valid
|
|
38
|
+
if self._token and time.time() < (self._token_exp - 20):
|
|
39
|
+
return self._token
|
|
40
|
+
|
|
41
|
+
async with self._lock:
|
|
42
|
+
if self._token and time.time() < (self._token_exp - 20):
|
|
43
|
+
return self._token
|
|
44
|
+
|
|
45
|
+
token_url = f"{self.s.base_url_slash}realms/master/protocol/openid-connect/token"
|
|
46
|
+
data = {
|
|
47
|
+
"client_id": "admin-cli",
|
|
48
|
+
"username": self.s.keycloak_admin_user,
|
|
49
|
+
"password": self.s.keycloak_admin_pass,
|
|
50
|
+
"grant_type": "password",
|
|
51
|
+
}
|
|
52
|
+
resp = await self._client.post(token_url, data=data)
|
|
53
|
+
try:
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
except httpx.HTTPStatusError as e:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Failed to obtain admin token: {e.response.status_code} {e.response.text}"
|
|
58
|
+
) from e
|
|
59
|
+
|
|
60
|
+
payload = resp.json()
|
|
61
|
+
self._token = payload["access_token"]
|
|
62
|
+
self._token_exp = time.time() + float(payload.get("expires_in", 60))
|
|
63
|
+
return self._token
|
|
64
|
+
|
|
65
|
+
def _auth_headers(self, token: str) -> dict[str, str]:
|
|
66
|
+
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
67
|
+
|
|
68
|
+
async def _request(
|
|
69
|
+
self,
|
|
70
|
+
method: str,
|
|
71
|
+
url: str,
|
|
72
|
+
*,
|
|
73
|
+
params: Optional[Dict[str, Any]] = None,
|
|
74
|
+
json: Optional[Dict[str, Any]] = None,
|
|
75
|
+
) -> httpx.Response:
|
|
76
|
+
token = await self._get_token()
|
|
77
|
+
resp = await self._client.request(
|
|
78
|
+
method,
|
|
79
|
+
url,
|
|
80
|
+
headers=self._auth_headers(token),
|
|
81
|
+
params=params,
|
|
82
|
+
json=json,
|
|
83
|
+
)
|
|
84
|
+
if resp.status_code == 401:
|
|
85
|
+
# refresh once
|
|
86
|
+
await self._get_token()
|
|
87
|
+
token = self._token or ""
|
|
88
|
+
resp = await self._client.request(
|
|
89
|
+
method,
|
|
90
|
+
url,
|
|
91
|
+
headers=self._auth_headers(token),
|
|
92
|
+
params=params,
|
|
93
|
+
json=json,
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
return resp
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# base helpers
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
|
|
102
|
+
def _realm_admin(self) -> str:
|
|
103
|
+
return f"{self.s.base_url_slash}admin/realms/{self.s.keycloak_realm}"
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
# client management
|
|
107
|
+
# ------------------------------------------------------------------ #
|
|
108
|
+
|
|
109
|
+
async def get_client_by_client_id(self, client_id: str) -> Optional[dict[str, Any]]:
|
|
110
|
+
url = f"{self._realm_admin()}/clients"
|
|
111
|
+
resp = await self._request("GET", url, params={"clientId": client_id})
|
|
112
|
+
arr = resp.json() or []
|
|
113
|
+
return arr[0] if arr else None
|
|
114
|
+
|
|
115
|
+
async def create_client(self, client_repr: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
url = f"{self._realm_admin()}/clients"
|
|
117
|
+
try:
|
|
118
|
+
resp = await self._request("POST", url, json=client_repr)
|
|
119
|
+
except httpx.HTTPStatusError as e:
|
|
120
|
+
# handle race: 409 when another process created it
|
|
121
|
+
if e.response is not None and e.response.status_code == 409:
|
|
122
|
+
existing = await self.get_client_by_client_id(client_repr["clientId"])
|
|
123
|
+
if existing:
|
|
124
|
+
return existing
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
location = resp.headers.get("Location")
|
|
128
|
+
if location:
|
|
129
|
+
fetch = await self._request("GET", location)
|
|
130
|
+
return fetch.json()
|
|
131
|
+
|
|
132
|
+
created = await self.get_client_by_client_id(client_repr["clientId"])
|
|
133
|
+
if not created:
|
|
134
|
+
raise RuntimeError("Client creation succeeded but could not read back the resource.")
|
|
135
|
+
return created
|
|
136
|
+
|
|
137
|
+
async def update_client(self, internal_id: str, client_repr: dict[str, Any]) -> dict[str, Any]:
|
|
138
|
+
url = f"{self._realm_admin()}/clients/{internal_id}"
|
|
139
|
+
await self._request("PUT", url, json=client_repr)
|
|
140
|
+
fetch = await self._request("GET", url)
|
|
141
|
+
return fetch.json()
|
|
142
|
+
|
|
143
|
+
async def ensure_client(self, client_repr: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
"""
|
|
145
|
+
Idempotently create or update a client by clientId.
|
|
146
|
+
Returns the trimmed server representation.
|
|
147
|
+
"""
|
|
148
|
+
existing = await self.get_client_by_client_id(client_repr["clientId"])
|
|
149
|
+
if not existing:
|
|
150
|
+
created = await self.create_client(client_repr)
|
|
151
|
+
return self._trim_client(created)
|
|
152
|
+
|
|
153
|
+
internal_id = existing["id"]
|
|
154
|
+
merged = {**existing, **client_repr}
|
|
155
|
+
updated = await self.update_client(internal_id, merged)
|
|
156
|
+
return self._trim_client(updated)
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
# roles
|
|
160
|
+
# ------------------------------------------------------------------ #
|
|
161
|
+
|
|
162
|
+
async def list_client_roles(self, internal_id: str) -> list[str]:
|
|
163
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/roles"
|
|
164
|
+
resp = await self._request("GET", url)
|
|
165
|
+
return [r.get("name") for r in (resp.json() or [])]
|
|
166
|
+
|
|
167
|
+
async def create_client_role(
|
|
168
|
+
self,
|
|
169
|
+
internal_id: str,
|
|
170
|
+
name: str,
|
|
171
|
+
description: Optional[str] = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/roles"
|
|
174
|
+
payload: Dict[str, Any] = {"name": name}
|
|
175
|
+
if description:
|
|
176
|
+
payload["description"] = description
|
|
177
|
+
await self._request("POST", url, json=payload)
|
|
178
|
+
|
|
179
|
+
async def ensure_client_roles(self, internal_id: str, role_names: List[str]) -> dict[str, int]:
|
|
180
|
+
existing = set(await self.list_client_roles(internal_id))
|
|
181
|
+
created = 0
|
|
182
|
+
for name in role_names:
|
|
183
|
+
if name not in existing:
|
|
184
|
+
await self.create_client_role(internal_id, name)
|
|
185
|
+
created += 1
|
|
186
|
+
return {
|
|
187
|
+
"created": created,
|
|
188
|
+
"existing": len(existing.intersection(set(role_names))),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async def delete_client_role(self, internal_id: str, role_name: str) -> None:
|
|
192
|
+
encoded = urllib.parse.quote(role_name, safe="")
|
|
193
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/roles/{encoded}"
|
|
194
|
+
await self._request("DELETE", url)
|
|
195
|
+
|
|
196
|
+
async def ensure_client_roles_strict(self, internal_id: str, desired_roles: List[str]) -> dict[str, int]:
|
|
197
|
+
existing = set(await self.list_client_roles(internal_id))
|
|
198
|
+
desired = set(desired_roles)
|
|
199
|
+
to_create = sorted(desired - existing)
|
|
200
|
+
to_delete = sorted(existing - desired)
|
|
201
|
+
created = 0
|
|
202
|
+
deleted = 0
|
|
203
|
+
for r in to_create:
|
|
204
|
+
await self.create_client_role(internal_id, r)
|
|
205
|
+
created += 1
|
|
206
|
+
for r in to_delete:
|
|
207
|
+
await self.delete_client_role(internal_id, r)
|
|
208
|
+
deleted += 1
|
|
209
|
+
return {
|
|
210
|
+
"created": created,
|
|
211
|
+
"deleted": deleted,
|
|
212
|
+
"kept": len(existing & desired),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# ------------------------------------------------------------------ #
|
|
216
|
+
# protocol mappers (audience + roles)
|
|
217
|
+
# ------------------------------------------------------------------ #
|
|
218
|
+
|
|
219
|
+
async def list_protocol_mappers(self, internal_id: str) -> list[dict[str, Any]]:
|
|
220
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
|
|
221
|
+
resp = await self._request("GET", url)
|
|
222
|
+
return resp.json() or []
|
|
223
|
+
|
|
224
|
+
# ---- audience mapper ------------------------------------------------
|
|
225
|
+
|
|
226
|
+
async def _find_audience_mapper(
|
|
227
|
+
self,
|
|
228
|
+
internal_id: str,
|
|
229
|
+
included_client_id: str,
|
|
230
|
+
) -> Optional[dict[str, Any]]:
|
|
231
|
+
for m in await self.list_protocol_mappers(internal_id):
|
|
232
|
+
if (
|
|
233
|
+
m.get("protocol") == "openid-connect"
|
|
234
|
+
and m.get("protocolMapper") == "oidc-audience-mapper"
|
|
235
|
+
and (m.get("config") or {}).get("included.client.audience") == included_client_id
|
|
236
|
+
):
|
|
237
|
+
return m
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
async def ensure_audience_mapper(
|
|
241
|
+
self,
|
|
242
|
+
internal_id: str,
|
|
243
|
+
included_client_id: str,
|
|
244
|
+
*,
|
|
245
|
+
id_token_claim: bool = True,
|
|
246
|
+
access_token_claim: bool = True,
|
|
247
|
+
update_if_different: bool = False,
|
|
248
|
+
) -> dict[str, bool]:
|
|
249
|
+
"""
|
|
250
|
+
Ensure audience mapper exists; optionally update its config if different.
|
|
251
|
+
|
|
252
|
+
Returns {"created": bool, "updated": bool}.
|
|
253
|
+
"""
|
|
254
|
+
desired_cfg = {
|
|
255
|
+
"included.client.audience": included_client_id,
|
|
256
|
+
"id.token.claim": "true" if id_token_claim else "false",
|
|
257
|
+
"access.token.claim": "true" if access_token_claim else "false",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
existing = await self._find_audience_mapper(internal_id, included_client_id)
|
|
261
|
+
if existing:
|
|
262
|
+
if update_if_different:
|
|
263
|
+
current = existing.get("config") or {}
|
|
264
|
+
if any(current.get(k) != v for k, v in desired_cfg.items()):
|
|
265
|
+
mapper_id = existing.get("id")
|
|
266
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
|
|
267
|
+
payload = {
|
|
268
|
+
"id": mapper_id,
|
|
269
|
+
"name": existing.get("name") or f"aud-{included_client_id}",
|
|
270
|
+
"protocol": "openid-connect",
|
|
271
|
+
"protocolMapper": "oidc-audience-mapper",
|
|
272
|
+
"config": desired_cfg,
|
|
273
|
+
}
|
|
274
|
+
await self._request("PUT", url, json=payload)
|
|
275
|
+
return {"created": False, "updated": True}
|
|
276
|
+
return {"created": False, "updated": False}
|
|
277
|
+
|
|
278
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
|
|
279
|
+
payload = {
|
|
280
|
+
"name": f"aud-{included_client_id}",
|
|
281
|
+
"protocol": "openid-connect",
|
|
282
|
+
"protocolMapper": "oidc-audience-mapper",
|
|
283
|
+
"config": desired_cfg,
|
|
284
|
+
}
|
|
285
|
+
await self._request("POST", url, json=payload)
|
|
286
|
+
return {"created": True, "updated": False}
|
|
287
|
+
|
|
288
|
+
async def remove_audience_mapper(
|
|
289
|
+
self,
|
|
290
|
+
internal_id: str,
|
|
291
|
+
included_client_id: str,
|
|
292
|
+
) -> bool:
|
|
293
|
+
existing = await self._find_audience_mapper(internal_id, included_client_id)
|
|
294
|
+
if not existing:
|
|
295
|
+
return False
|
|
296
|
+
mapper_id = existing.get("id")
|
|
297
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
|
|
298
|
+
await self._request("DELETE", url)
|
|
299
|
+
return True
|
|
300
|
+
|
|
301
|
+
# ---- client roles mapper (FE sees resource_access.<api>.roles) ------
|
|
302
|
+
|
|
303
|
+
async def _find_client_roles_mapper(
|
|
304
|
+
self,
|
|
305
|
+
internal_id: str,
|
|
306
|
+
source_client_id: str,
|
|
307
|
+
) -> Optional[dict[str, Any]]:
|
|
308
|
+
claim = f"resource_access.{source_client_id}.roles"
|
|
309
|
+
for m in await self.list_protocol_mappers(internal_id):
|
|
310
|
+
if (
|
|
311
|
+
m.get("protocol") == "openid-connect"
|
|
312
|
+
and m.get("protocolMapper") == "oidc-usermodel-client-role-mapper"
|
|
313
|
+
):
|
|
314
|
+
cfg = m.get("config") or {}
|
|
315
|
+
if (
|
|
316
|
+
cfg.get("usermodel.clientRoleMapping.clientId") == source_client_id
|
|
317
|
+
and cfg.get("claim.name") == claim
|
|
318
|
+
):
|
|
319
|
+
return m
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
async def ensure_client_roles_mapper(
|
|
323
|
+
self,
|
|
324
|
+
internal_id: str,
|
|
325
|
+
source_client_id: str,
|
|
326
|
+
*,
|
|
327
|
+
update_if_different: bool = False,
|
|
328
|
+
) -> dict[str, bool]:
|
|
329
|
+
"""
|
|
330
|
+
Ensure FE client has a roles mapper for roles of source_client_id under
|
|
331
|
+
resource_access.{source_client_id}.roles.
|
|
332
|
+
|
|
333
|
+
Returns {"created": bool, "updated": bool}.
|
|
334
|
+
"""
|
|
335
|
+
desired_cfg = {
|
|
336
|
+
"usermodel.clientRoleMapping.clientId": source_client_id,
|
|
337
|
+
"claim.name": f"resource_access.{source_client_id}.roles",
|
|
338
|
+
"jsonType.label": "String",
|
|
339
|
+
"multivalued": "true",
|
|
340
|
+
"id.token.claim": "true",
|
|
341
|
+
"access.token.claim": "true",
|
|
342
|
+
"userinfo.token.claim": "true",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
existing = await self._find_client_roles_mapper(internal_id, source_client_id)
|
|
346
|
+
if existing:
|
|
347
|
+
if update_if_different:
|
|
348
|
+
current = existing.get("config") or {}
|
|
349
|
+
if any(current.get(k) != v for k, v in desired_cfg.items()):
|
|
350
|
+
mapper_id = existing.get("id")
|
|
351
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
|
|
352
|
+
payload = {
|
|
353
|
+
"id": mapper_id,
|
|
354
|
+
"name": existing.get("name") or f"roles-{source_client_id}",
|
|
355
|
+
"protocol": "openid-connect",
|
|
356
|
+
"protocolMapper": "oidc-usermodel-client-role-mapper",
|
|
357
|
+
"config": {**current, **desired_cfg},
|
|
358
|
+
}
|
|
359
|
+
await self._request("PUT", url, json=payload)
|
|
360
|
+
return {"created": False, "updated": True}
|
|
361
|
+
return {"created": False, "updated": False}
|
|
362
|
+
|
|
363
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models"
|
|
364
|
+
payload = {
|
|
365
|
+
"name": f"roles-{source_client_id}",
|
|
366
|
+
"protocol": "openid-connect",
|
|
367
|
+
"protocolMapper": "oidc-usermodel-client-role-mapper",
|
|
368
|
+
"config": desired_cfg,
|
|
369
|
+
}
|
|
370
|
+
await self._request("POST", url, json=payload)
|
|
371
|
+
return {"created": True, "updated": False}
|
|
372
|
+
|
|
373
|
+
async def remove_client_roles_mapper(
|
|
374
|
+
self,
|
|
375
|
+
internal_id: str,
|
|
376
|
+
source_client_id: str,
|
|
377
|
+
) -> bool:
|
|
378
|
+
existing = await self._find_client_roles_mapper(internal_id, source_client_id)
|
|
379
|
+
if not existing:
|
|
380
|
+
return False
|
|
381
|
+
mapper_id = existing.get("id")
|
|
382
|
+
url = f"{self._realm_admin()}/clients/{internal_id}/protocol-mappers/models/{mapper_id}"
|
|
383
|
+
await self._request("DELETE", url)
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------ #
|
|
387
|
+
# helpers
|
|
388
|
+
# ------------------------------------------------------------------ #
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _trim_client(obj: dict[str, Any]) -> dict[str, Any]:
|
|
392
|
+
return {
|
|
393
|
+
"id": obj.get("id"),
|
|
394
|
+
"clientId": obj.get("clientId"),
|
|
395
|
+
"publicClient": obj.get("publicClient"),
|
|
396
|
+
"serviceAccountsEnabled": obj.get("serviceAccountsEnabled"),
|
|
397
|
+
"standardFlowEnabled": obj.get("standardFlowEnabled"),
|
|
398
|
+
"redirectUris": obj.get("redirectUris") or [],
|
|
399
|
+
"webOrigins": obj.get("webOrigins") or [],
|
|
400
|
+
"enabled": obj.get("enabled"),
|
|
401
|
+
}
|
pkg_auth/admin/env.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Any
|
|
6
|
+
|
|
7
|
+
from .settings import KCAdminSettings
|
|
8
|
+
from .provision_client import provision_keycloak_client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def settings_from_env() -> KCAdminSettings:
|
|
12
|
+
def _bool(key: str, default: bool = True) -> bool:
|
|
13
|
+
raw = os.getenv(key)
|
|
14
|
+
if raw is None:
|
|
15
|
+
return default
|
|
16
|
+
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
17
|
+
|
|
18
|
+
def _split_csv(key: str) -> list[str]:
|
|
19
|
+
raw = os.getenv(key)
|
|
20
|
+
if not raw:
|
|
21
|
+
return []
|
|
22
|
+
return [x.strip() for x in raw.split(",") if x and x.strip()]
|
|
23
|
+
|
|
24
|
+
base_url = os.getenv("KEYCLOAK_BASE_URL")
|
|
25
|
+
realm = os.getenv("KEYCLOAK_REALM")
|
|
26
|
+
admin_user = os.getenv("KEYCLOAK_ADMIN_USER")
|
|
27
|
+
admin_pass = os.getenv("KEYCLOAK_ADMIN_PASS")
|
|
28
|
+
if not all([base_url, realm, admin_user, admin_pass]):
|
|
29
|
+
missing = [
|
|
30
|
+
n
|
|
31
|
+
for n, v in [
|
|
32
|
+
("KEYCLOAK_BASE_URL", base_url),
|
|
33
|
+
("KEYCLOAK_REALM", realm),
|
|
34
|
+
("KEYCLOAK_ADMIN_USER", admin_user),
|
|
35
|
+
("KEYCLOAK_ADMIN_PASS", admin_pass),
|
|
36
|
+
]
|
|
37
|
+
if not v
|
|
38
|
+
]
|
|
39
|
+
raise RuntimeError(f"Missing Keycloak admin settings: {', '.join(missing)}")
|
|
40
|
+
|
|
41
|
+
return KCAdminSettings(
|
|
42
|
+
keycloak_base_url=base_url,
|
|
43
|
+
keycloak_realm=realm,
|
|
44
|
+
keycloak_admin_user=admin_user,
|
|
45
|
+
keycloak_admin_pass=admin_pass,
|
|
46
|
+
verify_ssl=_bool("VERIFY_SSL", True),
|
|
47
|
+
app_name=os.getenv("APP_NAME"),
|
|
48
|
+
service_name=os.getenv("SERVICE_NAME"),
|
|
49
|
+
frontend_client_ids=_split_csv("KEYCLOAK_FRONTEND_CLIENT_IDS"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_keycloak_client_from_env(
|
|
54
|
+
*,
|
|
55
|
+
strict_roles: bool = False,
|
|
56
|
+
strict_audience: bool = False,
|
|
57
|
+
client_id: Optional[str] = None,
|
|
58
|
+
permissions: Optional[list[str]] = None,
|
|
59
|
+
frontend_client_ids: Optional[list[str]] = None,
|
|
60
|
+
remove_frontend_client_ids: Optional[list[str]] = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Convenience sync wrapper using env-configured settings."""
|
|
63
|
+
settings = settings_from_env()
|
|
64
|
+
return asyncio.run(
|
|
65
|
+
provision_keycloak_client(
|
|
66
|
+
settings=settings,
|
|
67
|
+
client_id=client_id,
|
|
68
|
+
permissions=permissions,
|
|
69
|
+
frontend_client_ids=frontend_client_ids,
|
|
70
|
+
remove_frontend_client_ids=remove_frontend_client_ids,
|
|
71
|
+
strict_roles=strict_roles,
|
|
72
|
+
strict_audience=strict_audience,
|
|
73
|
+
)
|
|
74
|
+
)
|