diff --git a/authentik/README.md b/authentik/README.md
new file mode 100644
index 00000000..b70a6e38
--- /dev/null
+++ b/authentik/README.md
@@ -0,0 +1,351 @@
+# MIE Opensource — authentik
+
+This directory contains the [authentik](https://goauthentik.io/) deployment that
+provides Single Sign-On (SSO), enrollment, and LDAP for the MIE Opensource
+platform. Configuration is delivered declaratively through
+[blueprints](https://docs.goauthentik.io/docs/customize/blueprints/) so the
+identity provider can be reproduced from source.
+
+Two-factor authentication (2FA) is enforced for all interactive users via a
+**push notification service** (an external authenticator that approves logins
+with a phone push). Throughout this document and the diagrams, "push
+notification service" refers to that provider.
+
+## Contents
+
+| Path | Purpose |
+|---|---|
+| `compose.yml` | Server + worker + PostgreSQL services |
+| `blueprints/` | Declarative flows, stages, providers, and applications |
+| `certs/` | LDAPS / outpost certificates (git-ignored) |
+| `custom-templates/` | Email and flow template overrides (git-ignored) |
+| `data/` | Media (logos, icons) and runtime data (git-ignored) |
+
+## Blueprints
+
+Blueprints are applied **in filename order**. The numeric prefix encodes the
+required import order — later files reference objects created by earlier ones
+(via `!Find`/`!KeyOf`), so importing out of order will fail.
+
+| Order | File | Creates |
+|---|---|---|
+| 10 | `10-flows-recovery-email-mfa-verification.yaml` | `default-recovery-flow` (password reset) |
+| 10 | `10-opensource-authenticator-duo-setup.yaml` | Push-notification authenticator + its setup flow |
+| 10 | `10-opensource-ldap-identity.yaml` | `ldapusers` group and the `ldap-proxyuser` service account |
+| 11 | `11-opensource-mfa-validation-stage.yaml` | Shared 2FA validation stage (`opensource-authentication-mfa-validation`) |
+| 12 | `12-flows-enrollment-email-verification.yaml` | `default-enrollment-flow` (self-service registration) |
+| 20 | `20-opensource-authentication-flow.yaml` | `opensource-authentication-flow` (login/bind) + proxyuser 2FA exception |
+| 20 | `20-opensource-user-settings-flow.yaml` | `opensource-user-settings-flow` (profile + SSH key, read-only username) |
+| 30 | `30-flow-invitation-enrollment.yaml` | `invitation-enrollment-flow` (invite-only onboarding) |
+| 30 | `30-opensource-brand.yaml` | Brand: title, logos, and default flows |
+| 40 | `40-opensource-ldap.yaml` | LDAP provider + application + access restriction |
+
+> The shared 2FA stage is defined once in `11-...` and referenced (via `!Find`)
+> by the registration, authentication, and invitation flows so every interactive
+> flow enforces identical 2FA behaviour. It must be imported before those flows,
+> and after the push-notification authenticator it depends on.
+>
+> The `1x-flows-*` files are based on upstream authentik examples
+> (`blueprints.goauthentik.io/instantiate: "false"`), used as the enrollment and
+> recovery flows referenced by the identification stage.
+
+### Required environment variables
+
+These are read by `!Env` tags at import time and must be present in the
+authentik **server** and **worker** environment:
+
+| Variable | Used by | Purpose |
+|---|---|---|
+| `OPENSOURCE_PUSH_MFA_CLIENT_ID` | `10-...duo-setup` | Push service application key |
+| `OPENSOURCE_PUSH_MFA_CLIENT_SECRET` | `10-...duo-setup` | Push service application secret |
+| `OPENSOURCE_PUSH_MFA_HOSTNAME` | `10-...duo-setup` | Push service API hostname |
+| `OPENSOURCE_PUSH_MFA_ADMIN_ID` | `10-...duo-setup` | Push service admin API key |
+| `OPENSOURCE_PUSH_MFA_ADMIN_SECRET` | `10-...duo-setup` | Push service admin API secret |
+| `AUTHENTIK_LDAP_PROXYUSER_PASSWORD` | `10-...ldap-identity` | Password for the `ldap-proxyuser` account |
+| `AUTHENTIK_LDAP_BASE_DN` | `40-...ldap` | Base DN of the LDAP directory |
+
+## Flow reference
+
+The sections below describe each flow as configured by the blueprints. Stage
+order numbers match the `flowstagebinding` `order` values.
+
+### Authentication flow
+
+`opensource-authentication-flow` is the brand's default login flow. It is also
+used as the LDAP **bind** flow. Stages:
+
+1. **Identification** (order 10) — username/email; links to enrollment and
+ recovery.
+2. **Password** (order 20) — skipped if the user already has an authentication
+ backend attached (e.g. arrived passwordless).
+3. **Authenticator validation** (order 30) — the push-notification 2FA check.
+ The binding uses `policy_engine_mode: all`, so the stage runs only when
+ every bound policy passes. A negated user binding for the LDAP proxyuser
+ fails for that account, so the stage is skipped for it (exempting the
+ proxyuser from 2FA) while every other user still gets the check.
+4. **User login** (order 100) — creates the session.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Browser
+ participant authentik
+ participant Push as Push Notification Service
+
+ User->>Browser: Open protected app / login page
+ Browser->>authentik: Start opensource-authentication-flow
+
+ Note over authentik: Stage 10 — Identification
+ authentik-->>Browser: Prompt for username/email
+ User->>Browser: Enter identifier
+ Browser->>authentik: Submit identifier
+
+ Note over authentik: Stage 20 — Password (skipped if backend already attached)
+ authentik-->>Browser: Prompt for password
+ User->>Browser: Enter password
+ Browser->>authentik: Submit password
+ authentik->>authentik: Validate credentials
+
+ Note over authentik: Stage 30 — Authenticator validation (2FA)
+ authentik->>Push: Request login approval
+ Push-->>User: Push notification to device
+ User->>Push: Approve
+ Push-->>authentik: Approved
+
+ Note over authentik: Stage 100 — User login
+ authentik->>authentik: Create session
+ authentik-->>Browser: Redirect back to app (authenticated)
+```
+
+### Invitation enrollment flow
+
+`invitation-enrollment-flow` onboards users who hold a valid invitation. New
+users are created as **internal** accounts, added to the `ldapusers` group, and
+are required to set up the push-notification authenticator before their first
+login (the flow reuses the authentication flow's validation stage with
+`not_configured_action: configure`).
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Browser
+ participant authentik
+ participant Push as Push Notification Service
+
+ User->>Browser: Open invitation link (with token)
+ Browser->>authentik: Start invitation-enrollment-flow
+
+ Note over authentik: Stage 5 — Invitation
+ authentik->>authentik: Validate invitation token
+ alt Invalid or missing token
+ authentik-->>Browser: Deny (continue_flow_without_invitation = false)
+ end
+
+ Note over authentik: Stage 10 — Prompt: credentials
+ authentik-->>Browser: Prompt username, password, repeat
+ User->>Browser: Enter credentials
+ Browser->>authentik: Submit
+
+ Note over authentik: Stage 15 — Prompt: details
+ authentik-->>Browser: Prompt name, email
+ User->>Browser: Enter details
+ Browser->>authentik: Submit
+
+ Note over authentik: Stage 20 — User write
+ authentik->>authentik: Create internal user
+ authentik->>authentik: Add user to "ldapusers" group
+
+ Note over authentik: Stage 30 — Authenticator setup/validation
+ authentik-->>Browser: Require push-notification enrollment
+ User->>Push: Enroll device
+ Push-->>authentik: Device registered + approved
+
+ Note over authentik: Stage 100 — User login
+ authentik->>authentik: Create session
+ authentik-->>Browser: Logged in
+```
+
+### Registration flow (self-service)
+
+`default-enrollment-flow` is the self-service registration flow linked from the
+identification stage. Users are created **inactive** and must confirm via email
+before the account is activated. After verification they are required to set up
+the push-notification authenticator (2FA) before the login stage completes.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Browser
+ participant authentik
+ participant Email as Email Server
+ participant Push as Push Notification Service
+
+ User->>Browser: Click "Sign up" on login page
+ Browser->>authentik: Start default-enrollment-flow
+
+ Note over authentik: Stage 10 — Prompt: first
+ authentik-->>Browser: Prompt username, password, repeat
+ User->>Browser: Enter credentials
+ Browser->>authentik: Submit
+
+ Note over authentik: Stage 11 — Prompt: second
+ authentik-->>Browser: Prompt name, email
+ User->>Browser: Enter details
+ Browser->>authentik: Submit
+
+ Note over authentik: Stage 20 — User write
+ authentik->>authentik: Create user (inactive)
+
+ Note over authentik: Stage 30 — Email verification
+ authentik->>Email: Send confirmation email
+ Email-->>User: Confirmation link
+ User->>Browser: Open confirmation link
+ Browser->>authentik: Verify token
+ authentik->>authentik: Activate user
+
+ Note over authentik: Stage 40 — Authenticator setup (2FA) required before login
+ authentik-->>Browser: Require push-notification enrollment
+ User->>Push: Enroll device
+ Push-->>authentik: Device registered + approved
+
+ Note over authentik: Stage 100 — User login
+ authentik->>authentik: Create session
+ authentik-->>Browser: Logged in
+```
+
+> Self-registered users are not added to the `ldapusers` group and therefore
+> cannot bind to LDAP.
+
+### Push-notification authenticator setup
+
+`opensource-authenticator-duo-setup` is a `stage_configuration` flow that
+registers a user's device with the push notification service. It runs whenever a
+flow's authenticator-validation stage encounters a user without a configured
+authenticator (`not_configured_action: configure`), such as during invitation
+enrollment or a first login.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Browser
+ participant authentik
+ participant Push as Push Notification Service
+
+ Note over authentik: Triggered when 2FA is required but no authenticator is configured
+ authentik->>Browser: Start opensource-authenticator-duo-setup
+ Browser->>authentik: Begin device enrollment
+ authentik->>Push: Create enrollment for user
+ Push-->>User: Pair device (push notification)
+ User->>Push: Confirm pairing
+ Push-->>authentik: Device enrolled
+ authentik->>authentik: Attach authenticator to user
+ authentik-->>Browser: Return to originating flow
+```
+
+### User settings flow
+
+`opensource-user-settings-flow` is the brand's user-settings flow (reachable from
+the user dropdown). It is based on authentik's `default-user-settings-flow` with
+two changes:
+
+- The **username is read-only** — it is displayed but cannot be edited, and the
+ prompt stage enforces this server-side (read-only fields are reset to their
+ original value before the user-write stage).
+- A new **SSH public keys** field (one key per line) writes to the user's
+ `sshPublicKey` attribute as a **list**. The LDAP provider exposes this as a
+ multi-valued attribute, SSSD maps it with
+ `ldap_user_ssh_public_key = sshPublicKey`, and sshd serves it via
+ `sss_ssh_authorizedkeys`, enabling SSH public-key login on managed hosts with
+ any of the user's keys.
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Browser
+ participant authentik
+
+ User->>Browser: Open user settings
+ Browser->>authentik: Start opensource-user-settings-flow
+
+ Note over authentik: Stage 20 — Prompt (profile)
+ authentik-->>Browser: Show username (read-only), name, email, locale, SSH public keys
+ User->>Browser: Edit name / email / locale / SSH keys
+ Browser->>authentik: Submit
+
+ Note over authentik: Validation policy
+ authentik->>authentik: Enforce name/email change permissions
+ authentik->>authentik: Force username back to its current value
+
+ Note over authentik: Stage 100 — User write (re-evaluated policy)
+ authentik->>authentik: Normalize SSH keys into a list (one per line)
+ authentik->>authentik: Persist changes (incl. sshPublicKey list attribute)
+ authentik-->>Browser: Settings saved
+```
+
+### LDAP bind flow
+
+The LDAP application/provider (`opensource-ldap`) lets directory-aware services
+authenticate against authentik. The provider uses the **authentication flow** as
+its bind flow with `bind_mode: direct`, so 2FA is evaluated on **every** bind.
+
+Key access rules:
+
+- Only members of the `ldapusers` group may bind (enforced by a group policy
+ binding on the application).
+- `mfa_support` is enabled, so human users append their one-time code to the
+ bind password as `password;code`. (The push notification service prompts the
+ user's device during the bind.)
+- The `ldap-proxyuser` service account is exempt from 2FA (a negated user policy
+ binding skips the validation stage) and is the only principal granted
+ `search_full_directory` (read-only search of the whole directory). No user has
+ write access over LDAP.
+
+```mermaid
+sequenceDiagram
+ participant Client as LDAP Client / Service
+ participant Outpost as LDAP Outpost
+ participant authentik
+ participant Push as Push Notification Service
+
+ Client->>Outpost: bind request (DN + password, optional 2FA code)
+ Outpost->>authentik: Run bind flow (direct mode)
+
+ Note over authentik: Identification + password
+ authentik->>authentik: Resolve user, validate password
+
+ alt User is in "ldapusers"
+ alt User is ldap-proxyuser
+ Note over authentik: 2FA stage skipped (negated binding)
+ else Regular ldapusers member
+ authentik->>Push: Request approval
+ Push-->>authentik: Approved
+ end
+ authentik-->>Outpost: Bind success
+ Outpost-->>Client: Bind OK
+
+ opt Directory search
+ Client->>Outpost: search request
+ alt Caller is ldap-proxyuser
+ Note over Outpost: search_full_directory → full directory results
+ else Regular user
+ Note over Outpost: Returns only the caller's own object
+ end
+ Outpost-->>Client: Search results
+ end
+ else Not in "ldapusers"
+ authentik-->>Outpost: Access denied
+ Outpost-->>Client: Bind failed
+ end
+```
+
+## Deployment
+
+1. Provide the [environment variables](#required-environment-variables) to the
+ server and worker (e.g. via the authentik `.env`).
+2. Make the blueprints available to authentik (mounted into the container's
+ blueprints path).
+3. Apply blueprints **in numeric order** (`10` → `20` → `30` → `40`). authentik
+ discovers and applies file-based blueprints automatically; when importing
+ manually, follow the order in the [Blueprints](#blueprints) table.
+4. Deploy an **LDAP outpost** and assign it the `opensource-ldap` application to
+ serve the directory. (The outpost is not created by these blueprints.)
diff --git a/authentik/blueprints/10-opensource-authenticator-duo-setup.yaml b/authentik/blueprints/10-opensource-authenticator-duo-setup.yaml
new file mode 100644
index 00000000..770369bc
--- /dev/null
+++ b/authentik/blueprints/10-opensource-authenticator-duo-setup.yaml
@@ -0,0 +1,37 @@
+version: 1
+metadata:
+ name: Opensource - DUO Authenticator Setup Flow
+entries:
+ - attrs:
+ authentication: require_authenticated
+ designation: stage_configuration
+ layout: stacked
+ name: opensource-authenticator-duo-setup
+ policy_engine_mode: any
+ title: Set up Push Notifications
+ conditions: []
+ id: flow
+ identifiers:
+ slug: opensource-authenticator-duo-setup
+ model: authentik_flows.flow
+ permissions: []
+ state: present
+ - attrs:
+ friendly_name: Push Notification
+ client_id: !Env OPENSOURCE_PUSH_MFA_CLIENT_ID
+ client_secret: !Env OPENSOURCE_PUSH_MFA_CLIENT_SECRET
+ api_hostname: !Env OPENSOURCE_PUSH_MFA_HOSTNAME
+ admin_integration_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_ID
+ admin_secret_key: !Env OPENSOURCE_PUSH_MFA_ADMIN_SECRET
+ configure_flow: !KeyOf flow
+ id: opensource-authenticator-duo-setup
+ identifiers:
+ name: opensource-authenticator-duo-setup
+ model: authentik_stages_authenticator_duo.authenticatorduostage
+ state: present
+ - identifiers:
+ order: 0
+ stage: !KeyOf opensource-authenticator-duo-setup
+ target: !KeyOf flow
+ model: authentik_flows.flowstagebinding
+ state: present
diff --git a/authentik/blueprints/10-opensource-ldap-identity.yaml b/authentik/blueprints/10-opensource-ldap-identity.yaml
new file mode 100644
index 00000000..d49c75e1
--- /dev/null
+++ b/authentik/blueprints/10-opensource-ldap-identity.yaml
@@ -0,0 +1,52 @@
+version: 1
+metadata:
+ name: Opensource - LDAP Identity (group & proxyuser)
+context:
+ # The proxyuser password is injected from the environment so that no secret is
+ # committed to the blueprint.
+ ldap_proxyuser_password: !Env [AUTHENTIK_LDAP_PROXYUSER_PASSWORD, null]
+entries:
+ # ---------------------------------------------------------------------------
+ # Group that is allowed to bind against LDAP.
+ #
+ # This group is defined here (and referenced via !Find by the enrollment flow
+ # in 30-flow-invitation-enrollment.yaml and the LDAP provider/application in
+ # 40-opensource-ldap.yaml). It has no dependencies, so it is created early.
+ # ---------------------------------------------------------------------------
+ - id: ldapusers-group
+ model: authentik_core.group
+ state: present
+ identifiers:
+ name: ldapusers
+ attrs:
+ is_superuser: false
+ attributes:
+ gidNumber: 2001
+
+ # ---------------------------------------------------------------------------
+ # Dedicated proxyuser service account.
+ #
+ # This account is used by downstream services to bind to LDAP. It is a member
+ # of the ldapusers group (so it is permitted to bind) and is granted an
+ # exception from 2FA in the bind flow (the policy binding lives alongside the
+ # authentication flow in 20-opensource-authentication-flow.yaml, which is why
+ # this identity must be imported before that flow).
+ #
+ # NOTE: This account is intentionally granted the "Search full LDAP directory"
+ # permission so it can resolve any user/group on behalf of downstream services.
+ # This is read-only search access only; the LDAP provider exposes no write/edit
+ # capability, and no other user is granted it. The permission itself is defined
+ # alongside the LDAP provider in 40-opensource-ldap.yaml.
+ # ---------------------------------------------------------------------------
+ - id: ldap-proxyuser
+ model: authentik_core.user
+ state: present
+ identifiers:
+ username: ldap-proxyuser
+ attrs:
+ name: LDAP Proxy User
+ type: service_account
+ path: users/service-accounts
+ password: !Context ldap_proxyuser_password
+ groups:
+ - !KeyOf ldapusers-group
diff --git a/authentik/blueprints/11-opensource-mfa-validation-stage.yaml b/authentik/blueprints/11-opensource-mfa-validation-stage.yaml
new file mode 100644
index 00000000..c4563e9f
--- /dev/null
+++ b/authentik/blueprints/11-opensource-mfa-validation-stage.yaml
@@ -0,0 +1,50 @@
+version: 1
+metadata:
+ name: Opensource - MFA Validation Stage
+entries:
+ # ---------------------------------------------------------------------------
+ # Shared authenticator validation (2FA) stage.
+ #
+ # This is the single MFA validation stage used by every interactive flow:
+ # - the authentication / LDAP bind flow (20-opensource-authentication-flow)
+ # - the invitation enrollment flow (30-flow-invitation-enrollment)
+ # - the self-service registration flow (12-flows-enrollment-email-verification)
+ #
+ # It is defined in its own blueprint so it can be imported before all of those
+ # flows and referenced from each via !Find, keeping a single definition of the
+ # 2FA behaviour.
+ #
+ # not_configured_action: configure forces users without an authenticator to
+ # enroll one (via the push-notification setup flow) before they can proceed.
+ # It depends on the push-notification authenticator defined in
+ # 10-opensource-authenticator-duo-setup.yaml, which must be imported first.
+ # ---------------------------------------------------------------------------
+ - attrs:
+ configuration_stages:
+ - !Find [
+ authentik_stages_authenticator_duo.authenticatorduostage,
+ [name, opensource-authenticator-duo-setup],
+ ]
+ device_classes:
+ - static
+ - totp
+ - webauthn
+ - duo
+ - sms
+ - email
+ email_otp_throttling_factor: 1.0
+ last_auth_threshold: seconds=0
+ not_configured_action: configure
+ sms_otp_throttling_factor: 1.0
+ static_otp_throttling_factor: 1.0
+ totp_otp_throttling_factor: 1.0
+ webauthn_allowed_device_types: []
+ webauthn_hints: []
+ webauthn_user_verification: preferred
+ conditions: []
+ id: opensource-authentication-mfa-validation
+ identifiers:
+ name: opensource-authentication-mfa-validation
+ model: authentik_stages_authenticator_validate.authenticatorvalidatestage
+ permissions: []
+ state: present
diff --git a/authentik/blueprints/10-flows-enrollment-email-verification.yaml b/authentik/blueprints/12-flows-enrollment-email-verification.yaml
similarity index 89%
rename from authentik/blueprints/10-flows-enrollment-email-verification.yaml
rename to authentik/blueprints/12-flows-enrollment-email-verification.yaml
index 275313f9..aa0e5d82 100644
--- a/authentik/blueprints/10-flows-enrollment-email-verification.yaml
+++ b/authentik/blueprints/12-flows-enrollment-email-verification.yaml
@@ -129,6 +129,14 @@ entries:
stage: !KeyOf default-enrollment-email-verification
order: 30
model: authentik_flows.flowstagebinding
+ # Force the user to set up an authenticator (2FA) before logging in. Reuses the
+ # shared MFA validation stage defined in 11-opensource-mfa-validation-stage.yaml
+ # (not_configured_action: configure forces push-notification enrollment).
+ - identifiers:
+ target: !KeyOf flow
+ stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, opensource-authentication-mfa-validation]]
+ order: 40
+ model: authentik_flows.flowstagebinding
- identifiers:
target: !KeyOf flow
stage: !KeyOf default-enrollment-user-login
diff --git a/authentik/blueprints/50-opensource-authentication-flow.yaml b/authentik/blueprints/20-opensource-authentication-flow.yaml
similarity index 72%
rename from authentik/blueprints/50-opensource-authentication-flow.yaml
rename to authentik/blueprints/20-opensource-authentication-flow.yaml
index 082b6b90..d967b620 100644
--- a/authentik/blueprints/50-opensource-authentication-flow.yaml
+++ b/authentik/blueprints/20-opensource-authentication-flow.yaml
@@ -2,7 +2,7 @@ context: {}
entries:
- attrs:
authentication: none
- background: ''
+ background: ""
compatibility_mode: false
denied_action: message_continue
designation: authentication
@@ -19,7 +19,8 @@ entries:
state: present
- attrs:
execution_logging: false
- expression: "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\
+ expression:
+ "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\
\ return True\n# If the user does not have a backend attached to it, they\
\ haven't\n# been authenticated yet and we need the password stage\nreturn not\
\ hasattr(flow_plan.context.get(\"pending_user\"), \"backend\")"
@@ -32,7 +33,8 @@ entries:
state: present
- attrs:
execution_logging: false
- expression: "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\
+ expression:
+ "flow_plan = request.context.get(\"flow_plan\")\nif not flow_plan:\n\
\ return True\n# if the authentication method is webauthn (passwordless),\
\ then we skip the authenticator\n# validation stage by returning false (true\
\ will execute the stage)\nreturn not (flow_plan.context.get(\"auth_method\"\
@@ -51,7 +53,8 @@ entries:
- authentik.sources.kerberos.auth.KerberosBackend
- authentik.sources.ldap.auth.LDAPBackend
- authentik.core.auth.TokenBackend
- configure_flow: !Find [authentik_flows.flow, [slug, default-password-change]]
+ configure_flow:
+ !Find [authentik_flows.flow, [slug, default-password-change]]
failed_attempts_before_cancel: 5
conditions: []
id: opensource-authentication-password
@@ -60,37 +63,12 @@ entries:
model: authentik_stages_password.passwordstage
permissions: []
state: present
- - attrs:
- configuration_stages:
- - !Find [authentik_stages_authenticator_totp.authenticatortotpstage, [name, default-authenticator-totp-setup]]
- device_classes:
- - static
- - totp
- - webauthn
- - duo
- - sms
- - email
- email_otp_throttling_factor: 1.0
- last_auth_threshold: seconds=0
- not_configured_action: configure
- sms_otp_throttling_factor: 1.0
- static_otp_throttling_factor: 1.0
- totp_otp_throttling_factor: 1.0
- webauthn_allowed_device_types: []
- webauthn_hints: []
- webauthn_user_verification: preferred
- conditions: []
- id: opensource-authentication-mfa-validation
- identifiers:
- name: opensource-authentication-mfa-validation
- model: authentik_stages_authenticator_validate.authenticatorvalidatestage
- permissions: []
- state: present
- attrs:
captcha_stage: null
case_insensitive_matching: true
enable_remember_me: true
- enrollment_flow: !Find [authentik_flows.flow, [slug, default-enrollment-flow]]
+ enrollment_flow:
+ !Find [authentik_flows.flow, [slug, default-enrollment-flow]]
password_stage: !KeyOf opensource-authentication-password
passwordless_flow: null
pretend_user_exists: true
@@ -101,7 +79,13 @@ entries:
user_fields:
- email
- username
- webauthn_stage: !KeyOf opensource-authentication-mfa-validation
+ # The shared MFA validation stage is defined in
+ # 11-opensource-mfa-validation-stage.yaml.
+ webauthn_stage:
+ !Find [
+ authentik_stages_authenticator_validate.authenticatorvalidatestage,
+ [name, opensource-authentication-mfa-validation],
+ ]
conditions: []
id: opensource-authentication-identification
identifiers:
@@ -153,13 +137,21 @@ entries:
- attrs:
evaluate_on_plan: false
invalid_response_action: retry
- policy_engine_mode: any
+ # Use `all` so that every bound policy/binding must pass for the MFA stage
+ # to run. This allows the negated user binding for the LDAP proxyuser (see
+ # 40-opensource-ldap.yaml) to skip 2FA for that single service account while
+ # still enforcing MFA for everyone else.
+ policy_engine_mode: all
re_evaluate_policies: true
conditions: []
id: opensource-authentication-mfa-validation-stagebinding
identifiers:
order: 30
- stage: !KeyOf opensource-authentication-mfa-validation
+ stage:
+ !Find [
+ authentik_stages_authenticator_validate.authenticatorvalidatestage,
+ [name, opensource-authentication-mfa-validation],
+ ]
target: !KeyOf opensource-authentication-flow
model: authentik_flows.flowstagebinding
permissions: []
@@ -207,8 +199,32 @@ entries:
model: authentik_policies.policybinding
permissions: []
state: present
- metadata:
- labels:
- blueprints.goauthentik.io/generated: 'true'
- name: authentik Export - 2026-06-04 15:13:39.590416+00:00
- version: 1
+ # 2FA exception for the LDAP proxyuser.
+ #
+ # This binding references the proxyuser directly (via the `user` field) and is
+ # negated, so it passes for every user EXCEPT the proxyuser. Because the MFA
+ # stage binding uses policy_engine_mode `all`, every bound policy must pass for
+ # the MFA stage to run; for the proxyuser this negated binding fails, so MFA is
+ # skipped and it can bind without 2FA. All other users still go through MFA.
+ #
+ # The proxyuser is defined in 10-opensource-ldap-identity.yaml (imported first),
+ # so it is resolved here via !Find.
+ - attrs:
+ enabled: true
+ failure_result: true
+ group: null
+ negate: true
+ timeout: 30
+ conditions: []
+ identifiers:
+ order: 20
+ user: !Find [authentik_core.user, [username, ldap-proxyuser]]
+ target: !KeyOf opensource-authentication-mfa-validation-stagebinding
+ model: authentik_policies.policybinding
+ permissions: []
+ state: present
+metadata:
+ labels:
+ blueprints.goauthentik.io/generated: "true"
+ name: authentik Export - 2026-06-04 15:13:39.590416+00:00
+version: 1
diff --git a/authentik/blueprints/20-opensource-user-settings-flow.yaml b/authentik/blueprints/20-opensource-user-settings-flow.yaml
new file mode 100644
index 00000000..de47df07
--- /dev/null
+++ b/authentik/blueprints/20-opensource-user-settings-flow.yaml
@@ -0,0 +1,247 @@
+version: 1
+metadata:
+ name: Opensource - User settings flow
+entries:
+ # Based on authentik's default-user-settings-flow, with two changes:
+ # 1. The username field is read-only (users cannot change their username).
+ # 2. A new SSH public keys field (one key per line) writes a LIST to the
+ # user's `sshPublicKey` attribute, which SSSD exposes to SSH via
+ # ldap_user_ssh_public_key as a multi-valued attribute.
+ - attrs:
+ designation: stage_configuration
+ name: Opensource User settings
+ title: Update your info
+ authentication: require_authenticated
+ identifiers:
+ slug: opensource-user-settings-flow
+ model: authentik_flows.flow
+ id: flow
+
+ # Username — read-only. Shown for reference but cannot be edited, and because a
+ # read-only field is not submitted, it is never written back.
+ - attrs:
+ order: 200
+ placeholder: Username
+ placeholder_expression: false
+ initial_value: |
+ try:
+ return user.username
+ except:
+ return ''
+ initial_value_expression: true
+ required: false
+ type: text_read_only
+ field_key: username
+ label: Username
+ identifiers:
+ name: opensource-user-settings-field-username
+ id: prompt-field-username
+ model: authentik_stages_prompt.prompt
+
+ - attrs:
+ order: 201
+ placeholder: Name
+ placeholder_expression: false
+ initial_value: |
+ try:
+ return user.name
+ except:
+ return ''
+ initial_value_expression: true
+ required: true
+ type: text
+ field_key: name
+ label: Name
+ identifiers:
+ name: opensource-user-settings-field-name
+ id: prompt-field-name
+ model: authentik_stages_prompt.prompt
+
+ - attrs:
+ order: 202
+ placeholder: Email
+ placeholder_expression: false
+ initial_value: |
+ try:
+ return user.email
+ except:
+ return ''
+ initial_value_expression: true
+ required: true
+ type: email
+ field_key: email
+ label: Email
+ identifiers:
+ name: opensource-user-settings-field-email
+ id: prompt-field-email
+ model: authentik_stages_prompt.prompt
+
+ - attrs:
+ order: 203
+ placeholder: Locale
+ placeholder_expression: false
+ initial_value: |
+ try:
+ return user.attributes.get("settings", {}).get("locale", "")
+ except:
+ return ''
+ initial_value_expression: true
+ required: true
+ type: ak-locale
+ field_key: attributes.settings.locale
+ label: Locale
+ identifiers:
+ name: opensource-user-settings-field-locale
+ id: prompt-field-locale
+ model: authentik_stages_prompt.prompt
+
+ # New: SSH public key(s). Users enter one key per line in a text area. The
+ # submitted value is a single multiline string; the
+ # opensource-user-settings-ssh-keys policy (bound to the user-write stage
+ # below) splits it into a LIST before the value is written to the user's
+ # `sshPublicKey` attribute. Storing a list makes `sshPublicKey` a multi-valued
+ # LDAP attribute, so SSSD (ldap_user_ssh_public_key = sshPublicKey) exposes
+ # every key to sshd and a user can authenticate with any one of them.
+ - attrs:
+ order: 204
+ placeholder: "ssh-ed25519 AAAA... user@host"
+ placeholder_expression: false
+ # Pre-fill the editor with the user's existing keys, one per line. The
+ # stored attribute may be a list (multiple keys) or, for legacy records, a
+ # single string; handle both.
+ initial_value: |
+ try:
+ keys = user.attributes.get("sshPublicKey", [])
+ if isinstance(keys, str):
+ return keys
+ return "\n".join(keys)
+ except:
+ return ''
+ initial_value_expression: true
+ required: false
+ type: text_area
+ field_key: attributes.sshPublicKey
+ label: SSH Public Keys
+ sub_text: One key per line. Used for SSH public-key authentication on hosts.
+ identifiers:
+ name: opensource-user-settings-field-ssh-public-key
+ id: prompt-field-ssh-public-key
+ model: authentik_stages_prompt.prompt
+
+ # Authorization policy: enforces authentik's name/email change permissions.
+ # The username check from the upstream flow is intentionally omitted because the
+ # username field is read-only and is never submitted.
+ - attrs:
+ expression: |
+ from authentik.core.models import (
+ USER_ATTRIBUTE_CHANGE_EMAIL,
+ USER_ATTRIBUTE_CHANGE_NAME,
+ )
+ prompt_data = request.context.get("prompt_data")
+
+ if not request.user.group_attributes(request.http_request).get(
+ USER_ATTRIBUTE_CHANGE_EMAIL, request.http_request.tenant.default_user_change_email
+ ):
+ if prompt_data.get("email") != request.user.email:
+ ak_message("Not allowed to change email address.")
+ return False
+
+ if not request.user.group_attributes(request.http_request).get(
+ USER_ATTRIBUTE_CHANGE_NAME, request.http_request.tenant.default_user_change_name
+ ):
+ if prompt_data.get("name") != request.user.name:
+ ak_message("Not allowed to change name.")
+ return False
+
+ return True
+ identifiers:
+ name: opensource-user-settings-authorization
+ id: opensource-user-settings-authorization
+ model: authentik_policies_expression.expressionpolicy
+
+ # Converts the SSH public key text area into a LIST stored on the user's
+ # `sshPublicKey` attribute. Users enter one key per line; we split, trim,
+ # de-duplicate (preserving order), and drop blank lines.
+ #
+ # This is bound to the USER-WRITE stage (not the prompt stage) so it runs in
+ # the flow process after the prompt's data has been merged into the plan
+ # context, where mutating prompt_data in place persists to the User Write
+ # stage. (Policies on the prompt stage run against a separate validated-data
+ # dict, and process isolation can drop the mutation.)
+ #
+ # The submitted value lives at context["prompt_data"]["attributes"]["sshPublicKey"]
+ # (see goauthentik/authentik#3134). The conversion is idempotent: if the value
+ # is already a list (e.g. on a policy re-evaluation) it is left as-is. Always
+ # returns True; it normalizes the value and never blocks the stage.
+ - attrs:
+ expression: |
+ attributes = context["prompt_data"].get("attributes", {})
+ value = attributes.get("sshPublicKey", [])
+
+ if not isinstance(value, list):
+ keys = []
+ for line in value.split("\n"):
+ key = line.strip()
+ if key and key not in keys:
+ keys.append(key)
+ attributes["sshPublicKey"] = keys
+
+ return True
+ identifiers:
+ name: opensource-user-settings-ssh-keys
+ id: opensource-user-settings-ssh-keys
+ model: authentik_policies_expression.expressionpolicy
+
+ - identifiers:
+ name: opensource-user-settings-write
+ attrs:
+ user_creation_mode: never_create
+ id: opensource-user-settings-write
+ model: authentik_stages_user_write.userwritestage
+
+ - attrs:
+ fields:
+ - !KeyOf prompt-field-username
+ - !KeyOf prompt-field-name
+ - !KeyOf prompt-field-email
+ - !KeyOf prompt-field-locale
+ - !KeyOf prompt-field-ssh-public-key
+ validation_policies:
+ - !KeyOf opensource-user-settings-authorization
+ identifiers:
+ name: opensource-user-settings
+ id: opensource-user-settings
+ model: authentik_stages_prompt.promptstage
+
+ - identifiers:
+ order: 20
+ stage: !KeyOf opensource-user-settings
+ target: !KeyOf flow
+ model: authentik_flows.flowstagebinding
+
+ - identifiers:
+ order: 100
+ stage: !KeyOf opensource-user-settings-write
+ target: !KeyOf flow
+ # re_evaluate_policies makes the flow re-run this binding's policies at
+ # execution time (when prompt_data is populated), rather than only at plan
+ # time. This is what lets opensource-user-settings-ssh-keys normalize the
+ # submitted SSH keys into a list just before they are written to the user.
+ attrs:
+ re_evaluate_policies: true
+ evaluate_on_plan: false
+ id: opensource-user-settings-write-binding
+ model: authentik_flows.flowstagebinding
+
+ # Run the SSH-key normalization policy immediately before the user-write
+ # stage. Bound to the stage binding above so it executes in the flow process
+ # with access to the live prompt_data.
+ - identifiers:
+ target: !KeyOf opensource-user-settings-write-binding
+ policy: !KeyOf opensource-user-settings-ssh-keys
+ order: 0
+ attrs:
+ enabled: true
+ negate: false
+ timeout: 30
+ model: authentik_policies.policybinding
diff --git a/authentik/blueprints/30-flow-invitation-enrollment.yaml b/authentik/blueprints/30-flow-invitation-enrollment.yaml
new file mode 100644
index 00000000..0a46cd69
--- /dev/null
+++ b/authentik/blueprints/30-flow-invitation-enrollment.yaml
@@ -0,0 +1,170 @@
+version: 1
+metadata:
+ labels:
+ blueprints.goauthentik.io/instantiate: "false"
+ name: Opensource - Invitation-based Enrollment
+entries:
+ # Flow definition for internal users with group assignment
+ - identifiers:
+ slug: invitation-enrollment-flow
+ model: authentik_flows.flow
+ id: flow
+ attrs:
+ name: Opensource Invitation Enrollment Flow
+ title: Welcome to MIE Opensource!
+ designation: enrollment
+ authentication: require_unauthenticated
+
+ # Invitation Stage for internal engineering users
+ - identifiers:
+ name: invitation-enrollment-invitation
+ id: invitation-stage
+ model: authentik_stages_invitation.invitationstage
+ attrs:
+ continue_flow_without_invitation: false
+
+ # Prompt fields for user information
+ - id: prompt-field-username
+ model: authentik_stages_prompt.prompt
+ identifiers:
+ name: invitation-enrollment-field-username
+ attrs:
+ field_key: username
+ label: Username
+ type: username
+ required: true
+ placeholder: Username
+ placeholder_expression: false
+ order: 0
+
+ - identifiers:
+ name: invitation-enrollment-field-password
+ id: prompt-field-password
+ model: authentik_stages_prompt.prompt
+ attrs:
+ field_key: password
+ label: Password
+ type: password
+ required: true
+ placeholder: Password
+ placeholder_expression: false
+ order: 1
+
+ - identifiers:
+ name: invitation-enrollment-field-password-repeat
+ id: prompt-field-password-repeat
+ model: authentik_stages_prompt.prompt
+ attrs:
+ field_key: password_repeat
+ label: Password (repeat)
+ type: password
+ required: true
+ placeholder: Password (repeat)
+ placeholder_expression: false
+ order: 2
+
+ - identifiers:
+ name: invitation-enrollment-field-name
+ id: prompt-field-name
+ model: authentik_stages_prompt.prompt
+ attrs:
+ field_key: name
+ label: Name
+ type: text
+ required: true
+ placeholder: Name
+ placeholder_expression: false
+ order: 0
+
+ - identifiers:
+ name: invitation-enrollment-field-email
+ id: prompt-field-email
+ model: authentik_stages_prompt.prompt
+ attrs:
+ field_key: email
+ label: Email
+ type: email
+ required: true
+ placeholder: Email
+ placeholder_expression: false
+ order: 1
+
+ # Prompt stage for credentials
+ - identifiers:
+ name: invitation-enrollment-prompt-credentials
+ id: prompt-stage-credentials
+ model: authentik_stages_prompt.promptstage
+ attrs:
+ fields:
+ - !KeyOf prompt-field-username
+ - !KeyOf prompt-field-password
+ - !KeyOf prompt-field-password-repeat
+
+ # Prompt stage for user details
+ - identifiers:
+ name: invitation-enrollment-prompt-details
+ id: prompt-stage-details
+ model: authentik_stages_prompt.promptstage
+ attrs:
+ fields:
+ - !KeyOf prompt-field-name
+ - !KeyOf prompt-field-email
+
+ # User write stage for internal users with group assignment
+ - identifiers:
+ name: invitation-enrollment-user-write
+ id: user-write-stage
+ model: authentik_stages_user_write.userwritestage
+ attrs:
+ user_creation_mode: always_create
+ user_type: internal
+ user_path_template: users/internal
+ # The ldapusers group is defined in 10-opensource-ldap-identity.yaml.
+ create_users_group: !Find [authentik_core.group, [name, ldapusers]]
+
+ # User login stage
+ - identifiers:
+ name: invitation-enrollment-user-login
+ id: user-login-stage
+ model: authentik_stages_user_login.userloginstage
+
+ # Flow stage bindings for INTERNAL ENGINEERING users flow (with group assignment)
+ - identifiers:
+ target: !KeyOf flow
+ stage: !KeyOf invitation-stage
+ order: 5
+ model: authentik_flows.flowstagebinding
+ attrs:
+ evaluate_on_plan: true
+ re_evaluate_policies: true
+
+ - identifiers:
+ target: !KeyOf flow
+ stage: !KeyOf prompt-stage-credentials
+ order: 10
+ model: authentik_flows.flowstagebinding
+
+ - identifiers:
+ target: !KeyOf flow
+ stage: !KeyOf prompt-stage-details
+ order: 15
+ model: authentik_flows.flowstagebinding
+
+ - identifiers:
+ target: !KeyOf flow
+ stage: !KeyOf user-write-stage
+ order: 20
+ model: authentik_flows.flowstagebinding
+
+ # Force the user to setup or validate an authenticator before logging in
+ - identifiers:
+ target: !KeyOf flow
+ stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, opensource-authentication-mfa-validation]]
+ order: 30
+ model: authentik_flows.flowstagebinding
+
+ - identifiers:
+ target: !KeyOf flow
+ stage: !KeyOf user-login-stage
+ order: 100
+ model: authentik_flows.flowstagebinding
diff --git a/authentik/blueprints/30-opensource-brand.yaml b/authentik/blueprints/30-opensource-brand.yaml
new file mode 100644
index 00000000..fce0f8ad
--- /dev/null
+++ b/authentik/blueprints/30-opensource-brand.yaml
@@ -0,0 +1,26 @@
+version: 1
+metadata:
+ name: Opensource - Brand
+entries:
+ # Disable the current default brand
+ - attrs:
+ default: false
+ identifiers:
+ pk: !Find [authentik_brands.brand, [default, True]]
+ state: present
+ conditions:
+ - !Condition [OR, !Find [authentik_brands.brand, [default, True]]]
+ model: authentik_brands.brand
+
+ - attrs:
+ default: true
+ branding_title: MIE Opensource SSO
+ branding_logo: logo-lm.svg
+ branding_favicon: icon.svg
+ flow_authentication: !Find [authentik_flows.flow, [slug, opensource-authentication-flow]]
+ flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
+ flow_user_settings: !Find [authentik_flows.flow, [slug, opensource-user-settings-flow]]
+ identifiers:
+ domain: opensource-default
+ state: present
+ model: authentik_brands.brand
diff --git a/authentik/blueprints/40-opensource-ldap.yaml b/authentik/blueprints/40-opensource-ldap.yaml
new file mode 100644
index 00000000..31eb1121
--- /dev/null
+++ b/authentik/blueprints/40-opensource-ldap.yaml
@@ -0,0 +1,87 @@
+version: 1
+metadata:
+ name: Opensource - LDAP Application & Provider
+context:
+ # The Base DN under which the LDAP directory is served is injected from the
+ # environment so that no environment-specific value is committed to the
+ # blueprint.
+ ldap_base_dn: !Env [AUTHENTIK_LDAP_BASE_DN, "DC=ldap,DC=goauthentik,DC=io"]
+entries:
+ # ---------------------------------------------------------------------------
+ # LDAP provider.
+ #
+ # The bind flow is the opensource authentication flow (defined in
+ # 20-opensource-authentication-flow.yaml and resolved here via !Find), which
+ # enforces password + code-based MFA for everyone except the proxyuser.
+ # mfa_support is enabled so that ldapusers can append their TOTP code to the
+ # bind password (i.e. "password;123456").
+ #
+ # Only the proxyuser is granted the `search_full_directory` permission (so it
+ # can search the whole directory on behalf of downstream services). This is
+ # read-only search access - the LDAP provider does not expose any write/edit
+ # capability - and no other user is granted it, so regular users can still only
+ # read their own object.
+ # ---------------------------------------------------------------------------
+ - id: ldap-provider
+ model: authentik_providers_ldap.ldapprovider
+ state: present
+ identifiers:
+ name: opensource-ldap
+ attrs:
+ name: opensource-ldap
+ # LDAP outposts use authorization_flow as the bind flow.
+ authorization_flow:
+ !Find [authentik_flows.flow, [slug, opensource-authentication-flow]]
+ invalidation_flow:
+ !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
+ base_dn: !Context ldap_base_dn
+ # bind_mode must be `direct` so the bind flow (and its MFA check) runs on
+ # every bind. `cached` would cache the bind result and effectively bypass
+ # 2FA for the cache duration after the first successful bind.
+ bind_mode: direct
+ search_mode: cached
+ mfa_support: true
+ uid_start_number: 2000
+ gid_start_number: 4000
+ permissions:
+ # Allow the proxyuser to search the full LDAP directory. The proxyuser is
+ # defined in 10-opensource-ldap-identity.yaml and resolved via !Find.
+ - permission: authentik_providers_ldap.search_full_directory
+ user: !Find [authentik_core.user, [username, ldap-proxyuser]]
+
+ # ---------------------------------------------------------------------------
+ # LDAP application.
+ #
+ # The provider is attached directly (the LDAP outpost reads the application via
+ # the provider's assigned application). It is hidden from the user dashboard
+ # since LDAP has no launch URL and is not user-facing.
+ # ---------------------------------------------------------------------------
+ - id: ldap-application
+ model: authentik_core.application
+ state: present
+ identifiers:
+ slug: opensource-ldap
+ attrs:
+ name: Opensource LDAP
+ policy_engine_mode: any
+ provider: !KeyOf ldap-provider
+ meta_hide: true
+
+ # ---------------------------------------------------------------------------
+ # Access restriction: only members of the ldapusers group may bind.
+ #
+ # Binding against the LDAP provider requires access to this application. By
+ # binding the ldapusers group to the application, only users in that group
+ # (including the proxyuser) are able to bind. The ldapusers group is defined in
+ # 10-opensource-ldap-identity.yaml and resolved here via !Find.
+ # ---------------------------------------------------------------------------
+ - model: authentik_policies.policybinding
+ state: present
+ identifiers:
+ target: !KeyOf ldap-application
+ group: !Find [authentik_core.group, [name, ldapusers]]
+ order: 0
+ attrs:
+ enabled: true
+ negate: false
+ timeout: 30
diff --git a/authentik/data/icon.svg b/authentik/data/icon.svg
new file mode 100644
index 00000000..172f984f
--- /dev/null
+++ b/authentik/data/icon.svg
@@ -0,0 +1,9 @@
+
diff --git a/authentik/data/logo-lm.svg b/authentik/data/logo-lm.svg
new file mode 100644
index 00000000..6a5bd593
--- /dev/null
+++ b/authentik/data/logo-lm.svg
@@ -0,0 +1,9 @@
+
diff --git a/create-a-container/bin/sync-users-to-authentik.sh b/create-a-container/bin/sync-users-to-authentik.sh
new file mode 100755
index 00000000..906440af
--- /dev/null
+++ b/create-a-container/bin/sync-users-to-authentik.sh
@@ -0,0 +1,252 @@
+#!/bin/bash
+
+set -euo pipefail
+
+# Sync users from the create-a-container API into the authentik API.
+#
+# Reads every user from `GET /api/v1/users` (admin-only) and creates any that
+# are missing in authentik via `POST /api/v3/core/users/`. Users that already
+# exist in authentik (matched by username) are skipped — this script never
+# updates or deletes existing authentik users, so it is safe to re-run.
+#
+# Passwords are NOT copied: the create-a-container API never exposes password
+# hashes, and authentik users authenticate via the enrollment/recovery flows or
+# an external source. Created users are therefore left without a usable password.
+
+usage() {
+ cat <&2
+ echo "" >&2
+ usage
+ ;;
+ esac
+done
+
+# --- Dependency & config checks ----------------------------------------------
+for cmd in curl jq; do
+ if ! command -v "$cmd" >/dev/null 2>&1; then
+ echo "Error: required command '$cmd' is not installed." >&2
+ exit 1
+ fi
+done
+
+missing=0
+for var in CAC_API_URL CAC_API_KEY AUTHENTIK_URL AUTHENTIK_TOKEN; do
+ if [[ -z "${!var:-}" ]]; then
+ echo "Error: $var is required." >&2
+ missing=1
+ fi
+done
+[[ "$missing" -eq 1 ]] && { echo "" >&2; usage; }
+
+AUTHENTIK_USER_PATH="${AUTHENTIK_USER_PATH:-users}"
+AUTHENTIK_USER_TYPE="${AUTHENTIK_USER_TYPE:-internal}"
+
+# Strip any trailing slash so we can build URLs consistently.
+CAC_API_URL="${CAC_API_URL%/}"
+AUTHENTIK_URL="${AUTHENTIK_URL%/}"
+
+# --- HTTP helpers ------------------------------------------------------------
+# Each helper prints the response body to stdout and the HTTP status to fd 3
+# via a temp file, so callers can branch on the status code.
+
+cac_get() {
+ # $1 = path (e.g. /api/v1/users)
+ curl -sS \
+ -H "Authorization: Bearer $CAC_API_KEY" \
+ -H "Accept: application/json" \
+ "$CAC_API_URL$1"
+}
+
+authentik_get() {
+ # $1 = path with query string
+ curl -sS \
+ -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
+ -H "Accept: application/json" \
+ "$AUTHENTIK_URL$1"
+}
+
+authentik_post() {
+ # $1 = path, $2 = json body; writes HTTP status as the last line.
+ curl -sS -w '\n%{http_code}' \
+ -X POST \
+ -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json" \
+ -d "$2" \
+ "$AUTHENTIK_URL$1"
+}
+
+# --- Fetch users from create-a-container -------------------------------------
+echo "Fetching users from create-a-container API..."
+echo " $CAC_API_URL/api/v1/users"
+
+users_response="$(cac_get /api/v1/users)"
+
+# The API wraps success as { data: [...] } and errors as { error: {...} }.
+if echo "$users_response" | jq -e '.error' >/dev/null 2>&1; then
+ echo "Error from create-a-container API:" >&2
+ echo "$users_response" | jq -r '.error | " [\(.code)] \(.message)"' >&2
+ exit 1
+fi
+
+if ! echo "$users_response" | jq -e '.data | type == "array"' >/dev/null 2>&1; then
+ echo "Error: unexpected response from create-a-container API:" >&2
+ echo "$users_response" >&2
+ exit 1
+fi
+
+user_count="$(echo "$users_response" | jq '.data | length')"
+echo " Found $user_count user(s)."
+echo ""
+
+# --- Sync loop ---------------------------------------------------------------
+created=0
+skipped=0
+failed=0
+
+# Iterate users as compact JSON lines so each record survives the pipe intact.
+while IFS= read -r user; do
+ uid="$(echo "$user" | jq -r '.uid')"
+ cn="$(echo "$user" | jq -r '.cn // .uid')"
+ mail="$(echo "$user" | jq -r '.mail // ""')"
+ status="$(echo "$user" | jq -r '.status // ""')"
+
+ if [[ -z "$uid" || "$uid" == "null" ]]; then
+ echo "! Skipping a user with no uid: $user" >&2
+ failed=$((failed + 1))
+ continue
+ fi
+
+ # Check whether the user already exists in authentik (match by username).
+ # The list endpoint supports an exact ?username= filter.
+ existing="$(authentik_get "/api/v3/core/users/?username=$(jq -rn --arg u "$uid" '$u|@uri')")"
+
+ if ! echo "$existing" | jq -e 'has("results")' >/dev/null 2>&1; then
+ echo "! Failed to query authentik for '$uid':" >&2
+ echo "$existing" | jq -r '.detail // .' >&2 || echo "$existing" >&2
+ failed=$((failed + 1))
+ continue
+ fi
+
+ match_count="$(echo "$existing" | jq '[.results[] | select(.username == $uid)] | length' --arg uid "$uid")"
+ if [[ "$match_count" -gt 0 ]]; then
+ echo "= Skip (exists): $uid"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ # Build the authentik create payload.
+ # username <- uid
+ # name <- cn (full name)
+ # email <- mail
+ # is_active <- status == 'active'
+ # attributes preserves the POSIX/source fields used by the LDAP provider.
+ is_active="false"
+ [[ "$status" == "active" ]] && is_active="true"
+
+ payload="$(jq -n \
+ --arg username "$uid" \
+ --arg name "$cn" \
+ --arg email "$mail" \
+ --arg path "$AUTHENTIK_USER_PATH" \
+ --arg type "$AUTHENTIK_USER_TYPE" \
+ --argjson is_active "$is_active" \
+ --argjson source "$user" \
+ '{
+ username: $username,
+ name: $name,
+ email: $email,
+ is_active: $is_active,
+ path: $path,
+ type: $type,
+ attributes: {
+ uidNumber: $source.uidNumber,
+ givenName: $source.givenName,
+ sn: $source.sn,
+ status: $source.status,
+ managedBy: "create-a-container-sync"
+ }
+ }')"
+
+ if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo "~ Would create: $uid ($cn <$mail>, active=$is_active)"
+ created=$((created + 1))
+ continue
+ fi
+
+ response="$(authentik_post /api/v3/core/users/ "$payload")"
+ http_code="$(echo "$response" | tail -n1)"
+ body="$(echo "$response" | sed '$d')"
+
+ if [[ "$http_code" == "201" ]]; then
+ new_pk="$(echo "$body" | jq -r '.pk // "?"')"
+ echo "+ Created: $uid (authentik pk=$new_pk)"
+ created=$((created + 1))
+ else
+ echo "! Failed to create '$uid' (HTTP $http_code):" >&2
+ echo "$body" | jq -r 'if type=="object" then (to_entries | map(" \(.key): \(.value)") | join("\n")) else . end' >&2 \
+ || echo " $body" >&2
+ failed=$((failed + 1))
+ fi
+done < <(echo "$users_response" | jq -c '.data[]')
+
+# --- Summary -----------------------------------------------------------------
+echo ""
+echo "================================================="
+if [[ "$DRY_RUN" -eq 1 ]]; then
+ echo "Dry run complete (no changes made)."
+ echo " Would create: $created"
+else
+ echo "Sync complete."
+ echo " Created: $created"
+fi
+echo " Skipped (already exist): $skipped"
+echo " Failed: $failed"
+echo "================================================="
+
+[[ "$failed" -gt 0 ]] && exit 1
+exit 0
diff --git a/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js
new file mode 100644
index 00000000..af443093
--- /dev/null
+++ b/create-a-container/seeders/20260604000003-seed-sssd-access-filter-and-user-attrs.js
@@ -0,0 +1,98 @@
+'use strict';
+
+// Adds SSSD env vars introduced after the initial sssd.conf.template:
+// - SSSD_LDAP_USER_NAME (login-name attribute; blank => sssd default)
+// - SSSD_LDAP_USER_GECOS (full-name/gecos attribute; defaults to cn)
+// - SSSD_LDAP_ACCESS_FILTER (login access filter)
+//
+// This is a separate seeder (rather than an edit to 20260604000000) because
+// that seeder is already released and recorded as executed in existing
+// databases, so editing it in place would not back-fill the new keys.
+//
+// SSSD_LDAP_ACCESS_FILTER defaults to a permissive filter that every directory
+// entry matches, so out of the box all directory-authenticated users may log
+// in. This is deliberate: with access_provider=ldap and
+// ldap_access_order=filter, an EMPTY ldap_access_filter denies ALL users, so
+// the value must not be left blank. Admins restrict access by setting a more
+// specific filter, e.g. (memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com).
+const NEW_SSSD_DEFAULTS = [
+ {
+ key: 'SSSD_LDAP_USER_NAME',
+ value: '',
+ description: "LDAP attribute mapped to the user's login name. Leave blank to use the sssd builtin default (uid)"
+ },
+ {
+ key: 'SSSD_LDAP_USER_GECOS',
+ value: 'cn',
+ description: "LDAP attribute mapped to the NSS gecos (full name) field, read by getent/finger and the git-identity script"
+ },
+ {
+ key: 'SSSD_LDAP_ACCESS_FILTER',
+ value: '(objectClass=*)',
+ description: 'LDAP access filter; users matching it may log in. Defaults to (objectClass=*) so all directory users are allowed. Set a stricter filter (e.g. (memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com)) to restrict access. Must not be blank, which would deny everyone.'
+ }
+];
+
+function parseEnvVars(rawValue) {
+ try {
+ const parsed = JSON.parse(rawValue);
+ if (Array.isArray(parsed)) return parsed;
+ // Migrate from the legacy flat-object format {KEY: value} to array format.
+ if (typeof parsed === 'object' && parsed !== null) {
+ return Object.entries(parsed).map(([key, value]) => ({ key, value, description: '' }));
+ }
+ } catch (_) {
+ /* fall through */
+ }
+ return [];
+}
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface) {
+ const [rows] = await queryInterface.sequelize.query(
+ `SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`
+ );
+
+ const existing = rows.length > 0 ? parseEnvVars(rows[0].value) : [];
+ const existingKeys = new Set(existing.map((e) => e.key));
+
+ // Only add keys that are not already present so an admin's customized
+ // values are never overwritten.
+ const toAdd = NEW_SSSD_DEFAULTS.filter((e) => !existingKeys.has(e.key));
+ if (toAdd.length === 0) return;
+
+ const merged = [...existing, ...toAdd];
+ const now = new Date();
+
+ if (rows.length > 0) {
+ await queryInterface.sequelize.query(
+ `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`,
+ { replacements: { value: JSON.stringify(merged), now } }
+ );
+ } else {
+ await queryInterface.bulkInsert('Settings', [{
+ key: 'default_container_env_vars',
+ value: JSON.stringify(merged),
+ createdAt: now,
+ updatedAt: now
+ }]);
+ }
+ },
+
+ async down(queryInterface) {
+ const [rows] = await queryInterface.sequelize.query(
+ `SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`
+ );
+ if (rows.length === 0) return;
+
+ const existing = parseEnvVars(rows[0].value);
+ const keysToRemove = new Set(NEW_SSSD_DEFAULTS.map((e) => e.key));
+ const reverted = existing.filter((e) => !keysToRemove.has(e.key));
+
+ await queryInterface.sequelize.query(
+ `UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`,
+ { replacements: { value: JSON.stringify(reverted), now: new Date() } }
+ );
+ }
+};
diff --git a/images/base/50-sss-ssh-authorizedkeys.conf b/images/base/50-sss-ssh-authorizedkeys.conf
new file mode 100644
index 00000000..e4c83cfe
--- /dev/null
+++ b/images/base/50-sss-ssh-authorizedkeys.conf
@@ -0,0 +1,4 @@
+# Fetch users' authorized SSH public keys from SSSD (which serves the LDAP
+# `sshPublicKey` attribute). %u is replaced by sshd with the login username.
+AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys %u
+AuthorizedKeysCommandUser nobody
diff --git a/images/base/Dockerfile b/images/base/Dockerfile
index 6e89cdfe..d0d7a97c 100644
--- a/images/base/Dockerfile
+++ b/images/base/Dockerfile
@@ -15,8 +15,10 @@ COPY --from=builder /rootfs /
# Install and setup sssd autoconfiguration.
# ldap-utils provides ldapsearch, used by the git-identity profile script to
# fetch a user's email from the internal LDAP server on first interactive login.
+# sssd-tools provides sss_ssh_authorizedkeys, used by sshd to serve users' SSH
+# public keys (stored on the LDAP sshPublicKey attribute).
RUN apt-get update && \
- apt-get install -y sssd sudo tmux curl gnupg git jq ldap-utils unattended-upgrades locales && \
+ apt-get install -y sssd sssd-tools sudo tmux curl gnupg git jq ldap-utils unattended-upgrades locales && \
echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen && \
locale-gen && \
update-locale LANG=en_US.UTF-8 && \
@@ -25,6 +27,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/*
COPY --chmod=0440 sssd.conf.template /etc/sssd/sssd.conf.template
COPY --chmod=0440 50-sssd-conf-template.conf /etc/systemd/system/sssd.service.d/50-sssd-conf-template.conf
+COPY --chmod=0644 50-sss-ssh-authorizedkeys.conf /etc/ssh/sshd_config.d/50-sss-ssh-authorizedkeys.conf
COPY --chmod=0440 ldapusers /etc/sudoers.d/ldapusers
COPY --chmod=0644 ldap.conf /etc/ldap/ldap.conf
COPY --chmod=0755 git-identity.sh /etc/profile.d/git-identity.sh
diff --git a/images/base/sssd.conf.template b/images/base/sssd.conf.template
index f889f46e..bebfe10c 100644
--- a/images/base/sssd.conf.template
+++ b/images/base/sssd.conf.template
@@ -4,6 +4,7 @@ domains = default
[domain/default]
id_provider = ldap
auth_provider = ldap
+access_provider = ldap
ldap_uri = ${SSSD_LDAP_URI}
ldap_tls_reqcert = ${SSSD_LDAP_TLS_REQCERT}
@@ -12,9 +13,15 @@ ldap_search_base = ${SSSD_LDAP_SEARCH_BASE}
ldap_user_search_base = ${SSSD_LDAP_USER_SEARCH_BASE}
ldap_group_search_base = ${SSSD_LDAP_GROUP_SEARCH_BASE}
-# Map LDAP cn attribute to the NSS gecos field so that tools like getent,
-# finger, and the git-identity profile script can read the user's full name.
-ldap_user_gecos = cn
+ldap_user_name = ${SSSD_LDAP_USER_NAME}
+ldap_user_gecos = ${SSSD_LDAP_USER_GECOS}
+
+# Expose the user's SSH public key (stored on the LDAP `sshPublicKey` attribute)
+# so it can be served to sshd via `sss_ssh_authorizedkeys`.
+ldap_user_ssh_public_key = sshPublicKey
+
+ldap_access_order = filter
+ldap_access_filter = ${SSSD_LDAP_ACCESS_FILTER}
ldap_default_bind_dn = ${SSSD_LDAP_DEFAULT_BIND_DN}
ldap_default_authtok_type = ${SSSD_DEFAULT_AUTHTOK_TYPE}
@@ -22,3 +29,6 @@ ldap_default_authtok = ${SSSD_DEFAULT_AUTHTOK}
# set a timeout long enough for a push notification to be responded to
ldap_opt_timeout = 60
+
+fallback_homedir = /home/%u
+default_shell = /bin/bash
diff --git a/mie-opensource-landing/docs/admins/ldap-servers.md b/mie-opensource-landing/docs/admins/ldap-servers.md
index 830fb9b7..38338c7c 100644
--- a/mie-opensource-landing/docs/admins/ldap-servers.md
+++ b/mie-opensource-landing/docs/admins/ldap-servers.md
@@ -22,6 +22,7 @@ domains = default
[domain/default]
id_provider = ldap
auth_provider = ldap
+access_provider = ldap
ldap_uri = ${SSSD_LDAP_URI}
ldap_tls_reqcert = ${SSSD_LDAP_TLS_REQCERT}
@@ -30,9 +31,13 @@ ldap_search_base = ${SSSD_LDAP_SEARCH_BASE}
ldap_user_search_base = ${SSSD_LDAP_USER_SEARCH_BASE}
ldap_group_search_base = ${SSSD_LDAP_GROUP_SEARCH_BASE}
-# Map the LDAP cn attribute to the NSS gecos field so tools like getent,
-# finger, and the git-identity profile script can read the user's full name.
-ldap_user_gecos = cn
+ldap_user_name = ${SSSD_LDAP_USER_NAME}
+ldap_user_gecos = ${SSSD_LDAP_USER_GECOS}
+
+# Which users may log in. The default filter matches every entry, so all
+# directory users are allowed; set a stricter filter to restrict access.
+ldap_access_order = filter
+ldap_access_filter = ${SSSD_LDAP_ACCESS_FILTER}
ldap_default_bind_dn = ${SSSD_LDAP_DEFAULT_BIND_DN}
ldap_default_authtok_type = ${SSSD_DEFAULT_AUTHTOK_TYPE}
@@ -43,7 +48,10 @@ ldap_opt_timeout = 60
```
!!! note
- Any `SSSD_*` variable left blank is substituted as an empty value, and SSSD falls back to its built-in default (for example, auto-detecting the search base from the directory's RootDSE). Only set the variables your directory actually requires.
+ Most `SSSD_*` variables left blank are substituted as an empty value, and SSSD falls back to its built-in default (for example, auto-detecting the search base from the directory's RootDSE). Only set the variables your directory actually requires.
+
+!!! warning "Do not blank out `SSSD_LDAP_ACCESS_FILTER`"
+ `SSSD_LDAP_ACCESS_FILTER` is the exception to the rule above. Because the container uses `access_provider = ldap` with `ldap_access_order = filter`, an **empty** access filter causes SSSD to **deny every user**. It is seeded with the permissive default `(objectClass=*)` so that all directory users can log in out of the box. Replace it with a narrower filter to restrict access — but never leave it blank.
## Configuring the Connection
@@ -59,6 +67,9 @@ In the admin UI: **Settings** → **Default Container Environment Variables**. T
| `SSSD_LDAP_SEARCH_BASE` | *(blank)* | No | Base DN for all searches (e.g. `dc=example,dc=com`). Leave blank to let SSSD auto-detect it from the RootDSE. |
| `SSSD_LDAP_USER_SEARCH_BASE` | *(blank)* | No | Base DN for user searches. Overrides `SSSD_LDAP_SEARCH_BASE` for users (e.g. `ou=people,dc=example,dc=com`). |
| `SSSD_LDAP_GROUP_SEARCH_BASE` | *(blank)* | No | Base DN for group searches (e.g. `ou=groups,dc=example,dc=com`). |
+| `SSSD_LDAP_USER_NAME` | *(blank)* | No | LDAP attribute mapped to the user's login name. Leave blank for the SSSD default (`uid`); set to `sAMAccountName` for Active Directory. |
+| `SSSD_LDAP_USER_GECOS` | `cn` | No | LDAP attribute mapped to the NSS gecos (full name) field, read by `getent`, `finger`, and the git-identity script. |
+| `SSSD_LDAP_ACCESS_FILTER` | `(objectClass=*)` | Yes | LDAP filter deciding **who may log in**. The default matches every entry, so all directory users are allowed. Restrict access with a stricter filter, e.g. `(memberOf=cn=allowedusers,ou=Groups,dc=example,dc=com)`. Must not be blank — an empty filter denies everyone. |
| `SSSD_LDAP_DEFAULT_BIND_DN` | *(blank)* | No | DN used to bind for lookups. Leave blank for anonymous bind; set it if your directory disallows anonymous searches (e.g. `cn=svc-sssd,ou=services,dc=example,dc=com`). |
| `SSSD_DEFAULT_AUTHTOK_TYPE` | *(blank)* | No | Type of the bind credential, typically `password`. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. |
| `SSSD_DEFAULT_AUTHTOK` | *(blank)* | No | The bind credential (password) for the bind DN. Required when `SSSD_LDAP_DEFAULT_BIND_DN` is set. |