cpkit 0.1.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.
Files changed (90) hide show
  1. cpkit/README.md +82 -0
  2. cpkit/__init__.py +42 -0
  3. cpkit/admin.py +53 -0
  4. cpkit/app.py +130 -0
  5. cpkit/audit/README.md +33 -0
  6. cpkit/audit/__init__.py +44 -0
  7. cpkit/audit/events_service.py +36 -0
  8. cpkit/audit/recorder.py +240 -0
  9. cpkit/audit/repository.py +64 -0
  10. cpkit/audit/router.py +46 -0
  11. cpkit/audit/service.py +34 -0
  12. cpkit/audit/types.py +38 -0
  13. cpkit/auth/README.md +38 -0
  14. cpkit/auth/__init__.py +123 -0
  15. cpkit/auth/api_key_router.py +52 -0
  16. cpkit/auth/api_key_service.py +143 -0
  17. cpkit/auth/api_keys.py +143 -0
  18. cpkit/auth/bundle.py +113 -0
  19. cpkit/auth/claims.py +37 -0
  20. cpkit/auth/config.py +162 -0
  21. cpkit/auth/dependencies.py +155 -0
  22. cpkit/auth/oidc.py +703 -0
  23. cpkit/auth/redirects.py +12 -0
  24. cpkit/auth/repositories.py +181 -0
  25. cpkit/auth/router.py +214 -0
  26. cpkit/auth/secrets.py +78 -0
  27. cpkit/auth/types.py +49 -0
  28. cpkit/bundle.py +252 -0
  29. cpkit/cli/README.md +21 -0
  30. cpkit/cli/__init__.py +14 -0
  31. cpkit/cli/__main__.py +5 -0
  32. cpkit/cli/base.py +200 -0
  33. cpkit/cli/schema.py +30 -0
  34. cpkit/cli/server.py +22 -0
  35. cpkit/config/README.md +13 -0
  36. cpkit/config/__init__.py +9 -0
  37. cpkit/config/env.py +31 -0
  38. cpkit/db/README.md +24 -0
  39. cpkit/db/__init__.py +23 -0
  40. cpkit/db/postgres.py +252 -0
  41. cpkit/dependencies.py +73 -0
  42. cpkit/errors/README.md +22 -0
  43. cpkit/errors/__init__.py +35 -0
  44. cpkit/errors/http.py +45 -0
  45. cpkit/errors/repository.py +32 -0
  46. cpkit/errors/service.py +74 -0
  47. cpkit/jobs/README.md +29 -0
  48. cpkit/jobs/__init__.py +49 -0
  49. cpkit/jobs/maintenance.py +15 -0
  50. cpkit/jobs/repository.py +306 -0
  51. cpkit/jobs/router.py +105 -0
  52. cpkit/jobs/service.py +138 -0
  53. cpkit/jobs/types.py +67 -0
  54. cpkit/jobs/worker.py +200 -0
  55. cpkit/logging/README.md +19 -0
  56. cpkit/logging/__init__.py +13 -0
  57. cpkit/logging/context.py +29 -0
  58. cpkit/logging/middleware.py +55 -0
  59. cpkit/logging/setup.py +81 -0
  60. cpkit/playbooks/README.md +25 -0
  61. cpkit/playbooks/__init__.py +40 -0
  62. cpkit/playbooks/ansible.py +359 -0
  63. cpkit/playbooks/repository.py +95 -0
  64. cpkit/playbooks/router.py +95 -0
  65. cpkit/playbooks/service.py +232 -0
  66. cpkit/playbooks/types.py +43 -0
  67. cpkit/repository.py +63 -0
  68. cpkit/resources/__init__.py +17 -0
  69. cpkit/resources/ddl.sql +150 -0
  70. cpkit/settings/README.md +22 -0
  71. cpkit/settings/__init__.py +18 -0
  72. cpkit/settings/keys.py +30 -0
  73. cpkit/settings/repository.py +108 -0
  74. cpkit/settings/router.py +62 -0
  75. cpkit/settings/service.py +105 -0
  76. cpkit/settings/types.py +25 -0
  77. cpkit/time.py +4 -0
  78. cpkit/webapp/README.md +71 -0
  79. cpkit/webapp/__init__.py +12 -0
  80. cpkit/webapp/index.html +970 -0
  81. cpkit/webapp/script.js +1690 -0
  82. cpkit/webapp/style.css +1561 -0
  83. cpkit-0.1.0.dist-info/METADATA +105 -0
  84. cpkit-0.1.0.dist-info/RECORD +90 -0
  85. cpkit-0.1.0.dist-info/WHEEL +4 -0
  86. cpkit-0.1.0.dist-info/licenses/LICENSE +201 -0
  87. resources/README.md +16 -0
  88. resources/repository_maintenance_guide.md +90 -0
  89. resources/webapp_extension_guide.md +706 -0
  90. resources/webapp_extension_template.js +130 -0
