granny-devops 0.9.0__tar.gz → 0.9.1__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.1}/PKG-INFO +4 -5
- {granny_devops-0.9.0 → granny_devops-0.9.1}/README.md +3 -4
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/__init__.py +1 -1
- granny_devops-0.9.1/granny/authentik/__init__.py +8 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/authentik/client.py +79 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/authentik.py +103 -69
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/serverless.py +3 -3
- {granny_devops-0.9.0 → granny_devops-0.9.1}/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.1}/.gitignore +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/LICENSE +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/costs.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/credits.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/analyze.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/cdn.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/create.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/credentials.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/dns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/docker.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/edge.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/email.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/main.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/storage.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/registrars.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/credentials/secrets.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/base.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/desec.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/factory.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/inwx.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/manual.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/records.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/docker/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/docker/build_base.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/edge/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/edge/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/mailjet.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/workmail.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/report.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/__init__.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/aws.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/bunny.py +0 -0
- {granny_devops-0.9.0 → granny_devops-0.9.1}/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.1
|
|
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,9 @@ 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-stoz3n-dash development # idempotent per-stage setup
|
|
292
291
|
granny authentik list providers
|
|
293
|
-
granny authentik rotate-secret
|
|
294
|
-
granny authentik add-user-to-group
|
|
292
|
+
granny authentik rotate-secret my-oauth-provider
|
|
293
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
295
294
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
296
295
|
```
|
|
297
296
|
|
|
@@ -309,7 +308,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
309
308
|
| AWS inventory | VPCs, Lambdas |
|
|
310
309
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
311
310
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
312
|
-
| SSO / IdP | Authentik (provider
|
|
311
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
313
312
|
|
|
314
313
|
## As a library
|
|
315
314
|
|
|
@@ -156,10 +156,9 @@ 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-stoz3n-dash development # idempotent per-stage setup
|
|
160
159
|
granny authentik list providers
|
|
161
|
-
granny authentik rotate-secret
|
|
162
|
-
granny authentik add-user-to-group
|
|
160
|
+
granny authentik rotate-secret my-oauth-provider
|
|
161
|
+
granny authentik add-user-to-group user@example.com # defaults to dash_admins
|
|
163
162
|
granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
164
163
|
```
|
|
165
164
|
|
|
@@ -177,7 +176,7 @@ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
|
|
|
177
176
|
| AWS inventory | VPCs, Lambdas |
|
|
178
177
|
| Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
|
|
179
178
|
| SSL automation | Bunny, Cloudflare, ACM |
|
|
180
|
-
| SSO / IdP | Authentik (provider
|
|
179
|
+
| SSO / IdP | Authentik (provider, application, group, and user operations) |
|
|
181
180
|
|
|
182
181
|
## As a library
|
|
183
182
|
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
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:
|
|
@@ -38,62 +30,6 @@ def _client() -> AuthentikClient:
|
|
|
38
30
|
sys.exit(1)
|
|
39
31
|
|
|
40
32
|
|
|
41
|
-
def _print_provision_report(result: Any) -> None:
|
|
42
|
-
"""Pretty-print a ProvisionResult and the paste-ready JSON snippets."""
|
|
43
|
-
click.echo("", err=True)
|
|
44
|
-
click.echo("─" * 72)
|
|
45
|
-
click.echo(f"Stage : {result.stage}")
|
|
46
|
-
click.echo(f"Authentik URL : {result.base_url}")
|
|
47
|
-
click.echo(f"Issuer : {result.issuer}")
|
|
48
|
-
click.echo(f"client_id : {result.client_id}")
|
|
49
|
-
click.echo(
|
|
50
|
-
f"client_secret : {result.client_secret or '(not echoed — rotate-secret to see)'}"
|
|
51
|
-
)
|
|
52
|
-
click.echo(f"redirect_uri : {result.redirect_uri}")
|
|
53
|
-
click.echo(f"admin_group : {result.admin_group}")
|
|
54
|
-
click.echo(f"scopes : {result.scopes}")
|
|
55
|
-
flags = [
|
|
56
|
-
f"provider={'NEW' if result.created_provider else 'EXISTS'}",
|
|
57
|
-
f"application={'NEW' if result.created_application else 'EXISTS'}",
|
|
58
|
-
f"binding={'NEW' if result.created_binding else 'EXISTS'}",
|
|
59
|
-
f"group={'NEW' if result.created_group else 'EXISTS'}",
|
|
60
|
-
]
|
|
61
|
-
click.echo(f"state : {', '.join(flags)}")
|
|
62
|
-
click.echo("─" * 72)
|
|
63
|
-
click.echo("")
|
|
64
|
-
click.echo(f"# Paste into dotfiles/stoz3n-chat-agent/config.{result.stage}.json")
|
|
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))
|
|
69
|
-
if not result.client_secret:
|
|
70
|
-
click.echo("", err=True)
|
|
71
|
-
click.echo(
|
|
72
|
-
"WARN: Authentik did not echo client_secret in the response. "
|
|
73
|
-
"Run `granny authentik rotate-secret " + result.application_slug
|
|
74
|
-
+ "` to mint a fresh secret you can copy.",
|
|
75
|
-
err=True,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
@authentik.command("provision-stoz3n-dash")
|
|
80
|
-
@click.argument("stage", type=click.Choice(sorted(STOZ3N_DASH_STAGES.keys())))
|
|
81
|
-
@click.option(
|
|
82
|
-
"--rotate",
|
|
83
|
-
is_flag=True,
|
|
84
|
-
help="Force a fresh client_secret if the provider already exists.",
|
|
85
|
-
)
|
|
86
|
-
def provision_stoz3n_dash_cmd(stage: str, rotate: bool) -> None:
|
|
87
|
-
"""End-to-end per-stage stoz3n-dashboard provisioning (idempotent)."""
|
|
88
|
-
client = _client()
|
|
89
|
-
try:
|
|
90
|
-
result = provision_stoz3n_dash(client, stage=stage, rotate_secret=rotate)
|
|
91
|
-
except AuthentikError as exc:
|
|
92
|
-
click.echo(f"Authentik error: {exc}", err=True)
|
|
93
|
-
sys.exit(1)
|
|
94
|
-
_print_provision_report(result)
|
|
95
|
-
|
|
96
|
-
|
|
97
33
|
_LIST_ENDPOINTS = {
|
|
98
34
|
"providers": "/api/v3/providers/oauth2/",
|
|
99
35
|
"applications": "/api/v3/core/applications/",
|
|
@@ -203,6 +139,104 @@ def remove_user_from_group_cmd(user: str, group: str) -> None:
|
|
|
203
139
|
click.echo(f"Removed {user} from {group}")
|
|
204
140
|
|
|
205
141
|
|
|
142
|
+
@authentik.command("create-user")
|
|
143
|
+
@click.argument("username")
|
|
144
|
+
@click.option("--name", default=None, help="Display name (defaults to username).")
|
|
145
|
+
@click.option("--email", default=None, help="Email address.")
|
|
146
|
+
@click.option(
|
|
147
|
+
"--group",
|
|
148
|
+
default=None,
|
|
149
|
+
help="Group to add the new user to after creation (e.g. dash_admins).",
|
|
150
|
+
)
|
|
151
|
+
@click.option(
|
|
152
|
+
"--password",
|
|
153
|
+
default=None,
|
|
154
|
+
help="Set this password directly. Suppresses --recovery-link.",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--recovery-link/--no-recovery-link",
|
|
158
|
+
default=True,
|
|
159
|
+
help="Print a one-shot recovery URL the user opens to set their own password (default: yes).",
|
|
160
|
+
)
|
|
161
|
+
def create_user_cmd(
|
|
162
|
+
username: str,
|
|
163
|
+
name: str | None,
|
|
164
|
+
email: str | None,
|
|
165
|
+
group: str | None,
|
|
166
|
+
password: str | None,
|
|
167
|
+
recovery_link: bool,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Create an internal Authentik user (idempotent on username).
|
|
170
|
+
|
|
171
|
+
Defaults to printing a one-shot recovery URL so the user sets their
|
|
172
|
+
own password — pass ``--password`` to skip that and set one inline.
|
|
173
|
+
"""
|
|
174
|
+
if password is not None:
|
|
175
|
+
recovery_link = False
|
|
176
|
+
client = _client()
|
|
177
|
+
try:
|
|
178
|
+
existed_before = client.find_user_by_username(username) is not None
|
|
179
|
+
user = client.create_user(username, name=name, email=email)
|
|
180
|
+
click.echo(
|
|
181
|
+
f"User {username} {'already exists' if existed_before else 'created'} (pk={user['pk']})"
|
|
182
|
+
)
|
|
183
|
+
if group:
|
|
184
|
+
client.add_user_to_group(username, group)
|
|
185
|
+
click.echo(f"Added {username} to {group}")
|
|
186
|
+
if password is not None:
|
|
187
|
+
client.set_user_password(username, password)
|
|
188
|
+
click.echo(f"Password set for {username}")
|
|
189
|
+
if recovery_link:
|
|
190
|
+
link = client.generate_recovery_link(username)
|
|
191
|
+
click.echo("")
|
|
192
|
+
click.echo("Recovery link (single-use, share over a secure channel):")
|
|
193
|
+
click.echo(f" {link}")
|
|
194
|
+
except AuthentikError as exc:
|
|
195
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@authentik.command("recovery-link")
|
|
200
|
+
@click.argument("username")
|
|
201
|
+
def recovery_link_cmd(username: str) -> None:
|
|
202
|
+
"""Mint a one-shot recovery URL the user opens to set a password."""
|
|
203
|
+
client = _client()
|
|
204
|
+
try:
|
|
205
|
+
link = client.generate_recovery_link(username)
|
|
206
|
+
except AuthentikError as exc:
|
|
207
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
click.echo(link)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@authentik.command("set-password")
|
|
213
|
+
@click.argument("username")
|
|
214
|
+
@click.option("--password", required=True, help="New password to set.")
|
|
215
|
+
def set_password_cmd(username: str, password: str) -> None:
|
|
216
|
+
"""Set a user's password directly (admin reset)."""
|
|
217
|
+
client = _client()
|
|
218
|
+
try:
|
|
219
|
+
client.set_user_password(username, password)
|
|
220
|
+
except AuthentikError as exc:
|
|
221
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
click.echo(f"Password updated for {username}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@authentik.command("delete-user")
|
|
227
|
+
@click.argument("username")
|
|
228
|
+
@click.confirmation_option(prompt="Really delete this Authentik user?")
|
|
229
|
+
def delete_user_cmd(username: str) -> None:
|
|
230
|
+
"""Delete an Authentik user. No-op if the user doesn't exist."""
|
|
231
|
+
client = _client()
|
|
232
|
+
try:
|
|
233
|
+
client.delete_user(username)
|
|
234
|
+
except AuthentikError as exc:
|
|
235
|
+
click.echo(f"Authentik error: {exc}", err=True)
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
click.echo(f"User {username} deleted (or already absent)")
|
|
238
|
+
|
|
239
|
+
|
|
206
240
|
@authentik.command("api")
|
|
207
241
|
@click.argument("method")
|
|
208
242
|
@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
|