granny-devops 0.9.0__tar.gz → 0.9.2__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.
- {granny_devops-0.9.0 → granny_devops-0.9.2}/PKG-INFO +9 -5
- {granny_devops-0.9.0 → granny_devops-0.9.2}/README.md +8 -4
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/__init__.py +1 -1
- granny_devops-0.9.2/granny/authentik/__init__.py +12 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/authentik/client.py +79 -0
- granny_devops-0.9.2/granny/authentik/provision.py +197 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/authentik.py +165 -27
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/serverless.py +3 -3
- {granny_devops-0.9.0 → granny_devops-0.9.2}/pyproject.toml +1 -1
- granny_devops-0.9.0/granny/authentik/__init__.py +0 -33
- granny_devops-0.9.0/granny/authentik/provision.py +0 -235
- {granny_devops-0.9.0 → granny_devops-0.9.2}/.gitignore +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/LICENSE +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/costs.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/credits.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/analyze.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/cdn.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/create.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/credentials.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/dns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/docker.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/edge.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/email.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/main.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cli/storage.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/registrars.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/base.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/desec.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/factory.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/inwx.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/manual.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/dns/records.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/docker/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/docker/build_base.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/edge/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/edge/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/email/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/email/mailjet.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/email/workmail.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/report.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/storage/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/storage/aws.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/storage/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.2}/granny/storage/hetzner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: granny-devops
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation
|
|
5
5
|
Author-email: Martin Wieser <martin.wieser@pseekoo.com>
|
|
6
6
|
License: MIT License
|
|
@@ -288,10 +288,14 @@ granny create scaleway-container --name my-app --port 3000
|
|
|
288
288
|
granny create mailjet-dns example.com
|
|
289
289
|
|
|
290
290
|
# Authentik admin (provider + application + group plumbing)
|
|
291
|
-
granny authentik provision-
|
|
291
|
+
granny authentik provision-oauth-app my-app \
|
|
292
|
+
--name "My App" \
|
|
293
|
+
--redirect-uri https://app.example.com/auth/callback \
|
|
294
|
+
--launch-url https://app.example.com \
|
|
295
|
+
--group my-app-admins
|
|
292
296
|
granny authentik list providers
|
|
293
|
-
granny authentik rotate-secret
|
|
294
|
-
granny authentik add-user-to-group
|
|
297
|
+
granny authentik rotate-secret my-oauth-provider
|
|
298
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
295
299
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
296
300
|
```
|
|
297
301
|
|
|
@@ -309,7 +313,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
309
313
|
| AWS inventory | VPCs, Lambdas |
|
|
310
314
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
311
315
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
312
|
-
| SSO / IdP | Authentik (provider
|
|
316
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
313
317
|
|
|
314
318
|
## As a library
|
|
315
319
|
|
|
@@ -156,10 +156,14 @@ granny create scaleway-container --name my-app --port 3000
|
|
|
156
156
|
granny create mailjet-dns example.com
|
|
157
157
|
|
|
158
158
|
# Authentik admin (provider + application + group plumbing)
|
|
159
|
-
granny authentik provision-
|
|
159
|
+
granny authentik provision-oauth-app my-app \
|
|
160
|
+
--name "My App" \
|
|
161
|
+
--redirect-uri https://app.example.com/auth/callback \
|
|
162
|
+
--launch-url https://app.example.com \
|
|
163
|
+
--group my-app-admins
|
|
160
164
|
granny authentik list providers
|
|
161
|
-
granny authentik rotate-secret
|
|
162
|
-
granny authentik add-user-to-group
|
|
165
|
+
granny authentik rotate-secret my-oauth-provider
|
|
166
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
163
167
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
164
168
|
```
|
|
165
169
|
|
|
@@ -177,7 +181,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
177
181
|
| AWS inventory | VPCs, Lambdas |
|
|
178
182
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
179
183
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
180
|
-
| SSO / IdP | Authentik (provider
|
|
184
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
181
185
|
|
|
182
186
|
## As a library
|
|
183
187
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Authentik management — REST API wrapper."""
|
|
2
|
+
|
|
3
|
+
from granny.authentik.client import AuthentikClient, AuthentikError
|
|
4
|
+
from granny.authentik.provision import OAuthAppSpec, ProvisionResult, provision_oauth_app
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AuthentikClient",
|
|
8
|
+
"AuthentikError",
|
|
9
|
+
"OAuthAppSpec",
|
|
10
|
+
"ProvisionResult",
|
|
11
|
+
"provision_oauth_app",
|
|
12
|
+
]
|
|
@@ -256,3 +256,82 @@ class AuthentikClient:
|
|
|
256
256
|
f"/api/v3/providers/oauth2/{provider['pk']}/",
|
|
257
257
|
body={"client_secret": ""},
|
|
258
258
|
)
|
|
259
|
+
|
|
260
|
+
def create_user(
|
|
261
|
+
self,
|
|
262
|
+
username: str,
|
|
263
|
+
*,
|
|
264
|
+
name: str | None = None,
|
|
265
|
+
email: str | None = None,
|
|
266
|
+
path: str = "users",
|
|
267
|
+
user_type: str = "internal",
|
|
268
|
+
is_active: bool = True,
|
|
269
|
+
attributes: dict[str, Any] | None = None,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
"""Create an internal Authentik user (idempotent on ``username``).
|
|
272
|
+
|
|
273
|
+
If a user with the same ``username`` already exists it is returned
|
|
274
|
+
unmodified — call ``request("PATCH", …)`` separately if you need to
|
|
275
|
+
update fields. The default ``path="users"`` puts the user in the
|
|
276
|
+
regular Authentik directory (not under any LDAP source path), so
|
|
277
|
+
the user can authenticate against an internal password rather than
|
|
278
|
+
being bound to Kanidm.
|
|
279
|
+
"""
|
|
280
|
+
existing = self.find_user_by_username(username)
|
|
281
|
+
if existing:
|
|
282
|
+
return existing
|
|
283
|
+
body: dict[str, Any] = {
|
|
284
|
+
"username": username,
|
|
285
|
+
"name": name or username,
|
|
286
|
+
"is_active": is_active,
|
|
287
|
+
"type": user_type,
|
|
288
|
+
"path": path,
|
|
289
|
+
"groups": [],
|
|
290
|
+
}
|
|
291
|
+
if email is not None:
|
|
292
|
+
body["email"] = email
|
|
293
|
+
if attributes:
|
|
294
|
+
body["attributes"] = attributes
|
|
295
|
+
return self.request("POST", "/api/v3/core/users/", body=body)
|
|
296
|
+
|
|
297
|
+
def set_user_password(self, username: str, password: str) -> None:
|
|
298
|
+
"""Set a user's password directly (admin reset, no email)."""
|
|
299
|
+
user = self.find_user_by_username(username)
|
|
300
|
+
if not user:
|
|
301
|
+
raise AuthentikError(404, f"User {username!r} not found")
|
|
302
|
+
self.request(
|
|
303
|
+
"POST",
|
|
304
|
+
f"/api/v3/core/users/{user['pk']}/set_password/",
|
|
305
|
+
body={"password": password},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def delete_user(self, username: str) -> None:
|
|
309
|
+
"""Delete a user by username. No-op if absent."""
|
|
310
|
+
user = self.find_user_by_username(username)
|
|
311
|
+
if not user:
|
|
312
|
+
return
|
|
313
|
+
self.request("DELETE", f"/api/v3/core/users/{user['pk']}/")
|
|
314
|
+
|
|
315
|
+
def generate_recovery_link(self, username: str) -> str:
|
|
316
|
+
"""Mint a one-shot recovery URL for the given user.
|
|
317
|
+
|
|
318
|
+
Returns the absolute URL the user opens to set their own password
|
|
319
|
+
(and enroll MFA if the recovery flow is configured to require it).
|
|
320
|
+
Authentik invalidates the token after a single use; default TTL is
|
|
321
|
+
short, set in the recovery flow's stage configuration.
|
|
322
|
+
"""
|
|
323
|
+
user = self.find_user_by_username(username)
|
|
324
|
+
if not user:
|
|
325
|
+
raise AuthentikError(404, f"User {username!r} not found")
|
|
326
|
+
result = self.request(
|
|
327
|
+
"POST",
|
|
328
|
+
f"/api/v3/core/users/{user['pk']}/recovery/",
|
|
329
|
+
)
|
|
330
|
+
# Authentik returns {"link": "https://..."} on this endpoint.
|
|
331
|
+
if isinstance(result, dict) and "link" in result:
|
|
332
|
+
return result["link"]
|
|
333
|
+
raise AuthentikError(
|
|
334
|
+
500,
|
|
335
|
+
f"Unexpected recovery payload for {username!r}: {result!r}. "
|
|
336
|
+
"Make sure a recovery flow is configured on the tenant.",
|
|
337
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Generic Authentik OAuth2 application provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from granny.authentik.client import AuthentikClient
|
|
8
|
+
|
|
9
|
+
DEFAULT_AUTHORIZATION_FLOW_SLUG = "default-provider-authorization-explicit-consent"
|
|
10
|
+
DEFAULT_INVALIDATION_FLOW_SLUG = "default-provider-invalidation-flow"
|
|
11
|
+
DEFAULT_OIDC_SCOPES: tuple[str, ...] = ("openid", "profile", "email")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OAuthAppSpec:
|
|
16
|
+
"""Desired state for an Authentik OAuth2 provider + application."""
|
|
17
|
+
|
|
18
|
+
slug: str
|
|
19
|
+
name: str
|
|
20
|
+
redirect_uris: tuple[str, ...]
|
|
21
|
+
launch_url: str | None = None
|
|
22
|
+
group: str | None = None
|
|
23
|
+
provider_name: str | None = None
|
|
24
|
+
scopes: tuple[str, ...] = DEFAULT_OIDC_SCOPES
|
|
25
|
+
authorization_flow_slug: str = DEFAULT_AUTHORIZATION_FLOW_SLUG
|
|
26
|
+
invalidation_flow_slug: str = DEFAULT_INVALIDATION_FLOW_SLUG
|
|
27
|
+
access_token_validity: str = "minutes=10"
|
|
28
|
+
refresh_token_validity: str = "days=30"
|
|
29
|
+
sub_mode: str = "user_username"
|
|
30
|
+
include_claims_in_id_token: bool = True
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
if not self.slug:
|
|
34
|
+
raise ValueError("slug must not be empty")
|
|
35
|
+
if not self.name:
|
|
36
|
+
raise ValueError("name must not be empty")
|
|
37
|
+
if not self.redirect_uris:
|
|
38
|
+
raise ValueError("at least one redirect URI is required")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ProvisionResult:
|
|
43
|
+
"""Result of provisioning an Authentik OAuth2 application."""
|
|
44
|
+
|
|
45
|
+
base_url: str
|
|
46
|
+
issuer: str
|
|
47
|
+
client_id: str
|
|
48
|
+
client_secret: str
|
|
49
|
+
redirect_uris: tuple[str, ...]
|
|
50
|
+
scopes: str
|
|
51
|
+
provider_pk: str
|
|
52
|
+
application_slug: str
|
|
53
|
+
group: str | None
|
|
54
|
+
group_pk: str | None
|
|
55
|
+
created_provider: bool
|
|
56
|
+
created_application: bool
|
|
57
|
+
created_binding: bool
|
|
58
|
+
created_group: bool
|
|
59
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def authorization_endpoint(self) -> str:
|
|
63
|
+
return f"{self.base_url}/application/o/authorize/"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def token_endpoint(self) -> str:
|
|
67
|
+
return f"{self.base_url}/application/o/token/"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def userinfo_endpoint(self) -> str:
|
|
71
|
+
return f"{self.base_url}/application/o/userinfo/"
|
|
72
|
+
|
|
73
|
+
def oidc_block(self) -> dict[str, str | list[str] | None]:
|
|
74
|
+
"""Return a copy-paste friendly OIDC configuration snippet."""
|
|
75
|
+
return {
|
|
76
|
+
"issuer": self.issuer,
|
|
77
|
+
"authorization_endpoint": self.authorization_endpoint,
|
|
78
|
+
"token_endpoint": self.token_endpoint,
|
|
79
|
+
"userinfo_endpoint": self.userinfo_endpoint,
|
|
80
|
+
"client_id": self.client_id,
|
|
81
|
+
"client_secret": self.client_secret or None,
|
|
82
|
+
"redirect_uris": list(self.redirect_uris),
|
|
83
|
+
"scopes": self.scopes,
|
|
84
|
+
"admin_group": self.group,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def provision_oauth_app(
|
|
89
|
+
client: AuthentikClient,
|
|
90
|
+
spec: OAuthAppSpec,
|
|
91
|
+
*,
|
|
92
|
+
rotate_secret: bool = False,
|
|
93
|
+
) -> ProvisionResult:
|
|
94
|
+
"""Idempotently provision an OAuth2 provider and application in Authentik."""
|
|
95
|
+
provider_name = spec.provider_name or spec.slug
|
|
96
|
+
authorization_flow = client.find_flow_pk(spec.authorization_flow_slug)
|
|
97
|
+
invalidation_flow = client.find_flow_pk(spec.invalidation_flow_slug)
|
|
98
|
+
signing_key = client.find_signing_key_pk()
|
|
99
|
+
scope_mappings = client.find_scope_mappings(spec.scopes)
|
|
100
|
+
|
|
101
|
+
group = None
|
|
102
|
+
created_group = False
|
|
103
|
+
if spec.group:
|
|
104
|
+
existing_group = client.find_group_by_name(spec.group)
|
|
105
|
+
created_group = existing_group is None
|
|
106
|
+
group = existing_group or client.ensure_group(spec.group)
|
|
107
|
+
|
|
108
|
+
existing_app = client.find_application_by_slug(spec.slug)
|
|
109
|
+
adopt_pk: int | str | None = None
|
|
110
|
+
if existing_app is not None:
|
|
111
|
+
adopt_pk = existing_app.get("provider")
|
|
112
|
+
existing_provider = (
|
|
113
|
+
client.find_provider_by_pk(adopt_pk) if adopt_pk is not None else None
|
|
114
|
+
) or client.find_provider_by_name(provider_name)
|
|
115
|
+
|
|
116
|
+
provider_body: dict[str, object] = {
|
|
117
|
+
"name": provider_name,
|
|
118
|
+
"authorization_flow": authorization_flow,
|
|
119
|
+
"invalidation_flow": invalidation_flow,
|
|
120
|
+
"client_type": "confidential",
|
|
121
|
+
"redirect_uris": [
|
|
122
|
+
{"matching_mode": "strict", "url": uri} for uri in spec.redirect_uris
|
|
123
|
+
],
|
|
124
|
+
"signing_key": signing_key,
|
|
125
|
+
"property_mappings": scope_mappings,
|
|
126
|
+
"sub_mode": spec.sub_mode,
|
|
127
|
+
"include_claims_in_id_token": spec.include_claims_in_id_token,
|
|
128
|
+
"access_token_validity": spec.access_token_validity,
|
|
129
|
+
"refresh_token_validity": spec.refresh_token_validity,
|
|
130
|
+
}
|
|
131
|
+
if existing_provider:
|
|
132
|
+
if rotate_secret:
|
|
133
|
+
provider_body["client_secret"] = ""
|
|
134
|
+
provider = client.request(
|
|
135
|
+
"PATCH",
|
|
136
|
+
f"/api/v3/providers/oauth2/{existing_provider['pk']}/",
|
|
137
|
+
body=provider_body,
|
|
138
|
+
)
|
|
139
|
+
created_provider = False
|
|
140
|
+
else:
|
|
141
|
+
provider = client.request("POST", "/api/v3/providers/oauth2/", body=provider_body)
|
|
142
|
+
created_provider = True
|
|
143
|
+
|
|
144
|
+
app_body: dict[str, object] = {
|
|
145
|
+
"name": spec.name,
|
|
146
|
+
"slug": spec.slug,
|
|
147
|
+
"provider": provider["pk"],
|
|
148
|
+
"policy_engine_mode": "any",
|
|
149
|
+
}
|
|
150
|
+
if spec.launch_url is not None:
|
|
151
|
+
app_body["meta_launch_url"] = spec.launch_url
|
|
152
|
+
if existing_app:
|
|
153
|
+
client.request("PATCH", f"/api/v3/core/applications/{spec.slug}/", body=app_body)
|
|
154
|
+
created_application = False
|
|
155
|
+
target_pk = existing_app["pk"]
|
|
156
|
+
else:
|
|
157
|
+
new_app = client.request("POST", "/api/v3/core/applications/", body=app_body)
|
|
158
|
+
created_application = True
|
|
159
|
+
target_pk = new_app["pk"]
|
|
160
|
+
|
|
161
|
+
created_binding = False
|
|
162
|
+
if group is not None:
|
|
163
|
+
bindings = client.request(
|
|
164
|
+
"GET", "/api/v3/policies/bindings/", params={"target": target_pk}
|
|
165
|
+
).get("results", [])
|
|
166
|
+
has_binding = any(binding.get("group") == group["pk"] for binding in bindings)
|
|
167
|
+
if not has_binding:
|
|
168
|
+
client.request(
|
|
169
|
+
"POST",
|
|
170
|
+
"/api/v3/policies/bindings/",
|
|
171
|
+
body={
|
|
172
|
+
"target": target_pk,
|
|
173
|
+
"group": group["pk"],
|
|
174
|
+
"enabled": True,
|
|
175
|
+
"order": 0,
|
|
176
|
+
"negate": False,
|
|
177
|
+
"timeout": 30,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
created_binding = True
|
|
181
|
+
|
|
182
|
+
return ProvisionResult(
|
|
183
|
+
base_url=client.base_url,
|
|
184
|
+
issuer=f"{client.base_url}/application/o/{spec.slug}",
|
|
185
|
+
client_id=provider["client_id"],
|
|
186
|
+
client_secret=provider.get("client_secret") or "",
|
|
187
|
+
redirect_uris=spec.redirect_uris,
|
|
188
|
+
scopes=" ".join(spec.scopes),
|
|
189
|
+
provider_pk=str(provider["pk"]),
|
|
190
|
+
application_slug=spec.slug,
|
|
191
|
+
group=spec.group,
|
|
192
|
+
group_pk=str(group["pk"]) if group is not None else None,
|
|
193
|
+
created_provider=created_provider,
|
|
194
|
+
created_application=created_application,
|
|
195
|
+
created_binding=created_binding,
|
|
196
|
+
created_group=created_group,
|
|
197
|
+
)
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
"""``granny authentik`` — manage
|
|
2
|
-
|
|
3
|
-
Wraps :mod:`granny.authentik` so the common Authentik ops (per-stage
|
|
4
|
-
stoz3n-dash provisioning, group plumbing, secret rotation) work from the
|
|
5
|
-
command line without dropping to the admin UI.
|
|
1
|
+
"""``granny authentik`` — manage an Authentik instance via API.
|
|
6
2
|
|
|
7
3
|
Auth resolution: ``AUTHENTIK_API_TOKEN`` (env / .env / vault) or
|
|
8
4
|
``AK_TOKEN`` (env / .env). See :meth:`AuthentikClient.from_environment`.
|
|
@@ -16,18 +12,14 @@ from typing import Any
|
|
|
16
12
|
|
|
17
13
|
import click
|
|
18
14
|
|
|
19
|
-
from granny.authentik import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
AuthentikClient,
|
|
23
|
-
AuthentikError,
|
|
24
|
-
provision_stoz3n_dash,
|
|
25
|
-
)
|
|
15
|
+
from granny.authentik import AuthentikClient, AuthentikError, OAuthAppSpec, provision_oauth_app
|
|
16
|
+
|
|
17
|
+
ADMIN_GROUP_NAME = "dash_admins"
|
|
26
18
|
|
|
27
19
|
|
|
28
20
|
@click.group()
|
|
29
21
|
def authentik() -> None:
|
|
30
|
-
"""Manage Authentik
|
|
22
|
+
"""Manage Authentik via its REST API."""
|
|
31
23
|
|
|
32
24
|
|
|
33
25
|
def _client() -> AuthentikClient:
|
|
@@ -39,18 +31,17 @@ def _client() -> AuthentikClient:
|
|
|
39
31
|
|
|
40
32
|
|
|
41
33
|
def _print_provision_report(result: Any) -> None:
|
|
42
|
-
"""Pretty-print a ProvisionResult and the paste-ready JSON snippets."""
|
|
43
34
|
click.echo("", err=True)
|
|
44
35
|
click.echo("─" * 72)
|
|
45
|
-
click.echo(f"Stage : {result.stage}")
|
|
46
36
|
click.echo(f"Authentik URL : {result.base_url}")
|
|
37
|
+
click.echo(f"Application slug : {result.application_slug}")
|
|
47
38
|
click.echo(f"Issuer : {result.issuer}")
|
|
48
39
|
click.echo(f"client_id : {result.client_id}")
|
|
49
40
|
click.echo(
|
|
50
41
|
f"client_secret : {result.client_secret or '(not echoed — rotate-secret to see)'}"
|
|
51
42
|
)
|
|
52
|
-
click.echo(f"
|
|
53
|
-
click.echo(f"
|
|
43
|
+
click.echo(f"redirect_uris : {', '.join(result.redirect_uris)}")
|
|
44
|
+
click.echo(f"group : {result.group or '(none)'}")
|
|
54
45
|
click.echo(f"scopes : {result.scopes}")
|
|
55
46
|
flags = [
|
|
56
47
|
f"provider={'NEW' if result.created_provider else 'EXISTS'}",
|
|
@@ -61,11 +52,7 @@ def _print_provision_report(result: Any) -> None:
|
|
|
61
52
|
click.echo(f"state : {', '.join(flags)}")
|
|
62
53
|
click.echo("─" * 72)
|
|
63
54
|
click.echo("")
|
|
64
|
-
click.echo(
|
|
65
|
-
click.echo(json.dumps(result.chat_agent_oidc_block(), indent=2))
|
|
66
|
-
click.echo("")
|
|
67
|
-
click.echo(f"# Paste into dotfiles/stoz3n-agent-dash/config.{result.stage}.json")
|
|
68
|
-
click.echo(json.dumps(result.dashboard_oidc_block(), indent=2))
|
|
55
|
+
click.echo(json.dumps({"oidc": result.oidc_block()}, indent=2))
|
|
69
56
|
if not result.client_secret:
|
|
70
57
|
click.echo("", err=True)
|
|
71
58
|
click.echo(
|
|
@@ -76,18 +63,71 @@ def _print_provision_report(result: Any) -> None:
|
|
|
76
63
|
)
|
|
77
64
|
|
|
78
65
|
|
|
79
|
-
@authentik.command("provision-
|
|
80
|
-
@click.argument("
|
|
66
|
+
@authentik.command("provision-oauth-app")
|
|
67
|
+
@click.argument("slug")
|
|
68
|
+
@click.option("--name", required=True, help="Application display name.")
|
|
69
|
+
@click.option(
|
|
70
|
+
"--redirect-uri",
|
|
71
|
+
"redirect_uris",
|
|
72
|
+
multiple=True,
|
|
73
|
+
required=True,
|
|
74
|
+
help="Allowed redirect URI. Repeat for multiple callbacks.",
|
|
75
|
+
)
|
|
76
|
+
@click.option("--launch-url", default=None, help="Optional application launch URL.")
|
|
77
|
+
@click.option("--group", default=None, help="Optional group allowed to access the app.")
|
|
78
|
+
@click.option("--provider-name", default=None, help="OAuth2 provider name (defaults to slug).")
|
|
79
|
+
@click.option(
|
|
80
|
+
"--scope",
|
|
81
|
+
"scopes",
|
|
82
|
+
multiple=True,
|
|
83
|
+
default=("openid", "profile", "email"),
|
|
84
|
+
show_default=True,
|
|
85
|
+
help="OIDC scope to include. Repeat to override/add scopes.",
|
|
86
|
+
)
|
|
87
|
+
@click.option(
|
|
88
|
+
"--authorization-flow",
|
|
89
|
+
default="default-provider-authorization-explicit-consent",
|
|
90
|
+
show_default=True,
|
|
91
|
+
help="Authorization flow slug.",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--invalidation-flow",
|
|
95
|
+
default="default-provider-invalidation-flow",
|
|
96
|
+
show_default=True,
|
|
97
|
+
help="Invalidation flow slug.",
|
|
98
|
+
)
|
|
81
99
|
@click.option(
|
|
82
100
|
"--rotate",
|
|
83
101
|
is_flag=True,
|
|
84
102
|
help="Force a fresh client_secret if the provider already exists.",
|
|
85
103
|
)
|
|
86
|
-
def
|
|
87
|
-
|
|
104
|
+
def provision_oauth_app_cmd(
|
|
105
|
+
slug: str,
|
|
106
|
+
name: str,
|
|
107
|
+
redirect_uris: tuple[str, ...],
|
|
108
|
+
launch_url: str | None,
|
|
109
|
+
group: str | None,
|
|
110
|
+
provider_name: str | None,
|
|
111
|
+
scopes: tuple[str, ...],
|
|
112
|
+
authorization_flow: str,
|
|
113
|
+
invalidation_flow: str,
|
|
114
|
+
rotate: bool,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Provision an OAuth2 provider + application + optional group binding."""
|
|
117
|
+
spec = OAuthAppSpec(
|
|
118
|
+
slug=slug,
|
|
119
|
+
name=name,
|
|
120
|
+
redirect_uris=redirect_uris,
|
|
121
|
+
launch_url=launch_url,
|
|
122
|
+
group=group,
|
|
123
|
+
provider_name=provider_name,
|
|
124
|
+
scopes=scopes,
|
|
125
|
+
authorization_flow_slug=authorization_flow,
|
|
126
|
+
invalidation_flow_slug=invalidation_flow,
|
|
127
|
+
)
|
|
88
128
|
client = _client()
|
|
89
129
|
try:
|
|
90
|
-
result =
|
|
130
|
+
result = provision_oauth_app(client, spec, rotate_secret=rotate)
|
|
91
131
|
except AuthentikError as exc:
|
|
92
132
|
click.echo(f"Authentik error: {exc}", err=True)
|
|
93
133
|
sys.exit(1)
|
|
@@ -203,6 +243,104 @@ def remove_user_from_group_cmd(user: str, group: str) -> None:
|
|
|
203
243
|
click.echo(f"Removed {user} from {group}")
|
|
204
244
|
|
|
205
245
|
|
|
246
|
+
@authentik.command("create-user")
|
|
247
|
+
@click.argument("username")
|
|
248
|
+
@click.option("--name", default=None, help="Display name (defaults to username).")
|
|
249
|
+
@click.option("--email", default=None, help="Email address.")
|
|
250
|
+
@click.option(
|
|
251
|
+
"--group",
|
|
252
|
+
default=None,
|
|
253
|
+
help="Group to add the new user to after creation (e.g. dash_admins).",
|
|
254
|
+
)
|
|
255
|
+
@click.option(
|
|
256
|
+
"--password",
|
|
257
|
+
default=None,
|
|
258
|
+
help="Set this password directly. Suppresses --recovery-link.",
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"--recovery-link/--no-recovery-link",
|
|
262
|
+
default=True,
|
|
263
|
+
help="Print a one-shot recovery URL the user opens to set their own password (default: yes).",
|
|
264
|
+
)
|
|
265
|
+
def create_user_cmd(
|
|
266
|
+
username: str,
|
|
267
|
+
name: str | None,
|
|
268
|
+
email: str | None,
|
|
269
|
+
group: str | None,
|
|
270
|
+
password: str | None,
|
|
271
|
+
recovery_link: bool,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Create an internal Authentik user (idempotent on username).
|
|
274
|
+
|
|
275
|
+
Defaults to printing a one-shot recovery URL so the user sets their
|
|
276
|
+
own password — pass ``--password`` to skip that and set one inline.
|
|
277
|
+
"""
|
|
278
|
+
if password is not None:
|
|
279
|
+
recovery_link = False
|
|
280
|
+
client = _client()
|
|
281
|
+
try:
|
|
282
|
+
existed_before = client.find_user_by_username(username) is not None
|
|
283
|
+
user = client.create_user(username, name=name, email=email)
|
|
284
|
+
click.echo(
|
|
285
|
+
f"User {username} {'already exists' if existed_before else 'created'} (pk={user['pk']})"
|
|
286
|
+
)
|
|
287
|
+
if group:
|
|
288
|
+
client.add_user_to_group(username, group)
|
|
289
|
+
click.echo(f"Added {username} to {group}")
|
|
290
|
+
if password is not None:
|
|
291
|
+
client.set_user_password(username, password)
|
|
292
|
+
click.echo(f"Password set for {username}")
|
|
293
|
+
if recovery_link:
|
|
294
|
+
link = client.generate_recovery_link(username)
|
|
295
|
+
click.echo("")
|
|
296
|
+
click.echo("Recovery link (single-use, share over a secure channel):")
|
|
297
|
+
click.echo(f" {link}")
|
|
298
|
+
except AuthentikError as exc:
|
|
299
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
300
|
+
sys.exit(1)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@authentik.command("recovery-link")
|
|
304
|
+
@click.argument("username")
|
|
305
|
+
def recovery_link_cmd(username: str) -> None:
|
|
306
|
+
"""Mint a one-shot recovery URL the user opens to set a password."""
|
|
307
|
+
client = _client()
|
|
308
|
+
try:
|
|
309
|
+
link = client.generate_recovery_link(username)
|
|
310
|
+
except AuthentikError as exc:
|
|
311
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
312
|
+
sys.exit(1)
|
|
313
|
+
click.echo(link)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@authentik.command("set-password")
|
|
317
|
+
@click.argument("username")
|
|
318
|
+
@click.option("--password", required=True, help="New password to set.")
|
|
319
|
+
def set_password_cmd(username: str, password: str) -> None:
|
|
320
|
+
"""Set a user's password directly (admin reset)."""
|
|
321
|
+
client = _client()
|
|
322
|
+
try:
|
|
323
|
+
client.set_user_password(username, password)
|
|
324
|
+
except AuthentikError as exc:
|
|
325
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
326
|
+
sys.exit(1)
|
|
327
|
+
click.echo(f"Password updated for {username}")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@authentik.command("delete-user")
|
|
331
|
+
@click.argument("username")
|
|
332
|
+
@click.confirmation_option(prompt="Really delete this Authentik user?")
|
|
333
|
+
def delete_user_cmd(username: str) -> None:
|
|
334
|
+
"""Delete an Authentik user. No-op if the user doesn't exist."""
|
|
335
|
+
client = _client()
|
|
336
|
+
try:
|
|
337
|
+
client.delete_user(username)
|
|
338
|
+
except AuthentikError as exc:
|
|
339
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
click.echo(f"User {username} deleted (or already absent)")
|
|
342
|
+
|
|
343
|
+
|
|
206
344
|
@authentik.command("api")
|
|
207
345
|
@click.argument("method")
|
|
208
346
|
@click.argument("path")
|
|
@@ -213,11 +213,11 @@ def deploy(
|
|
|
213
213
|
"""Zip ``--source-dir``, upload, sync env vars, and deploy.
|
|
214
214
|
|
|
215
215
|
Example:
|
|
216
|
-
granny serverless deploy
|
|
216
|
+
granny serverless deploy my-api \\
|
|
217
217
|
--source-dir backend/dist-bundle \\
|
|
218
|
-
--namespace
|
|
218
|
+
--namespace my-app \\
|
|
219
219
|
--env NODE_ENV=production \\
|
|
220
|
-
--secret
|
|
220
|
+
--secret API_TOKEN=$API_TOKEN
|
|
221
221
|
"""
|
|
222
222
|
c = _client(region)
|
|
223
223
|
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"""Authentik management — REST API wrapper + per-stage stoz3n-dash provisioning.
|
|
2
|
-
|
|
3
|
-
Powered by the ``AUTHENTIK_API_TOKEN`` secret (resolves via env var or
|
|
4
|
-
Vaultwarden under ``granny/infra/authentik-api-token``) and the
|
|
5
|
-
non-secret ``AUTHENTIK_URL`` (default ``https://auth.pseekoo.io``).
|
|
6
|
-
|
|
7
|
-
Public API::
|
|
8
|
-
|
|
9
|
-
from granny.authentik import AuthentikClient, STOZ3N_DASH_STAGES, provision_stoz3n_dash
|
|
10
|
-
|
|
11
|
-
client = AuthentikClient.from_environment()
|
|
12
|
-
result = provision_stoz3n_dash(client, stage="development")
|
|
13
|
-
print(result.client_id, result.client_secret)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from granny.authentik.client import AuthentikClient, AuthentikError
|
|
17
|
-
from granny.authentik.provision import (
|
|
18
|
-
ADMIN_GROUP_NAME,
|
|
19
|
-
OIDC_SCOPES,
|
|
20
|
-
STOZ3N_DASH_STAGES,
|
|
21
|
-
ProvisionResult,
|
|
22
|
-
provision_stoz3n_dash,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
__all__ = [
|
|
26
|
-
"ADMIN_GROUP_NAME",
|
|
27
|
-
"AuthentikClient",
|
|
28
|
-
"AuthentikError",
|
|
29
|
-
"OIDC_SCOPES",
|
|
30
|
-
"ProvisionResult",
|
|
31
|
-
"STOZ3N_DASH_STAGES",
|
|
32
|
-
"provision_stoz3n_dash",
|
|
33
|
-
]
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
"""Per-stage stoz3n-dash app provisioning in Authentik.
|
|
2
|
-
|
|
3
|
-
The :data:`STOZ3N_DASH_STAGES` map defines the slug + redirect_uri + launch
|
|
4
|
-
URL for each environment. Keep this synchronised with the canonical doc at
|
|
5
|
-
``stoz3n-agent-dash/docs/authentik-sso.md``.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
|
|
12
|
-
from granny.authentik.client import AuthentikClient
|
|
13
|
-
|
|
14
|
-
STOZ3N_DASH_STAGES: dict[str, dict[str, str]] = {
|
|
15
|
-
"development": {
|
|
16
|
-
"slug": "stoz3n-dash-dev",
|
|
17
|
-
"name": "Stoz3n Dashboard (dev)",
|
|
18
|
-
"redirect_uri": "http://localhost:8000/api/auth/oidc/callback",
|
|
19
|
-
"launch_url": "http://localhost:5173",
|
|
20
|
-
},
|
|
21
|
-
"staging": {
|
|
22
|
-
"slug": "stoz3n-dash-staging",
|
|
23
|
-
"name": "Stoz3n Dashboard (staging)",
|
|
24
|
-
"redirect_uri": "https://facts-dev.stozn.ai/api/auth/oidc/callback",
|
|
25
|
-
"launch_url": "https://dash-dev.stozn.ai",
|
|
26
|
-
},
|
|
27
|
-
"production": {
|
|
28
|
-
"slug": "stoz3n-dash",
|
|
29
|
-
"name": "Stoz3n Dashboard",
|
|
30
|
-
"redirect_uri": "https://facts.stozn.ai/api/auth/oidc/callback",
|
|
31
|
-
"launch_url": "https://dash.stozn.ai",
|
|
32
|
-
},
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
ADMIN_GROUP_NAME = "dash_admins"
|
|
36
|
-
AUTHORIZATION_FLOW_SLUG = "default-provider-authorization-explicit-consent"
|
|
37
|
-
INVALIDATION_FLOW_SLUG = "default-provider-invalidation-flow"
|
|
38
|
-
OIDC_SCOPES: tuple[str, ...] = ("openid", "profile", "email")
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass
|
|
42
|
-
class ProvisionResult:
|
|
43
|
-
"""Outcome of :func:`provision_stoz3n_dash`."""
|
|
44
|
-
|
|
45
|
-
stage: str
|
|
46
|
-
base_url: str
|
|
47
|
-
issuer: str
|
|
48
|
-
client_id: str
|
|
49
|
-
client_secret: str # empty string if Authentik didn't echo it back
|
|
50
|
-
redirect_uri: str
|
|
51
|
-
admin_group: str
|
|
52
|
-
scopes: str
|
|
53
|
-
provider_pk: str
|
|
54
|
-
application_slug: str
|
|
55
|
-
group_pk: str
|
|
56
|
-
created_provider: bool
|
|
57
|
-
created_application: bool
|
|
58
|
-
created_binding: bool
|
|
59
|
-
created_group: bool
|
|
60
|
-
|
|
61
|
-
@property
|
|
62
|
-
def authorization_endpoint(self) -> str:
|
|
63
|
-
return f"{self.base_url}/application/o/authorize/"
|
|
64
|
-
|
|
65
|
-
@property
|
|
66
|
-
def token_endpoint(self) -> str:
|
|
67
|
-
return f"{self.base_url}/application/o/token/"
|
|
68
|
-
|
|
69
|
-
@property
|
|
70
|
-
def userinfo_endpoint(self) -> str:
|
|
71
|
-
return f"{self.base_url}/application/o/userinfo/"
|
|
72
|
-
|
|
73
|
-
def chat_agent_oidc_block(self) -> dict[str, dict[str, str]]:
|
|
74
|
-
"""JSON snippet to paste into the chat-agent's encrypted config."""
|
|
75
|
-
return {
|
|
76
|
-
"oidc": {
|
|
77
|
-
"issuer": self.issuer,
|
|
78
|
-
"authorization_endpoint": self.authorization_endpoint,
|
|
79
|
-
"token_endpoint": self.token_endpoint,
|
|
80
|
-
"userinfo_endpoint": self.userinfo_endpoint,
|
|
81
|
-
"client_id": self.client_id,
|
|
82
|
-
"client_secret": self.client_secret
|
|
83
|
-
or "<rerun with --rotate or run granny authentik rotate-secret>",
|
|
84
|
-
"redirect_uri": self.redirect_uri,
|
|
85
|
-
"admin_group": self.admin_group,
|
|
86
|
-
"scopes": self.scopes,
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
def dashboard_oidc_block(self) -> dict[str, dict[str, str]]:
|
|
91
|
-
"""JSON snippet to paste into the dashboard repo's encrypted config."""
|
|
92
|
-
return {
|
|
93
|
-
"oidc": {
|
|
94
|
-
"client_secret": self.client_secret
|
|
95
|
-
or "<rerun with --rotate or run granny authentik rotate-secret>"
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def provision_stoz3n_dash(
|
|
101
|
-
client: AuthentikClient,
|
|
102
|
-
*,
|
|
103
|
-
stage: str,
|
|
104
|
-
rotate_secret: bool = False,
|
|
105
|
-
) -> ProvisionResult:
|
|
106
|
-
"""End-to-end idempotent provisioning of one stoz3n-dashboard stage.
|
|
107
|
-
|
|
108
|
-
Steps:
|
|
109
|
-
|
|
110
|
-
1. Resolve authorization flow + signing key + OIDC scope mappings.
|
|
111
|
-
2. Ensure the ``dash_admins`` group exists.
|
|
112
|
-
3. Create or PATCH the OAuth2 provider.
|
|
113
|
-
4. Create or PATCH the application.
|
|
114
|
-
5. Create the application→group policy binding if absent.
|
|
115
|
-
|
|
116
|
-
Re-running is safe; nothing is destroyed. ``rotate_secret=True`` forces
|
|
117
|
-
a new ``client_secret`` to be minted on an existing provider.
|
|
118
|
-
"""
|
|
119
|
-
if stage not in STOZ3N_DASH_STAGES:
|
|
120
|
-
raise ValueError(
|
|
121
|
-
f"Unknown stage {stage!r}; choose one of {sorted(STOZ3N_DASH_STAGES)}"
|
|
122
|
-
)
|
|
123
|
-
cfg = STOZ3N_DASH_STAGES[stage]
|
|
124
|
-
provider_name = cfg["slug"]
|
|
125
|
-
app_slug = cfg["slug"]
|
|
126
|
-
|
|
127
|
-
# 1. Foreign keys
|
|
128
|
-
authorization_flow = client.find_flow_pk(AUTHORIZATION_FLOW_SLUG)
|
|
129
|
-
invalidation_flow = client.find_flow_pk(INVALIDATION_FLOW_SLUG)
|
|
130
|
-
signing_key = client.find_signing_key_pk()
|
|
131
|
-
scope_mappings = client.find_scope_mappings(OIDC_SCOPES)
|
|
132
|
-
|
|
133
|
-
# 2. Admin group
|
|
134
|
-
existing_group = client.find_group_by_name(ADMIN_GROUP_NAME)
|
|
135
|
-
created_group = existing_group is None
|
|
136
|
-
group = existing_group or client.ensure_group(ADMIN_GROUP_NAME)
|
|
137
|
-
|
|
138
|
-
# 3. Provider — three reconciliation paths:
|
|
139
|
-
# a. App+provider already exist under our slug → adopt them. We honor
|
|
140
|
-
# the existing provider's pk so its client_id stays stable; only
|
|
141
|
-
# rename + reconfigure fields. This is the production path where
|
|
142
|
-
# a pre-existing app may already be live with consumers.
|
|
143
|
-
# b. Provider exists by name (our convention) but no app → PATCH it
|
|
144
|
-
# in place.
|
|
145
|
-
# c. Nothing exists → POST a fresh provider.
|
|
146
|
-
existing_app_for_adoption = client.find_application_by_slug(app_slug)
|
|
147
|
-
adopt_pk: int | str | None = None
|
|
148
|
-
if existing_app_for_adoption is not None:
|
|
149
|
-
adopt_pk = existing_app_for_adoption.get("provider")
|
|
150
|
-
existing_provider = (
|
|
151
|
-
client.find_provider_by_pk(adopt_pk) if adopt_pk is not None else None
|
|
152
|
-
) or client.find_provider_by_name(provider_name)
|
|
153
|
-
|
|
154
|
-
provider_body: dict[str, object] = {
|
|
155
|
-
"name": provider_name,
|
|
156
|
-
"authorization_flow": authorization_flow,
|
|
157
|
-
"invalidation_flow": invalidation_flow,
|
|
158
|
-
"client_type": "confidential",
|
|
159
|
-
"redirect_uris": [{"matching_mode": "strict", "url": cfg["redirect_uri"]}],
|
|
160
|
-
"signing_key": signing_key,
|
|
161
|
-
"property_mappings": scope_mappings,
|
|
162
|
-
"sub_mode": "user_username",
|
|
163
|
-
"include_claims_in_id_token": True,
|
|
164
|
-
"access_token_validity": "minutes=10",
|
|
165
|
-
"refresh_token_validity": "days=30",
|
|
166
|
-
}
|
|
167
|
-
if existing_provider:
|
|
168
|
-
if rotate_secret:
|
|
169
|
-
provider_body["client_secret"] = "" # empty → Authentik regenerates
|
|
170
|
-
provider = client.request(
|
|
171
|
-
"PATCH",
|
|
172
|
-
f"/api/v3/providers/oauth2/{existing_provider['pk']}/",
|
|
173
|
-
body=provider_body,
|
|
174
|
-
)
|
|
175
|
-
created_provider = False
|
|
176
|
-
else:
|
|
177
|
-
provider = client.request("POST", "/api/v3/providers/oauth2/", body=provider_body)
|
|
178
|
-
created_provider = True
|
|
179
|
-
|
|
180
|
-
# 4. Application
|
|
181
|
-
app_body = {
|
|
182
|
-
"name": cfg["name"],
|
|
183
|
-
"slug": app_slug,
|
|
184
|
-
"provider": provider["pk"],
|
|
185
|
-
"meta_launch_url": cfg["launch_url"],
|
|
186
|
-
"policy_engine_mode": "any",
|
|
187
|
-
}
|
|
188
|
-
if existing_app_for_adoption:
|
|
189
|
-
client.request("PATCH", f"/api/v3/core/applications/{app_slug}/", body=app_body)
|
|
190
|
-
created_application = False
|
|
191
|
-
target_pk = existing_app_for_adoption["pk"]
|
|
192
|
-
else:
|
|
193
|
-
new_app = client.request("POST", "/api/v3/core/applications/", body=app_body)
|
|
194
|
-
created_application = True
|
|
195
|
-
target_pk = new_app["pk"]
|
|
196
|
-
|
|
197
|
-
# 5. Group → application policy binding
|
|
198
|
-
bindings = client.request(
|
|
199
|
-
"GET", "/api/v3/policies/bindings/", params={"target": target_pk}
|
|
200
|
-
).get("results", [])
|
|
201
|
-
has_binding = any(b.get("group") == group["pk"] for b in bindings)
|
|
202
|
-
if has_binding:
|
|
203
|
-
created_binding = False
|
|
204
|
-
else:
|
|
205
|
-
client.request(
|
|
206
|
-
"POST",
|
|
207
|
-
"/api/v3/policies/bindings/",
|
|
208
|
-
body={
|
|
209
|
-
"target": target_pk,
|
|
210
|
-
"group": group["pk"],
|
|
211
|
-
"enabled": True,
|
|
212
|
-
"order": 0,
|
|
213
|
-
"negate": False,
|
|
214
|
-
"timeout": 30,
|
|
215
|
-
},
|
|
216
|
-
)
|
|
217
|
-
created_binding = True
|
|
218
|
-
|
|
219
|
-
return ProvisionResult(
|
|
220
|
-
stage=stage,
|
|
221
|
-
base_url=client.base_url,
|
|
222
|
-
issuer=f"{client.base_url}/application/o/{app_slug}",
|
|
223
|
-
client_id=provider["client_id"],
|
|
224
|
-
client_secret=provider.get("client_secret") or "",
|
|
225
|
-
redirect_uri=cfg["redirect_uri"],
|
|
226
|
-
admin_group=ADMIN_GROUP_NAME,
|
|
227
|
-
scopes=" ".join(OIDC_SCOPES),
|
|
228
|
-
provider_pk=str(provider["pk"]),
|
|
229
|
-
application_slug=app_slug,
|
|
230
|
-
group_pk=str(group["pk"]),
|
|
231
|
-
created_provider=created_provider,
|
|
232
|
-
created_application=created_application,
|
|
233
|
-
created_binding=created_binding,
|
|
234
|
-
created_group=created_group,
|
|
235
|
-
)
|
|
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
|
|
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
|
|
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
|
|
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
|