granny-devops 0.9.0__tar.gz → 0.9.2__tar.gz

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