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. |