Merge remote-tracking branch 'origin/master' into wizard/claude-auth-renew
This commit is contained in:
commit
bc2fbc712c
7 changed files with 0 additions and 564 deletions
|
|
@ -108,31 +108,6 @@ All new users must use an invitation link to register. The invitation-enrollment
|
||||||
|
|
||||||
Group membership is auto-assigned from the invitation's `fixed_data` field. This prevents open registration while maintaining SSO convenience.
|
Group membership is auto-assigned from the invitation's `fixed_data` field. This prevents open registration while maintaining SSO convenience.
|
||||||
|
|
||||||
### TripIt External self-signup (open enrollment, fenced)
|
|
||||||
|
|
||||||
Unlike every other app, **TripIt allows open public self-signup** for people
|
|
||||||
outside the homelab (ADR-0020 in the tripit repo; runbook
|
|
||||||
`docs/runbooks/tripit-external-signup.md`). A dedicated public `tripit-enrollment`
|
|
||||||
flow (email + passkey, no password) creates the account and stamps it into the
|
|
||||||
parentless **`TripIt External`** group. Containment is two-layered:
|
|
||||||
|
|
||||||
- **Forward-auth apps**: a branch prepended to the `admin-services-restriction`
|
|
||||||
catch-all policy admits `TripIt External` to `tripit.viktorbarzin.me` only and
|
|
||||||
denies every other `auth="required"` host.
|
|
||||||
- **OIDC apps**: that branch does NOT cover OIDC (OIDC bypasses forward-auth).
|
|
||||||
External users are contained because every sensitive OIDC app already requires a
|
|
||||||
trusted group they do not hold — audited 2026-06-15:
|
|
||||||
Immich/Grafana/Linkwarden/Cloudflare Access → `Home Server Admins`, Forgejo →
|
|
||||||
`Task Submitters`/`Forgejo Users`, Headscale → `Headscale Users`, wrongmove →
|
|
||||||
`Wrongmove Users`. **Vault** was OPEN (any OIDC identity got a powerless
|
|
||||||
`default`-policy token) and is bound to **`Allow Login Users`** as part of this
|
|
||||||
change. The Kubernetes OIDC clients are OPEN but idle (apiserver rejects OIDC).
|
|
||||||
|
|
||||||
**Invariants**: keep `TripIt External` parentless (never under `Allow Login
|
|
||||||
Users`); keep the catch-all branch first; never co-assign `TripIt External` to a
|
|
||||||
trusted/internal user; the `tripit-enrollment` user_write "Create users group"
|
|
||||||
setting is the keystone that tags every signup.
|
|
||||||
|
|
||||||
### OIDC Applications
|
### OIDC Applications
|
||||||
|
|
||||||
Authentik provides OIDC for 10 applications:
|
Authentik provides OIDC for 10 applications:
|
||||||
|
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
# Runbook — TripIt external user self-signup (email + passkey)
|
|
||||||
|
|
||||||
Implements ADR-0020 (tripit repo): people outside the homelab self-register to
|
|
||||||
TripIt with **email + a passkey** (no password), are auto-tagged into the
|
|
||||||
**`TripIt External`** Authentik group, and are fenced to `tripit.viktorbarzin.me`
|
|
||||||
only. Audience: people Viktor knows; open public registration.
|
|
||||||
|
|
||||||
> **Safety model.** Containment is two-layered. (1) **Forward-auth apps** — the
|
|
||||||
> branch in `stacks/authentik/admin-services-restriction.tf` admits `TripIt
|
|
||||||
> External` to `tripit.viktorbarzin.me` and denies every other `auth="required"`
|
|
||||||
> host. (2) **OIDC apps** — the branch does NOT cover OIDC (it bypasses
|
|
||||||
> forward-auth); External users are contained because every sensitive OIDC app
|
|
||||||
> already requires a trusted group they do not hold (audit below). The no-lockout
|
|
||||||
> guarantee is that the group is created **empty**, so the new branch matches
|
|
||||||
> zero existing users on day one.
|
|
||||||
|
|
||||||
## OIDC app authorization audit (2026-06-15, read-only)
|
|
||||||
|
|
||||||
A parentless `TripIt External` user holds NONE of these groups, so:
|
|
||||||
|
|
||||||
| OIDC app | Requires | External user |
|
|
||||||
|---|---|---|
|
|
||||||
| Immich, Grafana, Linkwarden, Cloudflare Access | `Home Server Admins` | DENIED ✓ |
|
|
||||||
| Forgejo | `Task Submitters` / `Forgejo Users` | DENIED ✓ |
|
|
||||||
| Headscale | `Headscale Users` | DENIED ✓ |
|
|
||||||
| wrongmove | `Wrongmove Users` | DENIED ✓ |
|
|
||||||
| **Vault** | **was OPEN** → bound to `Allow Login Users` in Step 3 | DENIED after Step 3 |
|
|
||||||
| Kubernetes, Kubernetes Dashboard | OPEN | harmless — apiserver rejects OIDC tokens (idle) |
|
|
||||||
| TripIt App, Public | OPEN | by design (TripIt's own provider / guest) |
|
|
||||||
|
|
||||||
Vault's JWT `default` role grants only Vault's built-in `default` policy (token
|
|
||||||
self-management, cubbyhole — **no** secret access), so the pre-fix exposure was a
|
|
||||||
near-powerless token; Step 3 closes it anyway.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pre-flight gates (STOP if any fails)
|
|
||||||
|
|
||||||
1. **`TripIt External` is net-new / empty** (no-lockout precondition):
|
|
||||||
```
|
|
||||||
kubectl -n authentik exec -i deploy/goauthentik-server -- ak shell <<'PY'
|
|
||||||
from authentik.core.models import Group
|
|
||||||
g = Group.objects.filter(name="TripIt External").first()
|
|
||||||
print("exists:", bool(g), "members:", g.users.count() if g else 0)
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
Expect `exists: False`. If it exists with members → STOP.
|
|
||||||
2. **Authentik image pin matches live (B5)** — the policy edit auto-applies the
|
|
||||||
whole `authentik` stack; a stale pin re-triggers the 2026-06-10 downgrade
|
|
||||||
boot-storm:
|
|
||||||
```
|
|
||||||
kubectl -n authentik get deploy -o custom-columns=N:.metadata.name,IMG:.spec.template.spec.containers[0].image
|
|
||||||
```
|
|
||||||
Every `goauthentik`/`ak-outpost` image tag MUST equal
|
|
||||||
`stacks/authentik/modules/authentik/values.yaml` `global.image.tag`
|
|
||||||
(currently `2026.2.4`). If they differ → refresh the pin first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Terraform (group + fence branch)
|
|
||||||
|
|
||||||
Already written on this branch:
|
|
||||||
- `stacks/authentik/tripit-external.tf` — the empty, parentless group.
|
|
||||||
- `stacks/authentik/admin-services-restriction.tf` — the prepended fence branch.
|
|
||||||
|
|
||||||
**Local plan gate (B4 — CI auto-applies on push with `-auto-approve`, so there is
|
|
||||||
NO human plan review in the apply path; do it here):**
|
|
||||||
```
|
|
||||||
vault login -method=oidc
|
|
||||||
cd stacks/authentik && ../../scripts/tg plan
|
|
||||||
```
|
|
||||||
Confirm the plan is **exactly**:
|
|
||||||
- `+ authentik_group.tripit_external` (create)
|
|
||||||
- `~ authentik_policy_expression.admin_services_restriction` (update in place — the
|
|
||||||
`expression` body gains ONLY the new branch; every other line byte-identical)
|
|
||||||
- **`Plan: 1 to add, 1 to change, 0 to destroy.`**
|
|
||||||
|
|
||||||
ABORT if the plan shows any destroy/replace, any `authentik_provider_*` /
|
|
||||||
`authentik_outpost` / `authentik_flow*` / `helm_release`, or any other expression
|
|
||||||
change.
|
|
||||||
|
|
||||||
**Apply** (presence-claim courtesy, then push = apply; land human-watched, B5):
|
|
||||||
```
|
|
||||||
~/code/scripts/presence claim stack:authentik --purpose "ADR-0020 TripIt External group + fence branch"
|
|
||||||
# push the branch to master (this triggers CI tg apply on the authentik stack)
|
|
||||||
```
|
|
||||||
Watch: GHA → Woodpecker `default.yml` apply → outpost stays healthy
|
|
||||||
(`kubectl -n authentik get endpoints ak-outpost-authentik-embedded-outpost` = 2
|
|
||||||
IPs; an anonymous request to any `auth=required` host still 302s to Authentik).
|
|
||||||
The branch is inert (empty group) so no access changes yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Authentik SMTP (B1, BLOCKER before any flow)
|
|
||||||
|
|
||||||
Email verification is the **entire identity boundary** (TripIt trusts the
|
|
||||||
Authentik email verbatim). Authentik currently has the **default/unconfigured**
|
|
||||||
transport (`email.host = localhost`), so verification/recovery mail cannot send.
|
|
||||||
|
|
||||||
Add to **both** `server.env` and `worker.env` in
|
|
||||||
`stacks/authentik/modules/authentik/values.yaml` (wire the password from a secret;
|
|
||||||
the cluster mailserver is what TripIt already relays through —
|
|
||||||
`mailserver.mailserver.svc`):
|
|
||||||
```yaml
|
|
||||||
- { name: AUTHENTIK_EMAIL__HOST, value: "mailserver.mailserver.svc" }
|
|
||||||
- { name: AUTHENTIK_EMAIL__PORT, value: "587" }
|
|
||||||
- { name: AUTHENTIK_EMAIL__USE_TLS, value: "true" }
|
|
||||||
- { name: AUTHENTIK_EMAIL__FROM, value: "noreply@viktorbarzin.me" }
|
|
||||||
- { name: AUTHENTIK_EMAIL__USERNAME, value: "<relay user>" } # confirm relay creds
|
|
||||||
- { name: AUTHENTIK_EMAIL__PASSWORD, valueFrom: { secretKeyRef: { name: <secret>, key: <key> } } }
|
|
||||||
```
|
|
||||||
**Gate:** after apply, Authentik UI → System → Settings (or an Email stage) →
|
|
||||||
**Send test email**; it must arrive. Then prove enrollment cannot complete for an
|
|
||||||
address you do NOT control.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Bind Vault → `Allow Login Users` (close the one open OIDC gap)
|
|
||||||
|
|
||||||
Authentik UI → Applications → **Vault** → bind an authorization policy requiring
|
|
||||||
group **`Allow Login Users`** (the base group every real homelab user inherits;
|
|
||||||
parentless `TripIt External` is excluded). This changes nothing for existing
|
|
||||||
users and denies External users at the Vault consent step.
|
|
||||||
Verify: an External test account (Step 6) cannot complete Vault OIDC login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4 — Build the flows (Authentik UI; UI-managed per ADR split)
|
|
||||||
|
|
||||||
All three flows: designation as noted, no password stage.
|
|
||||||
|
|
||||||
**Flow `tripit-enrollment`** (Enrollment):
|
|
||||||
| Order | Stage | Key settings |
|
|
||||||
|---|---|---|
|
|
||||||
| 5 | Captcha | reCAPTCHA **v2 checkbox** keys (v3/invisible fail — see `crowdsec-recaptcha-key-type`) |
|
|
||||||
| 10 | Identification | email only; **no** `password_stage`; `sources` optional |
|
|
||||||
| 20 | Email (verification) | activate, blocking — **before** user_write |
|
|
||||||
| 30 | WebAuthn authenticator setup | `user_verification = required`, `resident_key = required` |
|
|
||||||
| 40 | User Write | **`create_users_group` = `TripIt External`** (the keystone tag); `user_type = external` |
|
|
||||||
| 50 | User Login | session as default (`weeks=4`) |
|
|
||||||
|
|
||||||
**Flow `tripit-login`** (Authentication, passwordless):
|
|
||||||
Identification (sets `enrollment_flow`/`recovery_flow`) → Authenticator
|
|
||||||
Validation (`device_classes = [webauthn]`, `user_verification = required`) → User
|
|
||||||
Login. Prefer routing a passkey-less email to recovery over minting a credential.
|
|
||||||
|
|
||||||
**Flow `tripit-recovery`** (Recovery):
|
|
||||||
Identification (`pretend_user_exists = on`) → Email (recovery link) → WebAuthn
|
|
||||||
authenticator setup → User Login. Notify the account on recovery + new-passkey.
|
|
||||||
|
|
||||||
> Do **NOT** bind the `brute-force-protection` ReputationPolicy to these flows —
|
|
||||||
> it denies anonymous users (2026-04-06 regression). The Captcha is the bot gate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — Surface "Sign up"
|
|
||||||
|
|
||||||
Recommended: a **TripIt-scoped** signup link / share-invite rather than a global
|
|
||||||
login-screen button (narrower bot surface). Enrollment URL:
|
|
||||||
`https://authentik.viktorbarzin.me/if/flow/tripit-enrollment/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6 — Verification (before/after — "all access keeps working")
|
|
||||||
|
|
||||||
Hosts for the matrix (must be real `auth="required"` default-allow hosts, NOT
|
|
||||||
`auth="app"` apps like immich/nextcloud which bypass the catch-all):
|
|
||||||
`tripit`, `family`, `hackmd`, `health` (default-allow) + `terminal` (admin-only).
|
|
||||||
|
|
||||||
**Before** (capture per user, no redirect-follow; 200=ALLOW, 302→authentik/403=DENY):
|
|
||||||
```
|
|
||||||
COOKIE='authentik_session=<paste for this user>'; for H in tripit family hackmd health terminal; do
|
|
||||||
printf '%-10s %s\n' "$H" "$(curl -s -o /dev/null -w '%{http_code}' --max-redirs 0 -H "Cookie: $COOKIE" https://$H.viktorbarzin.me/)"; done
|
|
||||||
```
|
|
||||||
Representative non-admin: `kadir.tugan@gmail.com` (Wrongmove-only) → tripit/family/hackmd/health ALLOW, terminal DENY. Admin `vbarzin@gmail.com` → all ALLOW.
|
|
||||||
|
|
||||||
**After Step 1 apply — regression:** re-run identically; both users' results MUST
|
|
||||||
be unchanged (diff empty).
|
|
||||||
|
|
||||||
**After flows — external smoke test (the security proof):** enrol a throwaway
|
|
||||||
account via the enrollment URL (email verify + passkey). Confirm it is tagged
|
|
||||||
`TripIt External`, then with its cookie:
|
|
||||||
```
|
|
||||||
for H in tripit family hackmd health terminal frigate; do printf '%-10s %s\n' "$H" \
|
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' --max-redirs 0 -H "Cookie: authentik_session=<external>" https://$H.viktorbarzin.me/)"; done
|
|
||||||
```
|
|
||||||
Expect **tripit=200, every other host DENY** (family/hackmd/health were ALLOW for
|
|
||||||
kadir — the contrast is the fence proof). Then:
|
|
||||||
- **OIDC containment:** with the external account, attempt OIDC login to Vault,
|
|
||||||
Immich, Forgejo, Grafana → each must be DENIED at the app's own login.
|
|
||||||
- **Auto-provision:** the TripIt `users` row exists (CNPG primary in ns `dbaas`:
|
|
||||||
`select id,email from tripit.users where email='<throwaway>'`).
|
|
||||||
- **Walling-off guard** `AuthentikWallingOffPublicPath` stays green.
|
|
||||||
|
|
||||||
**Any 200 on a non-tripit host, or any OIDC app admitting the external account →
|
|
||||||
ROLLBACK.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 — Standing regression probe (recommended)
|
|
||||||
|
|
||||||
Add a permanent `TripIt External` identity to the `blackbox-exporter` guard
|
|
||||||
(`stacks/monitoring/.../authentik_walloff_probe.tf` pattern): assert 200 on
|
|
||||||
`tripit.viktorbarzin.me` AND DENY on `family.viktorbarzin.me`. This converts the
|
|
||||||
"branch stays first" and "user_write keeps the keystone tag" invariants into
|
|
||||||
automated `#security` alerts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
Revert the `admin-services-restriction.tf` expression (delete the branch) and push
|
|
||||||
(= apply); removing a prepended `if g: return …` is behaviour-preserving on
|
|
||||||
non-members, restoring prior authz. Disable/delete the throwaway external account
|
|
||||||
(with the branch gone, a tagged account falls into default-allow). The empty group
|
|
||||||
may stay (harmless). Plan-gate the revert too.
|
|
||||||
|
|
||||||
## Operational invariants
|
|
||||||
|
|
||||||
- `TripIt External` stays **parentless** (never under `Allow Login Users`).
|
|
||||||
- The fence branch stays **first** in `admin-services-restriction`.
|
|
||||||
- **Never** co-assign `TripIt External` to a trusted/internal user.
|
|
||||||
- The `tripit-enrollment` user_write **`create_users_group`** setting is the
|
|
||||||
keystone — re-verify after any flow edit (clearing it makes UNtagged accounts
|
|
||||||
that fall into default-allow).
|
|
||||||
- Authentik SMTP is a live dependency of enrollment + recovery.
|
|
||||||
|
|
@ -49,21 +49,6 @@ resource "authentik_policy_expression" "admin_services_restriction" {
|
||||||
|
|
||||||
host = request.context.get("host", "")
|
host = request.context.get("host", "")
|
||||||
|
|
||||||
# TripIt External containment fence (ADR-0020 in the tripit repo). Publicly
|
|
||||||
# self-enrolled TripIt users (group "TripIt External", assigned by the
|
|
||||||
# tripit-enrollment flow's user_write) may reach tripit.viktorbarzin.me and
|
|
||||||
# NOTHING else. MUST be the FIRST host-dispatch branch: it is a request.user
|
|
||||||
# predicate that must dominate every host branch below, ESPECIALLY the
|
|
||||||
# default-allow `if host not in ADMIN_ONLY_HOSTS: return True` — placed after
|
|
||||||
# it, a tagged user would slip into other hosts. Safe to add: the group is
|
|
||||||
# net-new and created EMPTY, so this matches zero existing principals (no
|
|
||||||
# lockout). The fence is forward-auth ONLY; OIDC apps (Vault, Immich, …)
|
|
||||||
# contain External users via their own per-app group bindings — see
|
|
||||||
# docs/runbooks/tripit-external-signup.md. NEVER co-assign "TripIt External"
|
|
||||||
# to a trusted/internal user (this branch would fence them out of admin hosts).
|
|
||||||
if ak_is_group_member(request.user, name="TripIt External"):
|
|
||||||
return host == "tripit.viktorbarzin.me"
|
|
||||||
|
|
||||||
# t3 Workstation edge gate: only members of "T3 Users" may reach t3.
|
# t3 Workstation edge gate: only members of "T3 Users" may reach t3.
|
||||||
# Placed BEFORE the ADMIN_ONLY_HOSTS early-return (t3 is intentionally not in
|
# Placed BEFORE the ADMIN_ONLY_HOSTS early-return (t3 is intentionally not in
|
||||||
# that set — it must not require Home-Server-Admins, just T3 Users membership).
|
# that set — it must not require Home-Server-Admins, just T3 Users membership).
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# Delivers the TripIt enrollment/recovery email-verification stages + their flow
|
|
||||||
# bindings (tripit-email-stages.yaml) as a server-applied Authentik blueprint.
|
|
||||||
#
|
|
||||||
# Why a blueprint and not authentik_stage_email resources: the globally-pinned
|
|
||||||
# provider (goauthentik 2024.x in terragrunt.hcl) models EmailStage.token_expiry
|
|
||||||
# as an integer, but the live server (2026.2.x) requires a duration string and
|
|
||||||
# 400s any number. The blueprint is parsed by the server, which accepts the
|
|
||||||
# string. Bumping the provider would mean a global terragrunt.hcl change that
|
|
||||||
# re-applies every platform stack — disproportionate. See tripit-flows.tf.
|
|
||||||
#
|
|
||||||
# depends_on the flows so they exist before Authentik resolves the blueprint's
|
|
||||||
# !Find [..., slug, tripit-enrollment|tripit-recovery] references.
|
|
||||||
resource "authentik_blueprint" "tripit_email_stages" {
|
|
||||||
name = "tripit-email-stages"
|
|
||||||
content = file("${path.module}/tripit-email-stages.yaml")
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
depends_on = [
|
|
||||||
authentik_flow.tripit_enrollment,
|
|
||||||
authentik_flow.tripit_recovery,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# TripIt enrollment + recovery email-verification stages (tripit ADR-0020).
|
|
||||||
#
|
|
||||||
# Delivered as an Authentik blueprint (applied server-side by Authentik) instead
|
|
||||||
# of via the Terraform provider, because the globally-pinned provider
|
|
||||||
# (goauthentik 2024.x) models EmailStage.token_expiry as an integer while the
|
|
||||||
# live server (2026.2.x) requires a duration string ("hours=24") and rejects any
|
|
||||||
# number. See tripit-flows.tf for the rest of the flow and tripit-email-blueprint.tf
|
|
||||||
# for the delivery resource.
|
|
||||||
#
|
|
||||||
# These two stages + their flow bindings are the SECURITY BOUNDARY of ADR-0020:
|
|
||||||
# enrollment creates the user inactive; only clicking the link from
|
|
||||||
# tripit-enrollment-verify (activate_user_on_success) makes the account usable.
|
|
||||||
# Without this, anyone could self-enroll under an address they don't control and
|
|
||||||
# tripit (which trusts X-authentik-email) would treat them as that identity.
|
|
||||||
version: 1
|
|
||||||
metadata:
|
|
||||||
name: tripit-email-stages
|
|
||||||
entries:
|
|
||||||
# Enrollment: verify the email and ACTIVATE the (initially inactive) user.
|
|
||||||
- model: authentik_stages_email.emailstage
|
|
||||||
state: present
|
|
||||||
identifiers:
|
|
||||||
name: tripit-enrollment-verify
|
|
||||||
attrs:
|
|
||||||
use_global_settings: true # noreply@viktorbarzin.me via mail.viktorbarzin.me
|
|
||||||
activate_user_on_success: true
|
|
||||||
subject: Confirm your TripIt account
|
|
||||||
template: email/account_confirmation.html
|
|
||||||
token_expiry: hours=24
|
|
||||||
# Recovery: prove inbox ownership before letting the user register a new passkey.
|
|
||||||
- model: authentik_stages_email.emailstage
|
|
||||||
state: present
|
|
||||||
identifiers:
|
|
||||||
name: tripit-recovery-email
|
|
||||||
attrs:
|
|
||||||
use_global_settings: true
|
|
||||||
activate_user_on_success: false
|
|
||||||
subject: Recover your TripIt access
|
|
||||||
template: email/account_confirmation.html
|
|
||||||
token_expiry: hours=1
|
|
||||||
# Bind enrollment-verify into tripit-enrollment at order 30
|
|
||||||
# (prompt 10 -> write 20 -> VERIFY 30 -> passkey 40 -> login 50).
|
|
||||||
- model: authentik_flows.flowstagebinding
|
|
||||||
state: present
|
|
||||||
identifiers:
|
|
||||||
target: !Find [authentik_flows.flow, [slug, tripit-enrollment]]
|
|
||||||
stage: !Find [authentik_stages_email.emailstage, [name, tripit-enrollment-verify]]
|
|
||||||
attrs:
|
|
||||||
order: 30
|
|
||||||
# Bind recovery-email into tripit-recovery at order 20
|
|
||||||
# (identify 10 -> EMAIL 20 -> new passkey 30 -> login 40).
|
|
||||||
- model: authentik_flows.flowstagebinding
|
|
||||||
state: present
|
|
||||||
identifiers:
|
|
||||||
target: !Find [authentik_flows.flow, [slug, tripit-recovery]]
|
|
||||||
stage: !Find [authentik_stages_email.emailstage, [name, tripit-recovery-email]]
|
|
||||||
attrs:
|
|
||||||
order: 20
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# "TripIt External" group — containment anchor for publicly self-enrolled TripIt
|
|
||||||
# users (ADR-0020 in the tripit repo). Members are admitted to
|
|
||||||
# tripit.viktorbarzin.me ONLY and denied every other *.viktorbarzin.me
|
|
||||||
# forward-auth host by the prepended branch in admin-services-restriction.tf.
|
|
||||||
#
|
|
||||||
# Created EMPTY and PARENTLESS, on purpose:
|
|
||||||
# * EMPTY — the no-lockout guarantee. Zero members at apply time => the
|
|
||||||
# prepended policy branch matches zero existing principals => it cannot
|
|
||||||
# change anyone's authorization (contrast authentik_group "T3 Users", which
|
|
||||||
# is created WITH members atomically because THAT gate's safety property is
|
|
||||||
# the opposite). Membership is assigned at RUNTIME by the tripit-enrollment
|
|
||||||
# flow's user_write "Create users group" option (authentik_stage_user_write
|
|
||||||
# in tripit-flows.tf). Terraform owns the group's EXISTENCE and the flow that
|
|
||||||
# assigns it.
|
|
||||||
# * PARENTLESS — do NOT make this a child of "Allow Login Users". The sensitive
|
|
||||||
# OIDC apps gate on "Home Server Admins" / "Headscale Users" / "Wrongmove
|
|
||||||
# Users" (children of "Allow Login Users") or, for Vault, on "Allow Login
|
|
||||||
# Users" itself (bound as part of ADR-0020). Keeping External out of that
|
|
||||||
# tree is what stops these users reaching OIDC apps — mirrors guest.tf, which
|
|
||||||
# keeps the guest group out of "Allow Login Users" for the same reason.
|
|
||||||
resource "authentik_group" "tripit_external" {
|
|
||||||
name = "TripIt External"
|
|
||||||
}
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
# =============================================================================
|
|
||||||
# TripIt external-user self-service flows (tripit ADR-0020).
|
|
||||||
#
|
|
||||||
# Public, passwordless self-signup for external users (people Viktor shares
|
|
||||||
# trips with). Three concerns:
|
|
||||||
#
|
|
||||||
# * tripit-enrollment — open registration with email + passkey. Creates an
|
|
||||||
# INACTIVE external user in "TripIt External", then an email-verification
|
|
||||||
# stage ACTIVATES it. Email verification is the SECURITY BOUNDARY: tripit's
|
|
||||||
# backend trusts the X-authentik-email header (AUTH_MODE=hybrid), so a user
|
|
||||||
# must not be able to enroll under an address they don't control. Creating
|
|
||||||
# the user inactive and activating ONLY on a clicked verification link
|
|
||||||
# enforces that — an attacker who enters someone else's address produces an
|
|
||||||
# inactive account that never activates and can never log in.
|
|
||||||
#
|
|
||||||
# * passwordless login — already provided by the built-in `webauthn` flow,
|
|
||||||
# wired as passwordless_flow on the default login page's identification
|
|
||||||
# stage. No new flow needed; external users with a passkey log in there.
|
|
||||||
#
|
|
||||||
# * tripit-recovery — email-anchored: prove inbox ownership, then register a
|
|
||||||
# NEW passkey (the "lost my device" path; multi-passkey per ADR-0020). NOT
|
|
||||||
# wired into the brand/global login flow (that would change ADMIN recovery
|
|
||||||
# behaviour) — reached via its own /if/flow/tripit-recovery/ URL.
|
|
||||||
#
|
|
||||||
# Fence: the "TripIt External" group (tripit-external.tf) + the prepended branch
|
|
||||||
# in admin-services-restriction.tf admit these users to tripit.viktorbarzin.me
|
|
||||||
# ONLY and deny every other *.viktorbarzin.me forward-auth host. Email is sent
|
|
||||||
# via the global SMTP settings wired in modules/authentik/values.yaml
|
|
||||||
# (noreply@viktorbarzin.me through mail.viktorbarzin.me).
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---- Shared stages (used by both enrollment and recovery) -------------------
|
|
||||||
|
|
||||||
# Discoverable (resident) credential => usernameless passwordless login via the
|
|
||||||
# built-in `webauthn` flow, which has no identification stage and so REQUIRES a
|
|
||||||
# discoverable credential to resolve the user. "required" guarantees that;
|
|
||||||
# every modern passkey authenticator supports it.
|
|
||||||
resource "authentik_stage_authenticator_webauthn" "tripit_passkey" {
|
|
||||||
name = "tripit-passkey-setup"
|
|
||||||
resident_key_requirement = "required"
|
|
||||||
user_verification = "preferred"
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "authentik_stage_user_login" "tripit_login" {
|
|
||||||
name = "tripit-login"
|
|
||||||
session_duration = "weeks=4"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Enrollment -------------------------------------------------------------
|
|
||||||
|
|
||||||
resource "authentik_stage_prompt_field" "tripit_enroll_email" {
|
|
||||||
name = "tripit-enroll-email"
|
|
||||||
field_key = "email"
|
|
||||||
label = "Email"
|
|
||||||
type = "email"
|
|
||||||
required = true
|
|
||||||
order = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "authentik_stage_prompt_field" "tripit_enroll_name" {
|
|
||||||
name = "tripit-enroll-name"
|
|
||||||
field_key = "name"
|
|
||||||
label = "Full name"
|
|
||||||
type = "text"
|
|
||||||
required = true
|
|
||||||
order = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "authentik_stage_prompt" "tripit_enroll_prompt" {
|
|
||||||
name = "tripit-enrollment-prompt"
|
|
||||||
fields = [
|
|
||||||
authentik_stage_prompt_field.tripit_enroll_email.id,
|
|
||||||
authentik_stage_prompt_field.tripit_enroll_name.id,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "authentik_stage_user_write" "tripit_enroll_write" {
|
|
||||||
name = "tripit-enrollment-write"
|
|
||||||
# Created INACTIVE: only the email-verification stage (below) activates it.
|
|
||||||
create_users_as_inactive = true
|
|
||||||
# Land in the fenced group (tripit-only via admin-services-restriction).
|
|
||||||
create_users_group = authentik_group.tripit_external.id
|
|
||||||
user_type = "external"
|
|
||||||
# Open registration: ALWAYS create a fresh user; never attach to / mutate an
|
|
||||||
# existing one. There is no identification stage before this, so there is no
|
|
||||||
# pending user to hijack — this is belt-and-suspenders against account takeover.
|
|
||||||
user_creation_mode = "always_create"
|
|
||||||
}
|
|
||||||
|
|
||||||
# NOTE: the two email-verification stages (enrollment + recovery) AND their flow
|
|
||||||
# bindings are deliberately NOT defined here — they live in an Authentik
|
|
||||||
# BLUEPRINT (tripit-email-blueprint.tf), applied server-side. Reason: the
|
|
||||||
# globally-pinned provider (goauthentik 2024.x, terragrunt.hcl) models
|
|
||||||
# EmailStage.token_expiry as an INTEGER, but the live server (2026.2.x) requires
|
|
||||||
# a duration STRING ("hours=24") and 400s any number — the provider cannot send
|
|
||||||
# a valid value (confirmed: even the unset default `30` is rejected). The
|
|
||||||
# blueprint is parsed by the server, which accepts the string. Bumping the
|
|
||||||
# provider would be a global terragrunt.hcl change that re-applies every platform
|
|
||||||
# stack and breaks 3 other authentik-using app stacks' lockfiles — out of all
|
|
||||||
# proportion to two stages. See tripit ADR-0020.
|
|
||||||
|
|
||||||
resource "authentik_flow" "tripit_enrollment" {
|
|
||||||
name = "Sign up for TripIt"
|
|
||||||
title = "Create your TripIt account"
|
|
||||||
slug = "tripit-enrollment"
|
|
||||||
designation = "enrollment"
|
|
||||||
authentication = "require_unauthenticated"
|
|
||||||
}
|
|
||||||
|
|
||||||
# prompt -> write(inactive) -> verify(activate) -> passkey -> login
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_enroll_10_prompt" {
|
|
||||||
target = authentik_flow.tripit_enrollment.uuid
|
|
||||||
stage = authentik_stage_prompt.tripit_enroll_prompt.id
|
|
||||||
order = 10
|
|
||||||
}
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_enroll_20_write" {
|
|
||||||
target = authentik_flow.tripit_enrollment.uuid
|
|
||||||
stage = authentik_stage_user_write.tripit_enroll_write.id
|
|
||||||
order = 20
|
|
||||||
# Run the username-from-email policy (below) at stage-execution time, when
|
|
||||||
# prompt_data is populated — not at plan time. Mirrors guest.tf's pre-stage
|
|
||||||
# context-mutation pattern.
|
|
||||||
evaluate_on_plan = false
|
|
||||||
re_evaluate_policies = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# Passwordless, email-only signup collects no username, but user_write aborts on
|
|
||||||
# an empty username ("Aborting write to empty username"). Derive the username
|
|
||||||
# from the entered email just before user_write runs. Mutating flow_plan.context
|
|
||||||
# is the canonical mutable path — a plain request.context mutation would not
|
|
||||||
# propagate to the stage (see guest.tf's pending_user note).
|
|
||||||
resource "authentik_policy_expression" "tripit_username_from_email" {
|
|
||||||
name = "tripit-enrollment-username-from-email"
|
|
||||||
expression = trimspace(<<-EOT
|
|
||||||
pd = request.context["flow_plan"].context.setdefault("prompt_data", {})
|
|
||||||
pd["username"] = pd.get("email", "")
|
|
||||||
return True
|
|
||||||
EOT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "authentik_policy_binding" "tripit_username_before_write" {
|
|
||||||
target = authentik_flow_stage_binding.tripit_enroll_20_write.id
|
|
||||||
policy = authentik_policy_expression.tripit_username_from_email.id
|
|
||||||
order = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# order 30 (email-verification binding) is in tripit-email-blueprint.tf — see note above
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_enroll_40_passkey" {
|
|
||||||
target = authentik_flow.tripit_enrollment.uuid
|
|
||||||
stage = authentik_stage_authenticator_webauthn.tripit_passkey.id
|
|
||||||
order = 40
|
|
||||||
}
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_enroll_50_login" {
|
|
||||||
target = authentik_flow.tripit_enrollment.uuid
|
|
||||||
stage = authentik_stage_user_login.tripit_login.id
|
|
||||||
order = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Recovery (email-anchored, passwordless) --------------------------------
|
|
||||||
|
|
||||||
resource "authentik_stage_identification" "tripit_recover_ident" {
|
|
||||||
name = "tripit-recovery-identification"
|
|
||||||
user_fields = ["email"]
|
|
||||||
# Anti-enumeration: proceed even for an unknown address (no "user not found").
|
|
||||||
pretend_user_exists = true
|
|
||||||
}
|
|
||||||
|
|
||||||
# (recovery email-verification stage is in tripit-email-blueprint.tf — see note above)
|
|
||||||
|
|
||||||
resource "authentik_flow" "tripit_recovery" {
|
|
||||||
name = "Recover TripIt access"
|
|
||||||
title = "Recover your TripIt account"
|
|
||||||
slug = "tripit-recovery"
|
|
||||||
designation = "recovery"
|
|
||||||
authentication = "require_unauthenticated"
|
|
||||||
}
|
|
||||||
|
|
||||||
# identify(email) -> email(prove ownership) -> new passkey -> login
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_recover_10_ident" {
|
|
||||||
target = authentik_flow.tripit_recovery.uuid
|
|
||||||
stage = authentik_stage_identification.tripit_recover_ident.id
|
|
||||||
order = 10
|
|
||||||
}
|
|
||||||
# order 20 (email-verification binding) is in tripit-email-blueprint.tf — see note above
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_recover_30_passkey" {
|
|
||||||
target = authentik_flow.tripit_recovery.uuid
|
|
||||||
stage = authentik_stage_authenticator_webauthn.tripit_passkey.id
|
|
||||||
order = 30
|
|
||||||
}
|
|
||||||
resource "authentik_flow_stage_binding" "tripit_recover_40_login" {
|
|
||||||
target = authentik_flow.tripit_recovery.uuid
|
|
||||||
stage = authentik_stage_user_login.tripit_login.id
|
|
||||||
order = 40
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue