# Authentik User Management: Complete Roundup & Operations Guide This guide covers everything you need to know about managing users in Authentik for the **Haumdaucher** infrastructure (`https://auth.haumdaucher.de`): from manual creation and invitation workflows to Terraform IaC, custom email branding, fine-grained access control, handling custom attributes (e.g., Vereins-Mitgliedsnummer, second email), and bulk-importing users from an Excel / CSV spreadsheet. --- ## πŸ“‘ Table of Contents 1. [Executive Summary: Quick Answers](#-executive-summary-quick-answers) 2. [Method 1: Manual Web UI & Invitation Links](#-method-1-manual-web-ui--invitation-links) 3. [Method 2: User Provisioning via Terraform](#-method-2-user-provisioning-via-terraform) 4. [Method 3: Bulk Import from Excel / CSV (Python Script)](#-method-3-bulk-import-from-excel--csv-python-script) 5. [Customizing Registration & Onboarding Emails](#-customizing-registration--onboarding-emails) 6. [Access Control & Authorization Policies (Limiting App Access)](#-access-control--authorization-policies-limiting-app-access) 7. [Custom Attributes & OIDC Claim Mappings](#-custom-attributes--oidc-claim-mappings) 8. [Production Checklist & Best Practices](#-production-checklist--best-practices) --- ## ⚑ Executive Summary: Quick Answers | Question | Answer & Key Mechanism | | :--- | :--- | | **How to add new users?** | **4 Ways**: (1) Admin Web UI (`Directory > Users`), (2) **Invitations flow** (`Directory > Invitations` - recommended for self-service), (3) **Terraform** (`authentik_user` resource), or (4) **REST API / Python script** for bulk imports. | | **Can this be done in Terraform?** | **Yes.** Use `resource "authentik_user"` in `terraform/infrastructure/authentik.tf`. Supports custom attributes via `jsonencode()`, group assignments, and active status. | | **Can I change the email sent to the user?** | **Yes.** Configured in **Flows & Stages > Stages > Email Stage**. You can customize the subject line, token expiration, and HTML template (written in Django template syntax with access to user fields and custom attributes). | | **Can I limit what a user has access to?** | **Yes.** Through **Policy Bindings** attached to Applications: Group Membership Policies (e.g., `infra-admins` vs `users`), IP/Geo Policies, or Python Expression Policies checking custom attributes (e.g., only active club members). | | **How to handle Excel tables with custom attributes?** | Authentik stores arbitrary key-value pairs in `user.attributes` (JSON). You can import an Excel/CSV file with a Python script or Terraform `csvdecode()`, map fields like `haumdaucher_mitgliedsnummer` or `second_email`, and expose them to downstream apps (Nextcloud, Grafana, Gitea) via OIDC Scope Mappings. | --- ## πŸ‘€ Method 1: Manual Web UI & Invitation Links Authentik offers two UI methods to onboard users: ### Option A: Direct User Creation (Admin sets password or triggers reset) 1. Go to `https://auth.haumdaucher.de` and log in with your admin account. 2. Navigate to **Directory > Users** and click **Create**. 3. Fill in: * **Username**: `max.mustermann` * **Name**: `Max Mustermann` * **Email**: `max@example.com` * **Type**: `Internal` * **Groups**: Select groups (e.g., `users`, `verein-mitglieder`). * **Attributes (YAML/JSON)**: ```json { "second_email": "max.privat@gmail.com", "haumdaucher_mitgliedsnummer": "HM-2024-042", "phone": "+49 170 1234567" } ``` 4. Click **Create**. 5. To set credentials: Click on the user -> **Actions** -> **Set Password** (or **Explicitly trigger password reset flow** so the user receives a link via email). --- --- ## ⚠️ Troubleshooting: "Flow does not apply to current user" If you encounter this screen when opening an invite link: > **"Flow does not apply to current user."** ### Why this happens: 1. **Wrong Flow Selected (`default-source-enrollment`)**: In your URL (`/if/flow/default-source-enrollment/?itoken=...`), the flow used was `default-source-enrollment`. That flow is strictly designed for external identity providers (OAuth/SAML like Google/GitHub) and binds a policy that checks for an active external source in the context. When accessed directly with an `?itoken=...`, Authentik evaluates this policy, fails, and displays this error. 2. **Missing Invitation Stage**: Standard enrollment flows reject direct token access unless an **`Invitation Stage`** is bound to the beginning of the flow to validate `?itoken=...`. 3. **Active Admin Session**: If an administrator is currently logged in to the browser and clicks the enrollment link, flows with an "unauthenticated-only" policy will reject the session. Always test invitation URLs in an **Incognito / Private window**. --- ### Solution: The Haumdaucher Custom Invitation Flow (`haumdaucher-invitation-flow`) We provision a dedicated, streamlined flow in `terraform/infrastructure/authentik.tf`: ```mermaid graph LR Link[Invite Link with ?itoken=...] --> Stage1[1. Invitation Stage
Validates Token] Stage1 --> Stage2[2. Prompt Stage
User inputs Username, Name, Password] Stage2 --> Stage3[3. User Write Stage
Writes to DB & assigns Groups] Stage3 --> Stage4[4. User Login Stage
Logs user into session] Stage4 --> Stage5[5. Redirect Stage
Redirects to haumdaucher.de] ``` #### Flow Behavior (Tailored to your setup): * **User Inputs**: Prompts the user to choose their preferred **Username**, **Full Name**, and **Password** (with confirmation). * **Email & Custom Attributes**: Pre-filled from the admin invitation (e.g. `haumdaucher_mitgliedsnummer`, `second_email`). * **MFA / 2FA**: Optional. The user registers immediately with their password and can enroll Passkeys/TOTP anytime in their profile settings. * **Automatic Redirect**: Upon completing registration, the user is automatically logged in and redirected to **`https://haumdaucher.de`**. --- ### How to Create an Invitation using the Custom Flow 1. Go to `https://auth.haumdaucher.de` > **Directory > Invitations**. 2. Click **Create** (or **New Invitation**). 3. Set the fields: * **Name**: `Invite - Max Mustermann` * **Expires**: Set expiration (e.g., 7 days). * **Flow**: Select **`Haumdaucher Invitation Flow`** (`haumdaucher-invitation-flow`). * **Single use**: Checked (`true`). * **Fixed Data / Attributes (JSON)**: ```json { "email": "max@example.com", "haumdaucher_mitgliedsnummer": "HM-2024-042", "second_email": "max.privat@gmail.com" } ``` 4. Click **Create**. 5. Copy the generated URL: ``` https://auth.haumdaucher.de/if/flow/haumdaucher-invitation-flow/?itoken= ``` 6. Send the link to the user. When they open it, they will see the branded Haumdaucher registration form asking for their Username, Name, and Password! --- ## πŸ—οΈ Method 2: User Provisioning via Terraform You can manage users, groups, and permissions directly in `terraform/infrastructure/authentik.tf`. ### 1. Single User Definition ```hcl # Group for club members resource "authentik_group" "verein_mitglieder" { name = "verein-mitglieder" is_superuser = false } # User with custom attributes resource "authentik_user" "max_mustermann" { username = "max.mustermann" name = "Max Mustermann" email = "max@example.com" type = "internal" is_active = true groups = [ authentik_group.users.id, authentik_group.verein_mitglieder.id ] attributes = jsonencode({ "second_email" = "max.privat@gmail.com", "haumdaucher_mitgliedsnummer" = "HM-2024-042", "eintrittsdatum" = "2024-01-15", "status" = "aktiv" }) } ``` ### 2. Batch Provisioning from CSV in Terraform If you maintain users in a CSV file (`terraform/users.csv`): ```csv username,name,email,second_email,mitgliedsnummer,is_admin max.mustermann,Max Mustermann,max@example.com,max.privat@gmail.com,HM-2024-042,false anna.schmidt,Anna Schmidt,anna@example.com,anna.work@company.de,HM-2024-043,true ``` In `terraform/infrastructure/authentik.tf`: ```hcl locals { csv_users = csvdecode(file("${path.module}/users.csv")) } resource "authentik_user" "csv_members" { for_each = { for u in local.csv_users : u.username => u } username = each.value.username name = each.value.name email = each.value.email type = "internal" is_active = true groups = compact([ authentik_group.users.id, authentik_group.verein_mitglieder.id, each.value.is_admin == "true" ? authentik_group.infra_admins.id : null ]) attributes = jsonencode({ "second_email" = each.value.second_email, "haumdaucher_mitgliedsnummer" = each.value.mitgliedsnummer }) } ``` ### βš–οΈ Trade-offs: When to use Terraform for Users * **Pros**: Version-controlled in Git, declarative, reproducible, ideal for admins and service accounts (`ai_assistant`). * **Cons / Considerations**: Terraform cannot trigger interactive password onboarding emails out of the box; passwords must be set via recovery flow or first login reset. --- ## πŸ“Š Method 3: Bulk Import from Excel / CSV (Python Script) If you have an Excel file (`mitglieder.xlsx`) or CSV, using Authentik's REST API is the cleanest approach because it can **create users, attach custom attributes, and automatically email them an invitation link to set their own password**. ### Ready-to-Run Python Import Script Save this script (e.g. `k8s/auth/scripts/import_users_excel.py`): ```python #!/usr/bin/env python3 """ Bulk User Importer for Authentik (auth.haumdaucher.de) Reads an Excel (.xlsx) or CSV file and creates users with custom attributes and invitation links. """ import sys import os import requests import pandas as pd # Configuration AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "https://auth.haumdaucher.de") AUTHENTIK_TOKEN = os.getenv("AUTHENTIK_TOKEN") # API Token (e.g. ai_assistant_token) if not AUTHENTIK_TOKEN: print("❌ Error: AUTHENTIK_TOKEN environment variable is required.") sys.exit(1) HEADERS = { "Authorization": f"Bearer {AUTHENTIK_TOKEN}", "Content-Type": "application/json", "Accept": "application/json" } def get_or_create_group(group_name: str) -> str: """Fetch group ID or create if it doesn't exist.""" res = requests.get(f"{AUTHENTIK_URL}/api/v3/core/groups/?name={group_name}", headers=HEADERS) res.raise_for_status() results = res.json().get("results", []) if results: return results[0]["pk"] # Create group create_res = requests.post(f"{AUTHENTIK_URL}/api/v3/core/groups/", headers=HEADERS, json={"name": group_name}) create_res.raise_for_status() return create_res.json()["pk"] def import_users_from_file(file_path: str, send_invitation: bool = True): # Read Excel or CSV if file_path.endswith((".xlsx", ".xls")): df = pd.read_excel(file_path) else: df = pd.read_csv(file_path) # Standardize column names (lowercase & stripped) df.columns = [c.strip().lower() for c in df.columns] print(f"πŸ“‹ Found {len(df)} rows to process.") users_group_pk = get_or_create_group("users") verein_group_pk = get_or_create_group("verein-mitglieder") for idx, row in df.iterrows(): name = str(row.get("name", "")).strip() email = str(row.get("email", "")).strip() username = str(row.get("username", "")).strip() or email.split("@")[0].lower() second_email = str(row.get("second_email", row.get("zweite_email", ""))).strip() mitgliedsnummer = str(row.get("mitgliedsnummer", row.get("verein_mitgliedsnummer", ""))).strip() if not email or email == "nan": print(f"⚠️ Skipping row {idx+1}: missing email") continue # Prepare user payload with custom attributes user_payload = { "username": username, "name": name if name != "nan" else username, "email": email, "type": "internal", "is_active": True, "groups": [users_group_pk, verein_group_pk], "attributes": { "second_email": second_email if second_email != "nan" else "", "haumdaucher_mitgliedsnummer": mitgliedsnummer if mitgliedsnummer != "nan" else "", "source": "excel_import" } } # 1. Create or Update User in Authentik res = requests.get(f"{AUTHENTIK_URL}/api/v3/core/users/?username={username}", headers=HEADERS) res.raise_for_status() existing = res.json().get("results", []) if existing: user_pk = existing[0]["pk"] patch_res = requests.patch(f"{AUTHENTIK_URL}/api/v3/core/users/{user_pk}/", headers=HEADERS, json=user_payload) patch_res.raise_for_status() print(f"πŸ”„ Updated existing user: {username} ({email})") else: create_res = requests.post(f"{AUTHENTIK_URL}/api/v3/core/users/", headers=HEADERS, json=user_payload) if not create_res.ok: print(f"❌ Failed to create {username}: {create_res.text}") continue user_pk = create_res.json()["pk"] print(f"βœ… Created user: {username} ({email}) [ID: {user_pk}]") # 2. Generate Invitation / Password Reset Link if send_invitation: invitation_payload = { "name": f"Onboarding {username}", "single_use": True, "fixed_data": { "email": email, "username": username } } inv_res = requests.post(f"{AUTHENTIK_URL}/api/v3/stages/invitation/invitations/", headers=HEADERS, json=invitation_payload) if inv_res.ok: inv_key = inv_res.json()["pk"] link = f"{AUTHENTIK_URL}/if/flow/default-enrollment-flow/?itoken={inv_key}" print(f" βœ‰οΈ Invitation Link: {link}") if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python3 import_users_excel.py ") sys.exit(1) import_users_from_file(sys.argv[1]) ``` --- ## βœ‰οΈ Customizing Registration & Onboarding Emails When an admin creates a user or triggers an enrollment/password recovery, Authentik sends transactional emails. You can customize their sender address, subject line, and full HTML appearance. ### 1. SMTP & Sender Settings Authentik sends emails using the SMTP configuration defined in `k8s/auth/authentik.secret.values.yaml` or global environment variables: * `AUTHENTIK_EMAIL__HOST`: `smtp.gmail.com` (or Google Workspace / OVH relay) * `AUTHENTIK_EMAIL__PORT`: `587` * `AUTHENTIK_EMAIL__USE_TLS`: `true` * `AUTHENTIK_EMAIL__USERNAME`: `service@haumdaucher.de` * `AUTHENTIK_EMAIL__PASSWORD`: `` * `AUTHENTIK_EMAIL__FROM`: `HAUMDAUCHER Identity ` --- ### 2. Customizing Email Stages in Authentik Authentik controls outgoing emails via **Email Stages**: 1. Open Admin UI: **Flows & Stages > Stages**. 2. Locate the relevant email stage: * `default-password-reset-email` (for password recovery & manual user activation) * `default-enrollment-email` / `invitation-email` (for new user invitations) * `default-user-settings-password-reset-email` 3. Click **Edit** on the stage: * **Subject**: `Willkommen bei Haumdaucher e.V. – Aktiviere deinen Zugang` * **Token Expiry**: `Minutes=1440` (24 hours) or `Minutes=10080` (7 days) * **Template**: Select standard template or your custom template path. --- ### 3. Customizing the HTML Email Template (Django Syntax) Authentik email templates use Django template syntax. Available variables in email templates include: * `{{ user.name }}` / `{{ user.username }}` / `{{ user.email }}` * `{{ user.attributes.haumdaucher_mitgliedsnummer }}` * `{{ url }}` (The activation / password reset link) * `{{ token_expires }}` (Expiry timestamp) * `{{ brand.branding_title }}` (`HAUMDAUCHER`) #### Example Custom Haumdaucher Welcome Template ```html

