diff --git a/Scripts/authentik-sync-burrow-directory.sh b/Scripts/authentik-sync-burrow-directory.sh new file mode 100644 index 0000000..656b738 --- /dev/null +++ b/Scripts/authentik-sync-burrow-directory.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +directory_json="${AUTHENTIK_BURROW_DIRECTORY_JSON:-[]}" +users_group="${AUTHENTIK_BURROW_USERS_GROUP:-burrow-users}" +admins_group="${AUTHENTIK_BURROW_ADMINS_GROUP:-burrow-admins}" +forgejo_application_slug="${AUTHENTIK_FORGEJO_APPLICATION_SLUG:-}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-burrow-directory.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_BURROW_DIRECTORY_JSON + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_BURROW_USERS_GROUP + AUTHENTIK_BURROW_ADMINS_GROUP + AUTHENTIK_FORGEJO_APPLICATION_SLUG +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ -z "$bootstrap_token" ]]; then + echo "error: AUTHENTIK_BOOTSTRAP_TOKEN is required" >&2 + exit 1 +fi + +if ! printf '%s' "$directory_json" | jq -e 'type == "array"' >/dev/null; then + echo "error: AUTHENTIK_BURROW_DIRECTORY_JSON must be a JSON array" >&2 + exit 1 +fi + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + + if [[ -n "$data" ]]; then + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + else + curl -fsS \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + fi +} + +wait_for_authentik() { + for _ in $(seq 1 90); do + if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200&search=${group_name}" \ + | jq -r --arg name "$group_name" '.results[]? | select(.name == $name) | .pk // empty' \ + | head -n1 +} + +ensure_group() { + local group_name="$1" + local payload group_pk + + payload="$( + jq -cn \ + --arg name "$group_name" \ + '{name: $name}' + )" + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -n "$group_pk" ]]; then + api PATCH "/api/v3/core/groups/${group_pk}/" "$payload" >/dev/null + else + group_pk="$( + api POST "/api/v3/core/groups/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "$group_pk" ]]; then + echo "error: could not create Authentik group ${group_name}" >&2 + exit 1 + fi + + printf '%s\n' "$group_pk" +} + +lookup_user_pk() { + local username="$1" + + api GET "/api/v3/core/users/?page_size=200&search=${username}" \ + | jq -r --arg username "$username" '.results[]? | select(.username == $username) | .pk // empty' \ + | head -n1 +} + +ensure_user() { + local user_spec="$1" + local username name email is_admin groups_json effective_groups_json group_name + local group_pks_json payload user_pk + + username="$(printf '%s\n' "$user_spec" | jq -r '.username')" + name="$(printf '%s\n' "$user_spec" | jq -r '.name')" + email="$(printf '%s\n' "$user_spec" | jq -r '.email')" + is_admin="$(printf '%s\n' "$user_spec" | jq -r '.isAdmin // false')" + groups_json="$(printf '%s\n' "$user_spec" | jq -c '.groups // []')" + + if [[ -z "$username" || "$username" == "null" || -z "$email" || "$email" == "null" ]]; then + echo "error: each Burrow Authentik user requires username and email" >&2 + exit 1 + fi + + effective_groups_json="$( + printf '%s\n' "$groups_json" \ + | jq -c --arg users_group "$users_group" --arg admins_group "$admins_group" --argjson is_admin "$is_admin" ' + . + [$users_group] + (if $is_admin then [$admins_group] else [] end) | unique + ' + )" + + group_pks_json='[]' + while IFS= read -r group_name; do + group_pk="$(ensure_group "$group_name")" + group_pks_json="$( + jq -cn \ + --argjson current "$group_pks_json" \ + --arg next "$group_pk" \ + '$current + [$next]' + )" + done < <(printf '%s\n' "$effective_groups_json" | jq -r '.[]') + + payload="$( + jq -cn \ + --arg username "$username" \ + --arg name "$name" \ + --arg email "$email" \ + --argjson groups "$group_pks_json" \ + '{ + username: $username, + name: $name, + email: $email, + is_active: true, + path: "users", + groups: $groups + }' + )" + + user_pk="$(lookup_user_pk "$username")" + if [[ -n "$user_pk" ]]; then + api PATCH "/api/v3/core/users/${user_pk}/" "$payload" >/dev/null + else + user_pk="$( + api POST "/api/v3/core/users/" "$payload" \ + | jq -r '.pk // empty' + )" + fi + + if [[ -z "$user_pk" ]]; then + echo "error: could not create Authentik user ${username}" >&2 + exit 1 + fi +} + +lookup_application_pk() { + local slug="$1" + + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .pk // empty' \ + | head -n1 +} + +ensure_application_group_binding() { + local application_slug="$1" + local group_name="$2" + local application_pk group_pk existing payload binding_pk + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "warning: could not resolve Authentik application ${application_slug}; skipping application group binding" >&2 + return 0 + fi + + group_pk="$(lookup_group_pk "$group_name")" + if [[ -z "$group_pk" ]]; then + echo "error: could not resolve Authentik group ${group_name}" >&2 + exit 1 + fi + + existing="$( + api GET "/api/v3/policies/bindings/?page_size=200&target=${application_pk}" \ + | jq -c --arg group_pk "$group_pk" '.results[]? | select(.group == $group_pk)' \ + | head -n1 + )" + + payload="$( + jq -cn \ + --arg target "$application_pk" \ + --arg group "$group_pk" \ + '{ + group: $group, + target: $target, + negate: false, + enabled: true, + order: 100, + timeout: 30, + failure_result: false + }' + )" + + if [[ -n "$existing" ]]; then + binding_pk="$(printf '%s\n' "$existing" | jq -r '.pk')" + api PATCH "/api/v3/policies/bindings/${binding_pk}/" "$payload" >/dev/null + else + api POST "/api/v3/policies/bindings/" "$payload" >/dev/null + fi +} + +wait_for_authentik +ensure_group "$users_group" >/dev/null +ensure_group "$admins_group" >/dev/null + +while IFS= read -r user_spec; do + ensure_user "$user_spec" +done < <(printf '%s\n' "$directory_json" | jq -c '.[]') + +if [[ -n "$forgejo_application_slug" ]]; then + ensure_application_group_binding "$forgejo_application_slug" "$users_group" +fi + +echo "Synced Burrow Authentik directory." diff --git a/Scripts/authentik-sync-forgejo-oidc.sh b/Scripts/authentik-sync-forgejo-oidc.sh index f354633..7b292dc 100644 --- a/Scripts/authentik-sync-forgejo-oidc.sh +++ b/Scripts/authentik-sync-forgejo-oidc.sh @@ -74,6 +74,41 @@ api() { fi } +api_with_status() { + local method="$1" + local path="$2" + local data="${3:-}" + local response_file status + + response_file="$(mktemp)" + trap 'rm -f "$response_file"' RETURN + + if [[ -n "$data" ]]; then + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + -H "Content-Type: application/json" \ + -d "$data" \ + "${authentik_url}${path}" + )" + else + status="$( + curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X "$method" \ + -H "Authorization: Bearer ${bootstrap_token}" \ + "${authentik_url}${path}" + )" + fi + + printf '%s\n' "$status" + cat "$response_file" +} + wait_for_authentik() { for _ in $(seq 1 90); do if curl -fsS "${authentik_url}/-/health/ready/" >/dev/null 2>&1; then @@ -106,7 +141,6 @@ signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" provider_payload="$( jq -n \ --arg name "$provider_name" \ - --arg slug "$application_slug" \ --arg authorization_flow "$authorization_flow" \ --arg invalidation_flow "$invalidation_flow" \ --arg client_id "$client_id" \ @@ -116,7 +150,6 @@ provider_payload="$( --argjson redirect_uris "$redirect_uris_json" \ '{ name: $name, - slug: $slug, authorization_flow: $authorization_flow, invalidation_flow: $invalidation_flow, client_type: "confidential", @@ -172,18 +205,32 @@ application_payload="$( )" existing_application="$( - api GET "/api/v3/core/applications/?slug=${application_slug}" \ - | jq -c '.results[]? | select(.slug != null)' \ + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ | head -n1 )" if [[ -n "$existing_application" ]]; then application_pk="$(printf '%s\n' "$existing_application" | jq -r '.pk')" else - application_pk="$( - api POST "/api/v3/core/applications/" "$application_payload" \ - | jq -r '.pk // empty' + create_application_result="$( + api_with_status POST "/api/v3/core/applications/" "$application_payload" )" + create_application_status="$(printf '%s\n' "$create_application_result" | sed -n '1p')" + create_application_body="$(printf '%s\n' "$create_application_result" | sed '1d')" + + if [[ "$create_application_status" =~ ^20[01]$ ]]; then + application_pk="$(printf '%s\n' "$create_application_body" | jq -r '.pk // empty')" + elif [[ "$create_application_status" == "400" ]] && printf '%s\n' "$create_application_body" | jq -e ' + (.slug // [] | index("Application with this slug already exists.")) != null + or (.provider // [] | index("Application with this provider already exists.")) != null + ' >/dev/null; then + application_pk="existing-duplicate" + else + printf '%s\n' "$create_application_body" >&2 + echo "error: could not reconcile Authentik application ${application_slug}" >&2 + exit 1 + fi fi if [[ -z "${application_pk:-}" ]]; then diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 314d6f1..76b0ef5 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -91,6 +91,22 @@ headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; + bootstrapUsers = [ + { + username = "contact"; + name = "Burrow"; + email = "contact@burrow.net"; + sourceEmail = "net.burrow@gmail.com"; + isAdmin = true; + } + { + username = "conrad"; + name = "Conrad Kramer"; + email = "conrad@burrow.net"; + sourceEmail = "ckrames1234@gmail.com"; + isAdmin = true; + } + ]; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 78a305a..4e31d43 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -8,6 +8,7 @@ let blueprintFile = "${blueprintDir}/burrow-authentik.yaml"; postgresVolume = "burrow-authentik-postgresql:/var/lib/postgresql/data"; dataVolume = "burrow-authentik-data:/data"; + directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -31,6 +32,19 @@ let "email_verified": True, } + - model: authentik_providers_oauth2.scopemapping + id: burrow-oidc-groups + identifiers: + name: Burrow OIDC Groups + attrs: + name: Burrow OIDC Groups + scope_name: groups + description: Group membership mapping for Burrow + expression: | + return { + "groups": [group.name for group in request.user.ak_groups.all()], + } + - model: authentik_providers_oauth2.oauth2provider id: burrow-oidc-provider-ts identifiers: @@ -50,6 +64,7 @@ let property_mappings: - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] - !KeyOf burrow-oidc-email + - !KeyOf burrow-oidc-groups - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] @@ -159,6 +174,54 @@ in default = "redirect"; description = "Identification-stage behavior for the Google Authentik source."; }; + + userGroupName = lib.mkOption { + type = lib.types.str; + default = "burrow-users"; + description = "Authentik group granted baseline Burrow access."; + }; + + adminGroupName = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "Authentik group granted Burrow administrator access."; + }; + + bootstrapUsers = lib.mkOption { + type = with lib.types; listOf (submodule { + options = { + username = lib.mkOption { + type = str; + description = "Authentik username."; + }; + name = lib.mkOption { + type = str; + description = "Display name for the user."; + }; + email = lib.mkOption { + type = str; + description = "Canonical email stored in Authentik."; + }; + sourceEmail = lib.mkOption { + type = nullOr str; + default = null; + description = "External Google account email that should map onto this Authentik user."; + }; + groups = lib.mkOption { + type = listOf str; + default = [ ]; + description = "Additional Authentik groups for this user."; + }; + isAdmin = lib.mkOption { + type = bool; + default = false; + description = "Whether this user should be in the Burrow admin group."; + }; + }; + }); + default = [ ]; + description = "Declarative Burrow users to create in Authentik."; + }; }; config = lib.mkIf cfg.enable { @@ -295,6 +358,16 @@ EOF ]; }; + systemd.services.podman-burrow-authentik-server.restartTriggers = [ + blueprintFile + envFile + ]; + + systemd.services.podman-burrow-authentik-worker.restartTriggers = [ + blueprintFile + envFile + ]; + systemd.services.burrow-authentik-ready = { description = "Wait for Burrow Authentik to become ready"; after = [ "podman-burrow-authentik-server.service" ]; @@ -366,11 +439,66 @@ EOF export AUTHENTIK_GOOGLE_USER_MATCHING_MODE=email_link export AUTHENTIK_GOOGLE_CLIENT_ID="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientIDFile})" export AUTHENTIK_GOOGLE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.googleClientSecretFile})" + export AUTHENTIK_GOOGLE_ACCOUNT_MAP_JSON='${builtins.toJSON (map (user: { + source_email = user.sourceEmail; + username = user.username; + email = user.email; + name = user.name; + }) (lib.filter (user: user.sourceEmail != null) cfg.bootstrapUsers))}' ${pkgs.bash}/bin/bash ${googleSourceSyncScript} ''; }; + systemd.services.burrow-authentik-directory = lib.mkIf (cfg.bootstrapUsers != [ ]) { + description = "Reconcile Burrow Authentik users and groups"; + after = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; + wants = + [ + "burrow-authentik-ready.service" + "network-online.target" + ] + ++ lib.optionals (cfg.forgejoClientSecretFile != null) [ "burrow-authentik-forgejo-oidc.service" ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + directorySyncScript + cfg.envFile + ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + set -a + source ${lib.escapeShellArg cfg.envFile} + set +a + + export AUTHENTIK_URL=https://${cfg.domain} + export AUTHENTIK_BURROW_USERS_GROUP=${lib.escapeShellArg cfg.userGroupName} + export AUTHENTIK_BURROW_ADMINS_GROUP=${lib.escapeShellArg cfg.adminGroupName} + export AUTHENTIK_FORGEJO_APPLICATION_SLUG=${lib.escapeShellArg cfg.forgejoProviderSlug} + export AUTHENTIK_BURROW_DIRECTORY_JSON='${builtins.toJSON (map (user: { + inherit (user) username name email isAdmin; + groups = user.groups; + }) cfg.bootstrapUsers)}' + + ${pkgs.bash}/bin/bash ${directorySyncScript} + ''; + }; + systemd.services.burrow-authentik-forgejo-oidc = lib.mkIf (cfg.forgejoClientSecretFile != null) { description = "Reconcile the Burrow Authentik Forgejo OIDC application"; after = [ diff --git a/nixos/modules/burrow-forge.nix b/nixos/modules/burrow-forge.nix index 890e1d3..e2a57e0 100644 --- a/nixos/modules/burrow-forge.nix +++ b/nixos/modules/burrow-forge.nix @@ -92,6 +92,35 @@ in description = "OpenID Connect discovery URL for the Forgejo login source."; }; + oidcScopes = lib.mkOption { + type = with lib.types; listOf str; + default = [ + "openid" + "profile" + "email" + "groups" + ]; + description = "OIDC scopes requested from Authentik."; + }; + + oidcGroupClaimName = lib.mkOption { + type = lib.types.str; + default = "groups"; + description = "OIDC claim name that carries group membership."; + }; + + oidcAdminGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-admins"; + description = "OIDC group that should grant Forgejo admin access."; + }; + + oidcRestrictedGroup = lib.mkOption { + type = lib.types.str; + default = "burrow-users"; + description = "OIDC group that is required to log into Forgejo."; + }; + authorizedKeys = lib.mkOption { type = with lib.types; listOf str; default = [ ]; @@ -339,6 +368,10 @@ in --arg client_id ${lib.escapeShellArg cfg.oidcClientId} \ --arg client_secret "$oidc_secret" \ --arg discovery_url ${lib.escapeShellArg cfg.oidcDiscoveryUrl} \ + --argjson scopes '${builtins.toJSON cfg.oidcScopes}' \ + --arg group_claim_name ${lib.escapeShellArg cfg.oidcGroupClaimName} \ + --arg admin_group ${lib.escapeShellArg cfg.oidcAdminGroup} \ + --arg restricted_group ${lib.escapeShellArg cfg.oidcRestrictedGroup} \ '{ Provider: "openidConnect", ClientID: $client_id, @@ -346,15 +379,15 @@ in OpenIDConnectAutoDiscoveryURL: $discovery_url, CustomURLMapping: null, IconURL: "", - Scopes: ["openid", "profile", "email"], + Scopes: $scopes, AttributeSSHPublicKey: "", RequiredClaimName: "", RequiredClaimValue: "", - GroupClaimName: "", - AdminGroup: "", + GroupClaimName: $group_claim_name, + AdminGroup: $admin_group, GroupTeamMap: "", GroupTeamMapRemoval: false, - RestrictedGroup: "" + RestrictedGroup: $restricted_group }')" ${pkgs.postgresql}/bin/psql -v ON_ERROR_STOP=1 \