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.
Files changed (83) hide show
  1. {granny_devops-0.9.0 → granny_devops-0.9.1}/PKG-INFO +4 -5
  2. {granny_devops-0.9.0 → granny_devops-0.9.1}/README.md +3 -4
  3. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/__init__.py +1 -1
  4. granny_devops-0.9.1/granny/authentik/__init__.py +8 -0
  5. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/authentik/client.py +79 -0
  6. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/authentik.py +103 -69
  7. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/serverless.py +3 -3
  8. {granny_devops-0.9.0 → granny_devops-0.9.1}/pyproject.toml +1 -1
  9. granny_devops-0.9.0/granny/authentik/__init__.py +0 -33
  10. granny_devops-0.9.0/granny/authentik/provision.py +0 -235
  11. {granny_devops-0.9.0 → granny_devops-0.9.1}/.gitignore +0 -0
  12. {granny_devops-0.9.0 → granny_devops-0.9.1}/LICENSE +0 -0
  13. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/__init__.py +0 -0
  14. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/costs.py +0 -0
  15. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/credits.py +0 -0
  16. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/gpus.py +0 -0
  17. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/lambdas.py +0 -0
  18. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/analyze/vpcs.py +0 -0
  19. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cdn/__init__.py +0 -0
  20. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cdn/bunny.py +0 -0
  21. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/__init__.py +0 -0
  22. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/analyze.py +0 -0
  23. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/cdn.py +0 -0
  24. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/cloudflare.py +0 -0
  25. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/create.py +0 -0
  26. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/credentials.py +0 -0
  27. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/dns.py +0 -0
  28. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/docker.py +0 -0
  29. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/edge.py +0 -0
  30. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/email.py +0 -0
  31. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/main.py +0 -0
  32. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cli/storage.py +0 -0
  33. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/__init__.py +0 -0
  34. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/d1.py +0 -0
  35. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/r2.py +0 -0
  36. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/cloudflare/workers.py +0 -0
  37. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/__init__.py +0 -0
  38. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/auto_certificate.py +0 -0
  39. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/cloudfront-security-headers.js +0 -0
  40. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/manage-dns.sh +0 -0
  41. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/manage_mailjet_contacts.py +0 -0
  42. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/registrars.py +0 -0
  43. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_aws_cloudfront.py +0 -0
  44. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_bunny_edge_script.py +0 -0
  45. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_bunny_storage.py +0 -0
  46. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_cognito_identity_pool.py +0 -0
  47. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_hetzner_bunny.py +0 -0
  48. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_mailjet_dns.py +0 -0
  49. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_private_cdn.py +0 -0
  50. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_s3_website.py +0 -0
  51. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_container.py +0 -0
  52. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_scaleway_faas.py +0 -0
  53. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/setup_workmail.py +0 -0
  54. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/create/www-redirect-function.js +0 -0
  55. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/credentials/__init__.py +0 -0
  56. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/credentials/secrets.py +0 -0
  57. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/__init__.py +0 -0
  58. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/base.py +0 -0
  59. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/bunny.py +0 -0
  60. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/cloudflare.py +0 -0
  61. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/cloudns.py +0 -0
  62. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/desec.py +0 -0
  63. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/factory.py +0 -0
  64. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/hetzner.py +0 -0
  65. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/inwx.py +0 -0
  66. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/manual.py +0 -0
  67. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/dns/records.py +0 -0
  68. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/docker/__init__.py +0 -0
  69. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/docker/build_base.py +0 -0
  70. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/edge/__init__.py +0 -0
  71. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/edge/bunny.py +0 -0
  72. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/__init__.py +0 -0
  73. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/mailjet.py +0 -0
  74. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/mailjet_contacts.py +0 -0
  75. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/ses_forwarding.py +0 -0
  76. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/email/workmail.py +0 -0
  77. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/report.py +0 -0
  78. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/serverless/__init__.py +0 -0
  79. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/serverless/scaleway.py +0 -0
  80. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/__init__.py +0 -0
  81. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/aws.py +0 -0
  82. {granny_devops-0.9.0 → granny_devops-0.9.1}/granny/storage/bunny.py +0 -0
  83. {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.0
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 stoz3n-dash-staging
294
- granny authentik add-user-to-group martin # defaults to dash_admins
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 + application + group provisioning, per-stage stoz3n-dash workflow) |
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 stoz3n-dash-staging
162
- granny authentik add-user-to-group martin # defaults to dash_admins
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 + application + group provisioning, per-stage stoz3n-dash workflow) |
179
+ | SSO / IdP | Authentik (provider, application, group, and user operations) |
181
180
 
182
181
  ## As a library
183
182
 
@@ -1,6 +1,6 @@
1
1
  """Granny -- Cloud tools collection for AWS infrastructure and DevOps automation."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.9.1"
4
4
  __all__ = [
5
5
  "get_secret",
6
6
  "load_secrets_into_env",
@@ -0,0 +1,8 @@
1
+ """Authentik management — REST API wrapper."""
2
+
3
+ from granny.authentik.client import AuthentikClient, AuthentikError
4
+
5
+ __all__ = [
6
+ "AuthentikClient",
7
+ "AuthentikError",
8
+ ]
@@ -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 the pseekoo Authentik instance via API.
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
- ADMIN_GROUP_NAME,
21
- STOZ3N_DASH_STAGES,
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 (auth.pseekoo.io) via its REST API."""
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 bessa-wissa-api \\
216
+ granny serverless deploy my-api \\
217
217
  --source-dir backend/dist-bundle \\
218
- --namespace bessa-wissa \\
218
+ --namespace my-app \\
219
219
  --env NODE_ENV=production \\
220
- --secret STOZ3N_API_TOKEN=$STOZ3N_API_TOKEN
220
+ --secret API_TOKEN=$API_TOKEN
221
221
  """
222
222
  c = _client(region)
223
223
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "granny-devops"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  description = "Cloud tools collection -- AWS infrastructure, CDN, and DevOps automation"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -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