Willkommen beim Haumdaucher e.V.

Hallo {{ user.name }},

dein zentraler Zugang fΓΌr die Haumdaucher Cloud & Services wurde eingerichtet.

{% if user.attributes.haumdaucher_mitgliedsnummer %}
Mitgliedsnummer: {{ user.attributes.haumdaucher_mitgliedsnummer }}
Benutzername: {{ user.username }}
{% endif %}

Klicke auf den Button unten, um dein Passwort festzulegen und deinen Zugang zu aktivieren:

Zugang aktivieren & Passwort setzen

Dieser Link ist 7 Tage lang gΓΌltig. Falls du die Registrierung nicht angefordert hast, kannst du diese E-Mail ignorieren.

``` --- ## πŸ”’ Access Control & Authorization Policies (Limiting App Access) By default, an application without an attached policy allows **any authenticated user** to access it. Authentik provides fine-grained Access Control (RBAC & PBAC) to restrict access per application. ```mermaid graph TD subgraph "Authentik Directory Groups" Admin["πŸ‘‘ infra-admins
(Moritz & Wife / Full Admin)"] Verein["🍺 verein-mitglieder
(Club Members)"] Normal["πŸ‘€ users
(Normal Authenticated Users)"] end subgraph "Tier 1: Admin & Ops Tools (ForwardAuth / OIDC)" Z2M["Zigbee2MQTT"] Prom["Prometheus & Alertmanager"] PMA["phpMyAdmin (Nextcloud & Gitea)"] N8N["n8n Automation"] MinIO["MinIO Console"] Coder["Coder v2"] OpenClaw["OpenClaw Engine"] end subgraph "Tier 2: Daily Shared Services (OIDC)" HASS["Home Assistant"] Grafana["Grafana Dashboards"] Nextcloud["Nextcloud Cloud"] Gitea["Gitea Git Platform"] ClubWeb["haumdaucher.de"] end %% Admin Links (Full Superadmin access everywhere) Admin ==>|Full Access| Z2M Admin ==>|Full Access| OpenClaw Admin ==>|Admin| HASS Admin ==>|Admin| Grafana Admin ==>|Admin| Nextcloud Admin ==>|Admin| Gitea Admin ==>|Admin| ClubWeb %% Normal User Links (Restricted) Normal -->|Viewer (Read-Only)| Grafana Normal -->|Dashboard View| HASS Normal -->|Standard User| Nextcloud Normal -->|Developer| Gitea %% Club Members Links Verein -->|Member Portal| ClubWeb Verein -->|Club Shared Folder| Nextcloud Verein -->|Viewer (Read-Only)| Grafana ``` ### Master Permission Matrix | Application | Domain | Auth Type | `infra-admins` (Moritz & Wife) | `verein-mitglieder` (Club) | `users` (Normal Users) | | :--- | :--- | :--- | :---: | :---: | :---: | | **OpenClaw** | `openclaw.haumdaucher.de` | ForwardAuth | **Full Access** βœ… | ❌ *Denied* | ❌ *Denied* | | **Zigbee2MQTT** | `zigbee2mqtt.moritzgraf.de` | ForwardAuth | **Full Access** βœ… | ❌ *Denied* | ❌ *Denied* | | **Prometheus / Alertmanager** | `prometheus.haumdaucher.de` | ForwardAuth | **Full Access** βœ… | ❌ *Denied* | ❌ *Denied* | | **phpMyAdmin (All DBs)** | `*.phpmyadmin.haumdaucher.de` | ForwardAuth | **Full Access** βœ… | ❌ *Denied* | ❌ *Denied* | | **n8n** | `n8n.moritzgraf.de` | ForwardAuth | **Full Access** βœ… | ❌ *Denied* | ❌ *Denied* | | **MinIO Console** | `minio-console.haumdaucher.de` | Native OIDC | **`consoleAdmin`** βœ… | ❌ *Denied* | ❌ *Denied* | | **Coder v2** | `coder.moritzgraf.de` | Native OIDC | **`Owner / Admin`** βœ… | ❌ *Denied* | ❌ *Denied* | | **Home Assistant** | `hass.moritzgraf.de` | Native OIDC | **`Administrator`** βœ… | ❌ *Denied* | **`Standard User`** | | **Grafana** | `grafana.haumdaucher.de` | Native OIDC | **`Admin`** βœ… | **`Viewer` (Read-Only)** | **`Viewer` (Read-Only)** | | **Nextcloud** | `cloud.haumdaucher.de` | Native OIDC | **`Admin`** βœ… | **`User` (Club Folder)** | **`User` (Standard)** | | **Gitea** | `git.moritzgraf.de` | Native OIDC | **`Admin`** βœ… | ❌ / Member | **`Developer`** | | **haumdaucher.de** | `haumdaucher.de` | ForwardAuth / OIDC | **`Admin`** βœ… | **`Member Access`** βœ… | ❌ *Denied* | ### Policy Bindings in Terraform All policy bindings are enforced directly in `terraform/infrastructure/authentik.tf`: ```hcl # 1. Admin-Only Tools (Restricted strictly to infra-admins) resource "authentik_policy_binding" "openclaw_admin_only" { target = authentik_application.openclaw.uuid group = authentik_group.infra_admins.id order = 0 } resource "authentik_policy_binding" "zigbee2mqtt_admin_only" { target = authentik_application.zigbee2mqtt.uuid group = authentik_group.infra_admins.id order = 0 } # 2. Shared & Member Applications (Accessible to users group) resource "authentik_policy_binding" "grafana_users" { target = authentik_application.grafana.uuid group = authentik_group.users.id order = 0 } resource "authentik_policy_binding" "nextcloud_users" { target = authentik_application.nextcloud.uuid group = authentik_group.users.id order = 0 } ``` --- ### 2. Attribute-Based Policy (Python Expression Policy) You can write Python expressions that evaluate custom user attributes dynamically: ```hcl resource "authentik_policy_expression" "require_active_membership" { name = "require-active-haumdaucher-membership" expression = < Logs**.