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.
- {granny_devops-0.8.0 → granny_devops-0.9.0}/PKG-INFO +9 -1
- {granny_devops-0.8.0 → granny_devops-0.9.0}/README.md +8 -0
- granny_devops-0.9.0/granny/authentik/__init__.py +33 -0
- granny_devops-0.9.0/granny/authentik/client.py +258 -0
- granny_devops-0.9.0/granny/authentik/provision.py +235 -0
- granny_devops-0.9.0/granny/cli/authentik.py +229 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/main.py +5 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/credentials/secrets.py +4 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/pyproject.toml +127 -127
- {granny_devops-0.8.0 → granny_devops-0.9.0}/.gitignore +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/LICENSE +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/costs.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/credits.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/gpus.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/lambdas.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/analyze/vpcs.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cdn/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cdn/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/analyze.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/cdn.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/cloudflare.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/create.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/credentials.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/dns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/docker.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/edge.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/email.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/serverless.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cli/storage.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/d1.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/r2.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/cloudflare/workers.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/auto_certificate.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/cloudfront-security-headers.js +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/manage-dns.sh +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/manage_mailjet_contacts.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/registrars.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_aws_cloudfront.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_bunny_edge_script.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_bunny_storage.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_cognito_identity_pool.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_hetzner_bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_mailjet_dns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_private_cdn.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_s3_website.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_container.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_scaleway_faas.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/setup_workmail.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/create/www-redirect-function.js +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/credentials/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/base.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/cloudflare.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/cloudns.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/desec.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/factory.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/hetzner.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/inwx.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/manual.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/dns/records.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/docker/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/docker/build_base.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/edge/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/edge/bunny.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/mailjet.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/mailjet_contacts.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/ses_forwarding.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/email/workmail.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/report.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/serverless/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/serverless/scaleway.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/__init__.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/aws.py +0 -0
- {granny_devops-0.8.0 → granny_devops-0.9.0}/granny/storage/bunny.py +0 -0
- {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.
|
|
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
|
+
)
|