@@ -0,0 +1,12 @@
1
+ """Redirect target validation helpers."""
2
+
3
+
4
+ def safe_next_path(next_path: str | None) -> str:
5
+ """Normalize redirect targets so only in-app absolute paths are allowed."""
6
+ if not next_path:
7
+ return "/"
8
+ if not next_path.startswith("/"):
9
+ return "/"
10
+ if next_path.startswith("//"):
11
+ return "/"
12
+ return next_path
@@ -0,0 +1,181 @@
1
+ """Repository mixins for framework-owned auth tables."""
2
+
3
+ from cpkit.db import execute_stmt, fetch_all, fetch_one
4
+
5
+ from .types import ApiKeyRecord, ApiKeySummary, OIDCSessionRecord, RoleGroupMap
6
+
7
+ API_KEYS_TABLE = "cpkit.api_keys"
8
+ OIDC_SESSIONS_TABLE = "cpkit.oidc_sessions"
9
+ ROLE_GROUP_MAPPINGS_TABLE = "cpkit.role_to_groups_mappings"
10
+
11
+
12
+ class APIKeysRepositoryMixin:
13
+ api_key_record_type = ApiKeyRecord
14
+ api_key_summary_type = ApiKeySummary
15
+
16
+ def get_api_key(self, access_key: str):
17
+ return fetch_one(
18
+ f"""
19
+ SELECT access_key, encrypted_secret_access_key, owner, valid_until, roles
20
+ FROM {API_KEYS_TABLE}
21
+ WHERE access_key = %s
22
+ """,
23
+ (access_key,),
24
+ self.api_key_record_type,
25
+ operation="api_keys.get",
26
+ )
27
+
28
+ def list_api_keys(self, access_key: str | None = None):
29
+ params: list[str] = []
30
+ sql = f"""
31
+ SELECT access_key, owner, valid_until, roles
32
+ FROM {API_KEYS_TABLE}
33
+ """
34
+
35
+ if access_key is not None:
36
+ sql += " WHERE access_key = %s"
37
+ params.append(access_key)
38
+
39
+ sql += " ORDER BY access_key"
40
+
41
+ return fetch_all(
42
+ sql,
43
+ tuple(params),
44
+ self.api_key_summary_type,
45
+ operation="api_keys.list",
46
+ )
47
+
48
+ def create_api_key(
49
+ self,
50
+ api_key,
51
+ *,
52
+ owner: str,
53
+ encrypted_secret_access_key: bytes,
54
+ ):
55
+ return fetch_one(
56
+ f"""
57
+ INSERT INTO {API_KEYS_TABLE} (
58
+ access_key,
59
+ encrypted_secret_access_key,
60
+ owner,
61
+ valid_until,
62
+ roles
63
+ )
64
+ VALUES (%s, %s, %s, %s, %s)
65
+ RETURNING access_key, owner, valid_until, roles
66
+ """,
67
+ (
68
+ api_key.access_key,
69
+ encrypted_secret_access_key,
70
+ owner,
71
+ api_key.valid_until,
72
+ api_key.roles,
73
+ ),
74
+ self.api_key_summary_type,
75
+ operation="api_keys.create",
76
+ )
77
+
78
+ def delete_api_key(self, access_key: str) -> None:
79
+ execute_stmt(
80
+ f"""
81
+ DELETE
82
+ FROM {API_KEYS_TABLE}
83
+ WHERE access_key = %s
84
+ """,
85
+ (access_key,),
86
+ operation="api_keys.delete",
87
+ )
88
+
89
+
90
+ class OIDCSessionsRepositoryMixin:
91
+ oidc_session_record_type = OIDCSessionRecord
92
+
93
+ def get_oidc_session(self, session_id: str):
94
+ return fetch_one(
95
+ f"""
96
+ SELECT
97
+ session_id,
98
+ encrypted_id_token,
99
+ encrypted_refresh_token,
100
+ token_expires_at,
101
+ session_expires_at,
102
+ created_at,
103
+ updated_at
104
+ FROM {OIDC_SESSIONS_TABLE}
105
+ WHERE session_id = %s
106
+ AND session_expires_at > now()
107
+ """,
108
+ (session_id,),
109
+ self.oidc_session_record_type,
110
+ operation="auth.get_oidc_session",
111
+ )
112
+
113
+ def create_oidc_session(self, session) -> None:
114
+ execute_stmt(
115
+ f"""
116
+ INSERT INTO {OIDC_SESSIONS_TABLE}
117
+ (session_id, encrypted_id_token, encrypted_refresh_token,
118
+ token_expires_at, session_expires_at)
119
+ VALUES
120
+ (%s, %s, %s, %s, %s)
121
+ """,
122
+ (
123
+ session.session_id,
124
+ session.encrypted_id_token,
125
+ session.encrypted_refresh_token,
126
+ session.token_expires_at,
127
+ session.session_expires_at,
128
+ ),
129
+ operation="auth.create_oidc_session",
130
+ )
131
+
132
+ def update_oidc_session(
133
+ self,
134
+ session_id: str,
135
+ *,
136
+ encrypted_id_token: bytes,
137
+ encrypted_refresh_token: bytes | None,
138
+ token_expires_at,
139
+ ) -> None:
140
+ execute_stmt(
141
+ f"""
142
+ UPDATE {OIDC_SESSIONS_TABLE}
143
+ SET
144
+ encrypted_id_token = %s,
145
+ encrypted_refresh_token = %s,
146
+ token_expires_at = %s
147
+ WHERE session_id = %s
148
+ """,
149
+ (
150
+ encrypted_id_token,
151
+ encrypted_refresh_token,
152
+ token_expires_at,
153
+ session_id,
154
+ ),
155
+ operation="auth.update_oidc_session",
156
+ )
157
+
158
+ def delete_oidc_session(self, session_id: str) -> None:
159
+ execute_stmt(
160
+ f"""
161
+ DELETE FROM {OIDC_SESSIONS_TABLE}
162
+ WHERE session_id = %s
163
+ """,
164
+ (session_id,),
165
+ operation="auth.delete_oidc_session",
166
+ )
167
+
168
+
169
+ class RoleGroupMappingsRepositoryMixin:
170
+ role_group_map_type = RoleGroupMap
171
+
172
+ def list_role_group_mappings(self) -> list[RoleGroupMap]:
173
+ return fetch_all(
174
+ f"""
175
+ SELECT role, groups
176
+ FROM {ROLE_GROUP_MAPPINGS_TABLE}
177
+ """,
178
+ (),
179
+ self.role_group_map_type,
180
+ operation="auth.list_role_group_mappings",
181
+ )
cpkit/auth/router.py ADDED
@@ -0,0 +1,214 @@
1
+ """OIDC HTTP routes for cpkit FastAPI apps."""
2
+
3
+ import secrets
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, Security
8
+ from fastapi.responses import RedirectResponse
9
+
10
+ from .oidc import OIDCManager
11
+ from .redirects import safe_next_path
12
+
13
+ OIDC_STATE_COOKIE_NAME = "cp_oidc_state"
14
+ OIDC_NONCE_COOKIE_NAME = "cp_oidc_nonce"
15
+ OIDC_NEXT_COOKIE_NAME = "cp_oidc_next"
16
+
17
+ AuthEventHook = Callable[[Any, str, str, dict[str, Any] | None], None]
18
+
19
+
20
+ def create_oidc_router(
21
+ oidc: OIDCManager,
22
+ *,
23
+ get_repo: Callable[..., Any],
24
+ require_authenticated: Callable[..., Any],
25
+ get_audit_actor: Callable[..., str],
26
+ audit_event_hook: AuthEventHook | None = None,
27
+ prefix: str = "/auth",
28
+ tags: list[str] | None = None,
29
+ state_cookie_name: str = OIDC_STATE_COOKIE_NAME,
30
+ nonce_cookie_name: str = OIDC_NONCE_COOKIE_NAME,
31
+ next_cookie_name: str = OIDC_NEXT_COOKIE_NAME,
32
+ ) -> APIRouter:
33
+ """Create the standard OIDC auth router for a cpkit app."""
34
+ router = APIRouter(prefix=prefix, tags=tags or ["cpkit"])
35
+
36
+ def oidc_cookie_kwargs() -> dict[str, Any]:
37
+ return {
38
+ "httponly": True,
39
+ "secure": oidc.config.cookie_secure,
40
+ "samesite": oidc.config.cookie_samesite,
41
+ "domain": oidc.config.cookie_domain,
42
+ "path": "/",
43
+ }
44
+
45
+ def log_auth_event(
46
+ repo: Any,
47
+ actor_id: str,
48
+ action: str,
49
+ details: dict[str, Any] | None = None,
50
+ ) -> None:
51
+ if audit_event_hook is not None:
52
+ audit_event_hook(repo, actor_id, action, details)
53
+
54
+ @router.get("/login")
55
+ def oidc_login(
56
+ request: Request,
57
+ next: str = "/", # noqa: A002
58
+ repo: Any = Depends(get_repo),
59
+ ):
60
+ """Start the browser OIDC login flow and store anti-CSRF cookies."""
61
+ oidc.load_config(repo)
62
+ if not oidc.enabled:
63
+ raise HTTPException(
64
+ status_code=404,
65
+ detail="OIDC is disabled.",
66
+ )
67
+
68
+ state = secrets.token_urlsafe(24)
69
+ nonce = secrets.token_urlsafe(24)
70
+ next_path = safe_next_path(next)
71
+ redirect_uri = oidc.config.redirect_uri or str(request.url_for("oidc_callback"))
72
+ auth_url = oidc.build_authorization_url(redirect_uri, state, nonce)
73
+
74
+ resp = RedirectResponse(auth_url, status_code=302)
75
+ cookie_kwargs = oidc_cookie_kwargs()
76
+ resp.set_cookie(state_cookie_name, state, max_age=300, **cookie_kwargs)
77
+ resp.set_cookie(nonce_cookie_name, nonce, max_age=300, **cookie_kwargs)
78
+ resp.set_cookie(next_cookie_name, next_path, max_age=300, **cookie_kwargs)
79
+ return resp
80
+
81
+ @router.get("/callback", name="oidc_callback")
82
+ def oidc_callback(
83
+ request: Request,
84
+ repo: Any = Depends(get_repo),
85
+ code: str | None = None,
86
+ state: str | None = None,
87
+ error: str | None = None,
88
+ error_description: str | None = None,
89
+ ):
90
+ """Finish OIDC login, persist a session, and set the session cookie."""
91
+ oidc.load_config(repo)
92
+ if not oidc.enabled:
93
+ raise HTTPException(
94
+ status_code=404,
95
+ detail="OIDC is disabled.",
96
+ )
97
+
98
+ if error:
99
+ desc = error_description or "OIDC authorization failed."
100
+ raise HTTPException(status_code=401, detail=f"{error}: {desc}")
101
+
102
+ expected_state = request.cookies.get(state_cookie_name)
103
+ expected_nonce = request.cookies.get(nonce_cookie_name)
104
+ next_path = safe_next_path(request.cookies.get(next_cookie_name))
105
+
106
+ if not code:
107
+ raise HTTPException(status_code=400, detail="Missing authorization code.")
108
+ if not state or not expected_state or state != expected_state:
109
+ raise HTTPException(status_code=401, detail="Invalid OIDC state.")
110
+ if not expected_nonce:
111
+ raise HTTPException(status_code=401, detail="Missing OIDC nonce.")
112
+
113
+ redirect_uri = oidc.config.redirect_uri or str(request.url_for("oidc_callback"))
114
+ token_payload = oidc.exchange_code(code, redirect_uri)
115
+
116
+ id_token = token_payload.get("id_token")
117
+ if not id_token or not isinstance(id_token, str):
118
+ raise HTTPException(
119
+ status_code=401,
120
+ detail="Token endpoint response missing id_token.",
121
+ )
122
+ refresh_token = token_payload.get("refresh_token")
123
+ refresh_token_value = (
124
+ refresh_token if isinstance(refresh_token, str) and refresh_token else None
125
+ )
126
+
127
+ claims = oidc.validate_jwt(
128
+ id_token,
129
+ expected_nonce=expected_nonce,
130
+ strict_client_audience=True,
131
+ )
132
+ oidc.ensure_authorized(claims)
133
+ session_id = secrets.token_urlsafe(32)
134
+ repo.create_oidc_session(
135
+ oidc.build_session_record(
136
+ session_id,
137
+ id_token=id_token,
138
+ refresh_token=refresh_token_value,
139
+ claims=claims,
140
+ )
141
+ )
142
+ actor_id = str(claims.get(oidc.config.ui_username_claim) or claims.get("sub"))
143
+ log_auth_event(
144
+ repo,
145
+ actor_id,
146
+ "LOGIN",
147
+ {
148
+ "auth_type": "oidc",
149
+ "refresh_token_present": bool(refresh_token_value),
150
+ },
151
+ )
152
+
153
+ resp = RedirectResponse(next_path, status_code=302)
154
+ cookie_kwargs = oidc_cookie_kwargs()
155
+ if oidc.session_cookie_name:
156
+ resp.set_cookie(
157
+ oidc.session_cookie_name,
158
+ session_id,
159
+ max_age=oidc.config.session_max_age_seconds,
160
+ **cookie_kwargs,
161
+ )
162
+ resp.delete_cookie(
163
+ state_cookie_name,
164
+ path="/",
165
+ domain=oidc.config.cookie_domain,
166
+ )
167
+ resp.delete_cookie(
168
+ nonce_cookie_name,
169
+ path="/",
170
+ domain=oidc.config.cookie_domain,
171
+ )
172
+ resp.delete_cookie(
173
+ next_cookie_name,
174
+ path="/",
175
+ domain=oidc.config.cookie_domain,
176
+ )
177
+ return resp
178
+
179
+ @router.post("/logout")
180
+ def oidc_logout(
181
+ repo: Any = Depends(get_repo),
182
+ actor_id: str = Depends(get_audit_actor),
183
+ claims: dict[str, Any] = Security(require_authenticated),
184
+ ):
185
+ """Clear the OIDC session cookie and write a logout audit event."""
186
+ session_id = str(claims.get("_session_id") or "").strip()
187
+ if session_id:
188
+ repo.delete_oidc_session(session_id)
189
+ log_auth_event(
190
+ repo,
191
+ actor_id,
192
+ "LOGOUT",
193
+ {"auth_type": str(claims.get("auth_type") or "oidc")},
194
+ )
195
+ resp = Response(status_code=204)
196
+ if oidc.session_cookie_name:
197
+ resp.delete_cookie(
198
+ oidc.session_cookie_name,
199
+ path="/",
200
+ domain=oidc.config.cookie_domain,
201
+ )
202
+ return resp
203
+
204
+ @router.get("/me")
205
+ def oidc_me(
206
+ request: Request,
207
+ claims: dict[str, Any] = Security(require_authenticated),
208
+ ) -> dict[str, Any]:
209
+ """Return the current caller's claims plus auth metadata."""
210
+ payload = oidc.enrich_claims(claims)
211
+ payload["cookies"] = request.cookies
212
+ return payload
213
+
214
+ return router
cpkit/auth/secrets.py ADDED
@@ -0,0 +1,78 @@
1
+ """Versioned symmetric secret encryption helpers."""
2
+
3
+ import base64
4
+ import os
5
+ import secrets
6
+
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+
9
+ ENCRYPTED_SECRET_VERSION = b"\x01"
10
+ DEFAULT_MASTER_KEY_ENV_VAR = "CPKIT_MASTER_KEY"
11
+
12
+
13
+ def validate_secret_crypto_config(
14
+ *,
15
+ master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
16
+ ) -> None:
17
+ _secret_master_key(master_key_env_var=master_key_env_var)
18
+
19
+
20
+ def encrypt_secret(
21
+ secret: bytes | str,
22
+ *,
23
+ master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
24
+ ) -> bytes:
25
+ nonce = secrets.token_bytes(12)
26
+ key = _secret_master_key(master_key_env_var=master_key_env_var)
27
+ ciphertext = AESGCM(key).encrypt(nonce, _secret_bytes(secret), None)
28
+ return ENCRYPTED_SECRET_VERSION + nonce + ciphertext
29
+
30
+
31
+ def decrypt_secret(
32
+ secret: bytes | str,
33
+ *,
34
+ master_key_env_var: str = DEFAULT_MASTER_KEY_ENV_VAR,
35
+ ) -> bytes:
36
+ encrypted_secret = _secret_bytes(secret)
37
+ if not encrypted_secret:
38
+ raise RuntimeError("Encrypted secret is empty.")
39
+ if encrypted_secret[:1] != ENCRYPTED_SECRET_VERSION:
40
+ raise RuntimeError(
41
+ "Encrypted secret has an unsupported format. Migrate stored secrets to the versioned encrypted format."
42
+ )
43
+
44
+ nonce = encrypted_secret[1:13]
45
+ ciphertext = encrypted_secret[13:]
46
+ if len(nonce) != 12 or not ciphertext:
47
+ raise RuntimeError("Encrypted secret is malformed.")
48
+
49
+ try:
50
+ return AESGCM(
51
+ _secret_master_key(master_key_env_var=master_key_env_var)
52
+ ).decrypt(nonce, ciphertext, None)
53
+ except Exception as exc:
54
+ raise RuntimeError(
55
+ f"Encrypted secret could not be decrypted. Check {master_key_env_var} and stored key material."
56
+ ) from exc
57
+
58
+
59
+ def _secret_master_key(*, master_key_env_var: str) -> bytes:
60
+ encoded_key = os.getenv(master_key_env_var, "").strip()
61
+ if not encoded_key:
62
+ raise RuntimeError(f"{master_key_env_var} must be set for secret encryption.")
63
+
64
+ try:
65
+ key = base64.b64decode(encoded_key, validate=True)
66
+ except ValueError as exc:
67
+ raise RuntimeError(f"{master_key_env_var} must be valid base64.") from exc
68
+
69
+ if len(key) != 32:
70
+ raise RuntimeError(f"{master_key_env_var} must decode to exactly 32 bytes.")
71
+
72
+ return key
73
+
74
+
75
+ def _secret_bytes(secret: bytes | str) -> bytes:
76
+ if isinstance(secret, bytes):
77
+ return secret
78
+ return secret.encode("utf-8")
cpkit/auth/types.py ADDED
@@ -0,0 +1,49 @@
1
+ """Generic auth capability data types."""
2
+
3
+ import datetime as dt
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class ApiKeyRecord(BaseModel):
10
+ access_key: str
11
+ encrypted_secret_access_key: bytes
12
+ owner: str
13
+ valid_until: dt.datetime
14
+ roles: list[Any] | None = None
15
+
16
+
17
+ class ApiKeySummary(BaseModel):
18
+ access_key: str
19
+ owner: str
20
+ valid_until: dt.datetime
21
+ roles: list[Any] | None = None
22
+
23
+
24
+ class ApiKeyCreateRequest(BaseModel):
25
+ valid_until: dt.datetime
26
+ roles: list[Any] | None = None
27
+
28
+
29
+ class ApiKeyCreateRequestInDB(ApiKeyCreateRequest):
30
+ access_key: str
31
+
32
+
33
+ class ApiKeyCreateResponse(ApiKeySummary):
34
+ secret_access_key: str
35
+
36
+
37
+ class OIDCSessionRecord(BaseModel):
38
+ session_id: str
39
+ encrypted_id_token: bytes
40
+ encrypted_refresh_token: bytes | None = None
41
+ token_expires_at: dt.datetime
42
+ session_expires_at: dt.datetime
43
+ created_at: dt.datetime | None = None
44
+ updated_at: dt.datetime | None = None
45
+
46
+
47
+ class RoleGroupMap(BaseModel):
48
+ role: str
49
+ groups: list[str]