diff --git a/docs/architecture/authentication.md b/docs/architecture/authentication.md index 8de844de..9decc8dc 100644 --- a/docs/architecture/authentication.md +++ b/docs/architecture/authentication.md @@ -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. -### 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 Authentik provides OIDC for 10 applications: diff --git a/docs/runbooks/tripit-external-signup.md b/docs/runbooks/tripit-external-signup.md deleted file mode 100644 index 0172c9b1..00000000 --- a/docs/runbooks/tripit-external-signup.md +++ /dev/null @@ -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: "" } # confirm relay creds - - { name: AUTHENTIK_EMAIL__PASSWORD, valueFrom: { secretKeyRef: { name: , 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='; 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=" 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=''`). -- **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. diff --git a/stacks/authentik/admin-services-restriction.tf b/stacks/authentik/admin-services-restriction.tf index 6bf9ff59..2dcc1ca2 100644 --- a/stacks/authentik/admin-services-restriction.tf +++ b/stacks/authentik/admin-services-restriction.tf @@ -49,21 +49,6 @@ resource "authentik_policy_expression" "admin_services_restriction" { 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. # 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). diff --git a/stacks/authentik/tripit-email-blueprint.tf b/stacks/authentik/tripit-email-blueprint.tf deleted file mode 100644 index 1e6795e2..00000000 --- a/stacks/authentik/tripit-email-blueprint.tf +++ /dev/null @@ -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, - ] -} diff --git a/stacks/authentik/tripit-email-stages.yaml b/stacks/authentik/tripit-email-stages.yaml deleted file mode 100644 index 9d9b148e..00000000 --- a/stacks/authentik/tripit-email-stages.yaml +++ /dev/null @@ -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 diff --git a/stacks/authentik/tripit-external.tf b/stacks/authentik/tripit-external.tf deleted file mode 100644 index d0046bb0..00000000 --- a/stacks/authentik/tripit-external.tf +++ /dev/null @@ -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" -} diff --git a/stacks/authentik/tripit-flows.tf b/stacks/authentik/tripit-flows.tf deleted file mode 100644 index a3889dc9..00000000 --- a/stacks/authentik/tripit-flows.tf +++ /dev/null @@ -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 -}