granny-devops 0.8.0__tar.gz → 0.9.0__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 (82) hide show
  1. {granny_devops-0.8.0 → granny_devops-0.9.0}/PKG-INFO +9 -1
  2. {granny_devops-0.8.0 → granny_devops-0.9.0}/README.md +8 -0
  3. granny_devops-0.9.0/granny/authentik/__init__.py +33 -0
  4. granny_devops-0.9.0/granny/authentik/client.py +258 -0
  5. granny_devops-0.9.0/granny/authentik/provision.py +235 -0
  6. granny_devops-0.9.0/granny/cli/authentik.py +229 -0
  7. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/main.py +5 -0
  8. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/credentials/secrets.py +4 -0
  9. {granny_devops-0.8.0 → granny_devops-0.9.0}/pyproject.toml +127 -127
  10. {granny_devops-0.8.0 → granny_devops-0.9.0}/.gitignore +0 -0
  11. {granny_devops-0.8.0 → granny_devops-0.9.0}/LICENSE +0 -0
  12. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/__init__.py +0 -0
  13. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/__init__.py +0 -0
  14. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/costs.py +0 -0
  15. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/credits.py +0 -0
  16. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/gpus.py +0 -0
  17. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/lambdas.py +0 -0
  18. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/vpcs.py +0 -0
  19. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cdn/__init__.py +0 -0
  20. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cdn/bunny.py +0 -0
  21. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/__init__.py +0 -0
  22. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/analyze.py +0 -0
  23. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/cdn.py +0 -0
  24. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/cloudflare.py +0 -0
  25. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/create.py +0 -0
  26. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/credentials.py +0 -0
  27. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/dns.py +0 -0
  28. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/docker.py +0 -0
  29. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/edge.py +0 -0
  30. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/email.py +0 -0
  31. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/serverless.py +0 -0
  32. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/storage.py +0 -0
  33. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/__init__.py +0 -0
  34. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/d1.py +0 -0
  35. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/r2.py +0 -0
  36. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/workers.py +0 -0
  37. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/__init__.py +0 -0
  38. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/auto_certificate.py +0 -0
  39. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/cloudfront-security-headers.js +0 -0
  40. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/manage-dns.sh +0 -0
  41. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/manage_mailjet_contacts.py +0 -0
  42. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/registrars.py +0 -0
  43. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_aws_cloudfront.py +0 -0
  44. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_bunny_edge_script.py +0 -0
  45. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_bunny_storage.py +0 -0
  46. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_cognito_identity_pool.py +0 -0
  47. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_hetzner_bunny.py +0 -0
  48. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_mailjet_dns.py +0 -0
  49. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_private_cdn.py +0 -0
  50. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_s3_website.py +0 -0
  51. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_container.py +0 -0
  52. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_faas.py +0 -0
  53. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_workmail.py +0 -0
  54. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/www-redirect-function.js +0 -0
  55. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/credentials/__init__.py +0 -0
  56. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/__init__.py +0 -0
  57. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/base.py +0 -0
  58. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/bunny.py +0 -0
  59. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/cloudflare.py +0 -0
  60. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/cloudns.py +0 -0
  61. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/desec.py +0 -0
  62. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/factory.py +0 -0
  63. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/hetzner.py +0 -0
  64. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/inwx.py +0 -0
  65. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/manual.py +0 -0
  66. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/records.py +0 -0
  67. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/docker/__init__.py +0 -0
  68. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/docker/build_base.py +0 -0
  69. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/edge/__init__.py +0 -0
  70. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/edge/bunny.py +0 -0
  71. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/__init__.py +0 -0
  72. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/mailjet.py +0 -0
  73. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/mailjet_contacts.py +0 -0
  74. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/ses_forwarding.py +0 -0
  75. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/workmail.py +0 -0
  76. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/report.py +0 -0
  77. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/serverless/__init__.py +0 -0
  78. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/serverless/scaleway.py +0 -0
  79. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/__init__.py +0 -0
  80. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/aws.py +0 -0
  81. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/bunny.py +0 -0
  82. {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/hetzner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: granny-devops
3
- Version: 0.8.0
3
+ Version: 0.9.0
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
@@ -286,6 +286,13 @@ granny email workmail create-user example.com --email user@example.com
286
286
  granny create s3-website example.com --help
287
287
  granny create scaleway-container --name my-app --port 3000
288
288
  granny create mailjet-dns example.com
289
+
290
+ # Authentik admin (provider + application + group plumbing)
291
+ granny authentik provision-stoz3n-dash development # idempotent per-stage setup
292
+ granny authentik list providers
293
+ granny authentik rotate-secret stoz3n-dash-staging
294
+ granny authentik add-user-to-group martin # defaults to dash_admins
295
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
289
296
  ```
290
297
 
291
298
  ## Capability matrix
@@ -302,6 +309,7 @@ granny create mailjet-dns example.com
302
309
  | AWS inventory | VPCs, Lambdas |
303
310
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
304
311
  | SSL automation | Bunny, Cloudflare, ACM |
312
+ | SSO / IdP | Authentik (provider + application + group provisioning, per-stage stoz3n-dash workflow) |
305
313
 
306
314
  ## As a library
307
315
 
@@ -154,6 +154,13 @@ granny email workmail create-user example.com --email user@example.com
154
154
  granny create s3-website example.com --help
155
155
  granny create scaleway-container --name my-app --port 3000
156
156
  granny create mailjet-dns example.com
157
+
158
+ # Authentik admin (provider + application + group plumbing)
159
+ granny authentik provision-stoz3n-dash development # idempotent per-stage setup
160
+ granny authentik list providers
161
+ granny authentik rotate-secret stoz3n-dash-staging
162
+ granny authentik add-user-to-group martin # defaults to dash_admins
163
+ granny authentik api GET /api/v3/core/users/me/ # generic escape hatch
157
164
  ```
158
165
 
159
166
  ## Capability matrix
@@ -170,6 +177,7 @@ granny create mailjet-dns example.com
170
177
  | AWS inventory | VPCs, Lambdas |
171
178
  | Cross-cloud inventory | GPU instances + reservations, credit balances, MTD spend + forecast (AWS, GCP, Azure) |
172
179
  | SSL automation | Bunny, Cloudflare, ACM |
180
+ | SSO / IdP | Authentik (provider + application + group provisioning, per-stage stoz3n-dash workflow) |
173
181
 
174
182
  ## As a library
175
183
 
@@ -0,0 +1,33 @@
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
+ ]
@@ -0,0 +1,258 @@
1
+ """Thin wrapper around the Authentik REST API.
2
+
3
+ Uses ``requests`` (already a granny core dependency) so this module does
4
+ not require any optional extras.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ import requests
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AuthentikError(RuntimeError):
18
+ """Raised on non-2xx responses from the Authentik API."""
19
+
20
+ def __init__(self, status: int, body: str) -> None:
21
+ super().__init__(f"HTTP {status}: {body[:400]}")
22
+ self.status = status
23
+ self.body = body
24
+
25
+
26
+ class AuthentikClient:
27
+ """Authenticated client for the Authentik v3 REST API.
28
+
29
+ Construct directly when you already have the credentials, or use
30
+ :meth:`from_environment` to resolve via granny's normal env/vault
31
+ pipeline.
32
+ """
33
+
34
+ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0) -> None:
35
+ self.base_url = base_url.rstrip("/")
36
+ self._token = token
37
+ self._timeout = timeout
38
+ self._session = requests.Session()
39
+ self._session.headers.update(
40
+ {
41
+ "Authorization": f"Bearer {token}",
42
+ "Accept": "application/json",
43
+ }
44
+ )
45
+
46
+ @classmethod
47
+ def from_environment(cls) -> "AuthentikClient":
48
+ """Build a client from env vars + Vaultwarden fallback.
49
+
50
+ Resolution order for the token (explicit env always wins so a
51
+ rotated value in ``.env`` is picked up immediately without having
52
+ to invalidate the vault session cache):
53
+
54
+ 1. ``AK_TOKEN`` (env / .env / .deploy.env) — short alias matching
55
+ the standalone Authentik scripts.
56
+ 2. ``AUTHENTIK_API_TOKEN`` (env / .env / .deploy.env or
57
+ Vaultwarden at ``granny/infra/authentik-api-token``, via the
58
+ normal :func:`granny.credentials.get_secret` chain).
59
+
60
+ For the base URL, ``AUTHENTIK_URL`` wins; default
61
+ ``https://auth.pseekoo.io``.
62
+ """
63
+ import os
64
+
65
+ from granny.credentials import get_secret
66
+
67
+ token = os.environ.get("AK_TOKEN") or get_secret("AUTHENTIK_API_TOKEN")
68
+ if not token:
69
+ raise AuthentikError(
70
+ 0,
71
+ "AUTHENTIK_API_TOKEN (or AK_TOKEN) is not set. "
72
+ "Either export it, add it to .env/.deploy.env, or push the "
73
+ "token to Vaultwarden under granny/infra/authentik-api-token "
74
+ "and install the [vault] extra.",
75
+ )
76
+ base_url = os.environ.get("AUTHENTIK_URL") or "https://auth.pseekoo.io"
77
+ return cls(base_url=base_url, token=token)
78
+
79
+ # ── HTTP plumbing ────────────────────────────────────────────────────
80
+
81
+ def request(
82
+ self,
83
+ method: str,
84
+ path: str,
85
+ *,
86
+ body: Any = None,
87
+ params: dict[str, Any] | None = None,
88
+ ) -> Any:
89
+ """Execute one API request and return the decoded JSON body.
90
+
91
+ Returns ``None`` on 204 / empty bodies.
92
+ """
93
+ if not path.startswith("/"):
94
+ path = "/" + path
95
+ url = self.base_url + path
96
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
97
+ resp = self._session.request(
98
+ method=method.upper(),
99
+ url=url,
100
+ params=clean_params or None,
101
+ json=body,
102
+ timeout=self._timeout,
103
+ )
104
+ if resp.status_code >= 400:
105
+ raise AuthentikError(resp.status_code, resp.text)
106
+ if not resp.content:
107
+ return None
108
+ try:
109
+ return resp.json()
110
+ except ValueError:
111
+ return resp.text
112
+
113
+ def paginate(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
114
+ """Walk an Authentik list endpoint, returning all pages concatenated."""
115
+ out: list[dict[str, Any]] = []
116
+ page = 1
117
+ while True:
118
+ p = dict(params or {})
119
+ p["page"] = page
120
+ data = self.request("GET", path, params=p) or {}
121
+ out.extend(data.get("results", []))
122
+ if not data.get("pagination", {}).get("next"):
123
+ break
124
+ page += 1
125
+ return out
126
+
127
+ # ── High-level lookups ───────────────────────────────────────────────
128
+
129
+ def find_flow_pk(self, slug: str) -> str:
130
+ results = self.request("GET", "/api/v3/flows/instances/", params={"slug": slug}).get(
131
+ "results", []
132
+ )
133
+ for f in results:
134
+ if f["slug"] == slug:
135
+ return f["pk"]
136
+ raise AuthentikError(404, f"No flow with slug={slug!r}")
137
+
138
+ def find_signing_key_pk(self, *, prefer_self_signed: bool = True) -> str:
139
+ """Return the pk of a key pair that can sign tokens."""
140
+ keys = self.paginate("/api/v3/crypto/certificatekeypairs/")
141
+ candidates = [k for k in keys if k.get("private_key_available")]
142
+ if not candidates:
143
+ raise AuthentikError(404, "No usable signing key pairs found")
144
+ if prefer_self_signed:
145
+ for k in candidates:
146
+ if "self-signed" in k["name"].lower():
147
+ return k["pk"]
148
+ return candidates[0]["pk"]
149
+
150
+ def find_scope_mappings(self, scope_names: tuple[str, ...] | list[str]) -> list[str]:
151
+ """Resolve scope-property-mapping pks by ``scope_name``."""
152
+ mappings = self.paginate("/api/v3/propertymappings/provider/scope/")
153
+ by_scope = {m["scope_name"]: m["pk"] for m in mappings}
154
+ try:
155
+ return [by_scope[scope] for scope in scope_names]
156
+ except KeyError as exc:
157
+ raise AuthentikError(404, f"Scope mapping for {exc.args[0]!r} not found") from exc
158
+
159
+ def find_group_by_name(self, name: str) -> dict[str, Any] | None:
160
+ for g in self.request("GET", "/api/v3/core/groups/", params={"name": name}).get(
161
+ "results", []
162
+ ):
163
+ if g["name"] == name:
164
+ return g
165
+ return None
166
+
167
+ def find_user_by_username(self, username: str) -> dict[str, Any] | None:
168
+ for u in self.request("GET", "/api/v3/core/users/", params={"username": username}).get(
169
+ "results", []
170
+ ):
171
+ if u["username"] == username:
172
+ return u
173
+ return None
174
+
175
+ def find_provider_by_name(self, name: str) -> dict[str, Any] | None:
176
+ for p in self.request("GET", "/api/v3/providers/oauth2/", params={"name": name}).get(
177
+ "results", []
178
+ ):
179
+ if p["name"] == name:
180
+ return p
181
+ return None
182
+
183
+ def find_application_by_slug(self, slug: str) -> dict[str, Any] | None:
184
+ """Look up an application by slug.
185
+
186
+ Uses a direct GET on the slug-keyed detail endpoint instead of the
187
+ list endpoint. Authentik's application list quietly hides records
188
+ whose policy bindings restrict ``view`` permissions, even for
189
+ superusers — direct GET by slug bypasses that filter.
190
+ """
191
+ try:
192
+ return self.request("GET", f"/api/v3/core/applications/{slug}/")
193
+ except AuthentikError as exc:
194
+ if exc.status == 404:
195
+ return None
196
+ raise
197
+
198
+ def find_provider_by_pk(self, pk: int | str) -> dict[str, Any] | None:
199
+ """Look up an OAuth2 provider by primary key (integer)."""
200
+ try:
201
+ return self.request("GET", f"/api/v3/providers/oauth2/{pk}/")
202
+ except AuthentikError as exc:
203
+ if exc.status == 404:
204
+ return None
205
+ raise
206
+
207
+ # ── Mutating helpers ─────────────────────────────────────────────────
208
+
209
+ def ensure_group(self, name: str) -> dict[str, Any]:
210
+ """Create a group if missing; return the (existing or new) group dict."""
211
+ existing = self.find_group_by_name(name)
212
+ if existing:
213
+ return existing
214
+ return self.request("POST", "/api/v3/core/groups/", body={"name": name})
215
+
216
+ def add_user_to_group(self, username: str, group_name: str) -> None:
217
+ user = self.find_user_by_username(username)
218
+ if not user:
219
+ raise AuthentikError(404, f"User {username!r} not found")
220
+ group = self.find_group_by_name(group_name)
221
+ if not group:
222
+ raise AuthentikError(
223
+ 404, f"Group {group_name!r} not found — create it with ensure_group first"
224
+ )
225
+ self.request(
226
+ "POST",
227
+ f"/api/v3/core/groups/{group['pk']}/add_user/",
228
+ body={"pk": user["pk"]},
229
+ )
230
+
231
+ def remove_user_from_group(self, username: str, group_name: str) -> None:
232
+ user = self.find_user_by_username(username)
233
+ if not user:
234
+ raise AuthentikError(404, f"User {username!r} not found")
235
+ group = self.find_group_by_name(group_name)
236
+ if not group:
237
+ raise AuthentikError(404, f"Group {group_name!r} not found")
238
+ self.request(
239
+ "POST",
240
+ f"/api/v3/core/groups/{group['pk']}/remove_user/",
241
+ body={"pk": user["pk"]},
242
+ )
243
+
244
+ def rotate_client_secret(self, provider_name: str) -> dict[str, Any]:
245
+ """Rotate ``client_secret`` on an existing OAuth2 provider.
246
+
247
+ Authentik regenerates the secret server-side when we PATCH it to
248
+ an empty string. The returned dict carries the new value (when
249
+ the API echoes it back — varies by version).
250
+ """
251
+ provider = self.find_provider_by_name(provider_name)
252
+ if not provider:
253
+ raise AuthentikError(404, f"Provider {provider_name!r} not found")
254
+ return self.request(
255
+ "PATCH",
256
+ f"/api/v3/providers/oauth2/{provider['pk']}/",
257
+ body={"client_secret": ""},
258
+ )
@@ -0,0 +1,235 @@
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
+ )