diff --git a/Scripts/authentik-sync-tailscale-oidc.sh b/Scripts/authentik-sync-tailscale-oidc.sh index 54564ad..9e01b97 100755 --- a/Scripts/authentik-sync-tailscale-oidc.sh +++ b/Scripts/authentik-sync-tailscale-oidc.sh @@ -10,6 +10,8 @@ template_slug="${AUTHENTIK_TAILSCALE_TEMPLATE_SLUG:-ts}" client_id="${AUTHENTIK_TAILSCALE_CLIENT_ID:-tailscale.burrow.net}" client_secret="${AUTHENTIK_TAILSCALE_CLIENT_SECRET:-}" launch_url="${AUTHENTIK_TAILSCALE_LAUNCH_URL:-https://login.tailscale.com/start/oidc}" +access_group="${AUTHENTIK_TAILSCALE_ACCESS_GROUP:-}" +default_external_application_slug="${AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG:-}" redirect_uris_json="${AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON:-[ \"https://login.tailscale.com/a/oauth_response\" ]}" @@ -31,6 +33,8 @@ Optional environment: AUTHENTIK_TAILSCALE_CLIENT_ID AUTHENTIK_TAILSCALE_LAUNCH_URL AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON + AUTHENTIK_TAILSCALE_ACCESS_GROUP + AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG EOF } @@ -123,6 +127,97 @@ wait_for_authentik() { wait_for_authentik +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +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 +} + +ensure_default_external_application() { + local application_slug="$1" + local application_pk default_brand brand_payload + + application_pk="$(lookup_application_pk "$application_slug")" + if [[ -z "$application_pk" ]]; then + echo "error: could not resolve Authentik application ${application_slug} for brand default application" >&2 + exit 1 + fi + + default_brand="$( + api GET "/api/v3/core/brands/?page_size=200" \ + | jq -c '.results[]? | select(.default == true)' \ + | head -n1 + )" + + if [[ -z "$default_brand" ]]; then + echo "warning: could not resolve the default Authentik brand; skipping external default application" >&2 + return 0 + fi + + brand_payload="$( + printf '%s\n' "$default_brand" \ + | jq --arg application_pk "$application_pk" '.default_application = $application_pk' + )" + + api PUT "/api/v3/core/brands/$(printf '%s\n' "$default_brand" | jq -r '.brand_uuid')/" "$brand_payload" >/dev/null +} + template_provider="$( api GET "/api/v3/providers/oauth2/?page_size=200" \ | jq -c --arg template_slug "$template_slug" '.results[]? | select(.assigned_application_slug == $template_slug)' \ @@ -239,6 +334,14 @@ if [[ -z "${application_pk:-}" ]]; then exit 1 fi +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +if [[ -n "$default_external_application_slug" ]]; then + ensure_default_external_application "$default_external_application_slug" +fi + for _ in $(seq 1 30); do if curl -fsS "${authentik_url}/application/o/${application_slug}/.well-known/openid-configuration" >/dev/null 2>&1; then echo "Synced Authentik Tailscale OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-zulip-saml.sh b/Scripts/authentik-sync-zulip-saml.sh new file mode 100644 index 0000000..d503ce0 --- /dev/null +++ b/Scripts/authentik-sync-zulip-saml.sh @@ -0,0 +1,398 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_ZULIP_APPLICATION_SLUG:-zulip}" +application_name="${AUTHENTIK_ZULIP_APPLICATION_NAME:-Zulip}" +provider_name="${AUTHENTIK_ZULIP_PROVIDER_NAME:-Zulip}" +acs_url="${AUTHENTIK_ZULIP_ACS_URL:-https://chat.burrow.net/complete/saml/}" +audience="${AUTHENTIK_ZULIP_AUDIENCE:-https://chat.burrow.net}" +launch_url="${AUTHENTIK_ZULIP_LAUNCH_URL:-https://chat.burrow.net/}" +access_group="${AUTHENTIK_ZULIP_ACCESS_GROUP:-}" +issuer="${AUTHENTIK_ZULIP_ISSUER:-$authentik_url}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-zulip-saml.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_ZULIP_APPLICATION_SLUG + AUTHENTIK_ZULIP_APPLICATION_NAME + AUTHENTIK_ZULIP_PROVIDER_NAME + AUTHENTIK_ZULIP_ACS_URL + AUTHENTIK_ZULIP_AUDIENCE + AUTHENTIK_ZULIP_LAUNCH_URL + AUTHENTIK_ZULIP_ACCESS_GROUP + AUTHENTIK_ZULIP_ISSUER +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 + +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 +} + +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 + return 0 + fi + sleep 2 + done + + echo "error: Authentik did not become ready at ${authentik_url}" >&2 + exit 1 +} + +lookup_oauth_template_field() { + local field="$1" + + api GET "/api/v3/providers/oauth2/?page_size=200" \ + | jq -r --arg field "$field" '.results[]? | select(.assigned_application_slug == "ts") | .[$field]' \ + | head -n1 +} + +lookup_group_pk() { + local group_name="$1" + + api GET "/api/v3/core/groups/?page_size=200" \ + | jq -r --arg group_name "$group_name" '.results[]? | select(.name == $group_name) | .pk // empty' \ + | head -n1 +} + +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 +} + +reconcile_property_mapping() { + local name="$1" + local saml_name="$2" + local friendly_name="$3" + local expression="$4" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg saml_name "$saml_name" \ + --arg friendly_name "$friendly_name" \ + --arg expression "$expression" \ + '{ + name: $name, + saml_name: $saml_name, + friendly_name: $friendly_name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/saml/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/saml/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/saml/" "$payload" | jq -r '.pk // empty' + fi +} + +wait_for_authentik + +authorization_flow="$(lookup_oauth_template_field authorization_flow)" +invalidation_flow="$(lookup_oauth_template_field invalidation_flow)" +signing_kp="$(lookup_oauth_template_field signing_key)" + +if [[ -z "$authorization_flow" || -z "$invalidation_flow" || -z "$signing_kp" ]]; then + echo "error: could not resolve Authentik provider defaults from Burrow Tailnet template" >&2 + exit 1 +fi + +email_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Email" \ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ + "email" \ + 'return request.user.email' +)" + +name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Name" \ + "name" \ + "name" \ + 'return request.user.name or request.user.username' +)" + +first_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML First Name" \ + "firstName" \ + "firstName" \ + $'parts = (request.user.name or "").split(" ", 1)\nif len(parts) > 0 and parts[0]:\n return parts[0]\nreturn request.user.username' +)" + +last_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Zulip SAML Last Name" \ + "lastName" \ + "lastName" \ + $'parts = (request.user.name or "").rsplit(" ", 1)\nif len(parts) == 2 and parts[1]:\n return parts[1]\nreturn request.user.username' +)" + +if [[ -z "$email_mapping_pk" || -z "$name_mapping_pk" || -z "$first_name_mapping_pk" || -z "$last_name_mapping_pk" ]]; then + echo "error: failed to reconcile Zulip SAML property mappings" >&2 + exit 1 +fi + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg acs_url "$acs_url" \ + --arg audience "$audience" \ + --arg issuer "$issuer" \ + --arg signing_kp "$signing_kp" \ + --arg name_id_mapping "$email_mapping_pk" \ + --arg email_mapping "$email_mapping_pk" \ + --arg name_mapping "$name_mapping_pk" \ + --arg first_name_mapping "$first_name_mapping_pk" \ + --arg last_name_mapping "$last_name_mapping_pk" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + acs_url: $acs_url, + audience: $audience, + issuer: $issuer, + signing_kp: $signing_kp, + sign_assertion: true, + sign_response: true, + sp_binding: "post", + name_id_mapping: $name_id_mapping, + property_mappings: [ + $email_mapping, + $name_mapping, + $first_name_mapping, + $last_name_mapping + ] + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/saml/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +)" + +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/saml/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/saml/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Zulip SAML provider did not return a primary key" >&2 + exit 1 +fi + +application_payload="$( + jq -n \ + --arg name "$application_name" \ + --arg slug "$application_slug" \ + --arg provider "$provider_pk" \ + --arg launch_url "$launch_url" \ + '{ + name: $name, + slug: $slug, + provider: ($provider | tonumber), + meta_launch_url: $launch_url, + open_in_new_tab: true, + policy_engine_mode: "any" + }' +)" + +existing_application="$( + 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="existing" + api PATCH "/api/v3/core/applications/${application_slug}/" "$application_payload" >/dev/null +else + 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 + echo "error: Zulip SAML application did not return a primary key" >&2 + exit 1 +fi + +if [[ -n "$access_group" ]]; then + ensure_application_group_binding "$application_slug" "$access_group" +fi + +for _ in $(seq 1 30); do + metadata_status="$( + curl -sS \ + -o /dev/null \ + -w '%{http_code}' \ + --max-redirs 0 \ + "${authentik_url}/application/saml/${application_slug}/metadata/" \ + || true + )" + case "$metadata_status" in + 200|301|302|307|308) + echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." + exit 0 + ;; + esac + sleep 2 +done + +echo "warning: Zulip SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Zulip SAML application ${application_slug} (${application_name})." diff --git a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md index 63e0994..ff6e63d 100644 --- a/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -68,6 +68,9 @@ across vendor-native Google auth flows when Burrow already operates an IdP. options instead of hand-edited UI state. - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds and host replacement converge automatically. +- When Burrow wants an external-user launcher surface in Authentik, configure + the brand's `default_application` explicitly instead of relying on + `/if/user/`, which otherwise remains internal-user-only. - Derive Linear SCIM role groups from Burrow's canonical identity metadata. If Burrow-wide admin intent says a user is an operator/admin, the repo-owned configuration should map that intent onto the Linear push group without a @@ -111,8 +114,10 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Verify: - `chat.burrow.net` serves a working Zulip login surface - Authentik exposes working metadata for Zulip and Linear - - Authentik exposes a working OIDC issuer for 1Password +- Authentik exposes a working OIDC issuer for 1Password - users in Burrow admin groups receive the expected access on first login + - external Burrow users landing on `auth.burrow.net` reach the intended + app launcher target instead of the internal-only Authentik user interface - Record concrete evidence for: - host deployment generation - Authentik reconciliation success diff --git a/flake.nix b/flake.nix index 1974f17..e842fba 100644 --- a/flake.nix +++ b/flake.nix @@ -214,6 +214,7 @@ nixosModules.burrow-forgejo-nsc = nsc-autoscaler.nixosModules.default; nixosModules.burrow-authentik = import ./nixos/modules/burrow-authentik.nix; nixosModules.burrow-headscale = import ./nixos/modules/burrow-headscale.nix; + nixosModules.burrow-zulip = import ./nixos/modules/burrow-zulip.nix; nixosConfigurations.burrow-forge = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 0121f92..2d943b9 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -61,6 +61,7 @@ in self.nixosModules.burrow-forgejo-nsc self.nixosModules.burrow-authentik self.nixosModules.burrow-headscale + self.nixosModules.burrow-zulip ]; system.stateVersion = "24.11"; @@ -162,9 +163,44 @@ in mode = "0400"; }; + age.secrets.burrowZulipPostgresPassword = { + file = ../../../secrets/infra/zulip-postgres-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipMemcachedPassword = { + file = ../../../secrets/infra/zulip-memcached-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipRabbitmqPassword = { + file = ../../../secrets/infra/zulip-rabbitmq-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipRedisPassword = { + file = ../../../secrets/infra/zulip-redis-password.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + + age.secrets.burrowZulipSecretKey = { + file = ../../../secrets/infra/zulip-secret-key.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; + networking.extraHosts = '' - 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net - ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net nsc-autoscaler.burrow.net + 127.0.0.1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net + ::1 burrow.net git.burrow.net auth.burrow.net ts.burrow.net chat.burrow.net nsc-autoscaler.burrow.net ''; services.burrow.forge = { @@ -208,6 +244,8 @@ in forgejoClientSecretFile = config.age.secrets.burrowForgejoOidcClientSecret.path; headscaleClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; tailscaleClientSecretFile = config.age.secrets.burrowTailscaleOidcClientSecret.path; + tailscaleAccessGroupName = contributors.groups.users; + defaultExternalApplicationSlug = "tailscale"; googleClientIDFile = config.age.secrets.burrowAuthentikGoogleClientId.path; googleClientSecretFile = config.age.secrets.burrowAuthentikGoogleClientSecret.path; googleAccountMapFile = config.age.secrets.burrowAuthentikGoogleAccountMap.path; @@ -224,6 +262,7 @@ in linearOwnerGroupName = linearGroups.owners; linearAdminGroupName = linearGroups.admins; linearGuestGroupName = linearGroups.guests; + zulipAccessGroupName = contributors.groups.users; }; services.burrow.headscale = { @@ -231,4 +270,14 @@ in oidcClientSecretFile = config.age.secrets.burrowHeadscaleOidcClientSecret.path; bootstrapUsers = headscaleBootstrapUsers; }; + + services.burrow.zulip = { + enable = true; + administratorEmail = identities.contact.canonicalEmail; + postgresPasswordFile = config.age.secrets.burrowZulipPostgresPassword.path; + memcachedPasswordFile = config.age.secrets.burrowZulipMemcachedPassword.path; + rabbitmqPasswordFile = config.age.secrets.burrowZulipRabbitmqPassword.path; + redisPasswordFile = config.age.secrets.burrowZulipRedisPassword.path; + secretKeyFile = config.age.secrets.burrowZulipSecretKey.path; + }; } diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 772adc4..acf76ce 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -12,6 +12,7 @@ let forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; + zulipSamlSyncScript = ../../Scripts/authentik-sync-zulip-saml.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; @@ -153,6 +154,18 @@ in description = "Host-local file containing the Authentik Tailscale OIDC client secret."; }; + tailscaleAccessGroupName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik group that should be allowed to launch the Tailscale application."; + }; + + defaultExternalApplicationSlug = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik application slug that external users should land on instead of /if/user/."; + }; + onePasswordDomain = lib.mkOption { type = lib.types.str; default = "burrow-team.1password.com"; @@ -186,6 +199,42 @@ in description = "Authentik application slug for Linear SAML."; }; + zulipDomain = lib.mkOption { + type = lib.types.str; + default = "chat.burrow.net"; + description = "Public Zulip domain exposed through Authentik SAML."; + }; + + zulipProviderSlug = lib.mkOption { + type = lib.types.str; + default = "zulip"; + description = "Authentik application slug for Zulip SAML."; + }; + + zulipAcsUrl = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}/complete/saml/"; + description = "Zulip SAML ACS URL."; + }; + + zulipAudience = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}"; + description = "Zulip SAML audience/entity identifier."; + }; + + zulipLaunchUrl = lib.mkOption { + type = lib.types.str; + default = "https://${config.services.burrow.authentik.zulipDomain}/"; + description = "Zulip URL exposed in Authentik."; + }; + + zulipAccessGroupName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Authentik group allowed to launch Zulip from Burrow SSO surfaces."; + }; + linearAcsUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -809,6 +858,12 @@ EOF export AUTHENTIK_TAILSCALE_CLIENT_SECRET="$(tr -d '\r\n' < ${lib.escapeShellArg cfg.tailscaleClientSecretFile})" export AUTHENTIK_TAILSCALE_LAUNCH_URL=https://login.tailscale.com/start/oidc export AUTHENTIK_TAILSCALE_REDIRECT_URIS_JSON='["https://login.tailscale.com/a/oauth_response"]' + ${lib.optionalString (cfg.tailscaleAccessGroupName != null) '' + export AUTHENTIK_TAILSCALE_ACCESS_GROUP=${lib.escapeShellArg cfg.tailscaleAccessGroupName} + ''} + ${lib.optionalString (cfg.defaultExternalApplicationSlug != null) '' + export AUTHENTIK_DEFAULT_EXTERNAL_APPLICATION_SLUG=${lib.escapeShellArg cfg.defaultExternalApplicationSlug} + ''} ${pkgs.bash}/bin/bash ${tailscaleOidcSyncScript} ''; @@ -859,6 +914,53 @@ EOF ''; }; + systemd.services.burrow-authentik-zulip-saml = { + description = "Reconcile the Burrow Authentik Zulip SAML application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + zulipSamlSyncScript + 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_ZULIP_APPLICATION_SLUG=${lib.escapeShellArg cfg.zulipProviderSlug} + export AUTHENTIK_ZULIP_APPLICATION_NAME=Zulip + export AUTHENTIK_ZULIP_PROVIDER_NAME=Zulip + export AUTHENTIK_ZULIP_ACS_URL=${lib.escapeShellArg cfg.zulipAcsUrl} + export AUTHENTIK_ZULIP_AUDIENCE=${lib.escapeShellArg cfg.zulipAudience} + export AUTHENTIK_ZULIP_LAUNCH_URL=${lib.escapeShellArg cfg.zulipLaunchUrl} + ${lib.optionalString (cfg.zulipAccessGroupName != null) '' + export AUTHENTIK_ZULIP_ACCESS_GROUP=${lib.escapeShellArg cfg.zulipAccessGroupName} + ''} + + ${pkgs.bash}/bin/bash ${zulipSamlSyncScript} + ''; + }; + systemd.services.burrow-authentik-linear-saml = lib.mkIf ( cfg.linearAcsUrl != null && cfg.linearAudience != null ) { diff --git a/nixos/modules/burrow-zulip.nix b/nixos/modules/burrow-zulip.nix new file mode 100644 index 0000000..0fcad65 --- /dev/null +++ b/nixos/modules/burrow-zulip.nix @@ -0,0 +1,354 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.burrow.zulip; + yamlFormat = pkgs.formats.yaml { }; + composeFile = yamlFormat.generate "burrow-zulip-compose.yaml" { + services = { + database = { + image = "zulip/zulip-postgresql:14"; + restart = "unless-stopped"; + secrets = [ "zulip__postgres_password" ]; + environment = { + POSTGRES_DB = "zulip"; + POSTGRES_USER = "zulip"; + POSTGRES_PASSWORD_FILE = "/run/secrets/zulip__postgres_password"; + }; + volumes = [ "postgresql-14:/var/lib/postgresql/data:rw" ]; + attach = false; + }; + memcached = { + image = "memcached:alpine"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + '' + echo 'mech_list: plain' > "$SASL_CONF_PATH" + echo "zulip@$HOSTNAME:$(cat $MEMCACHED_PASSWORD_FILE)" > "$MEMCACHED_SASL_PWDB" + echo "zulip@localhost:$(cat $MEMCACHED_PASSWORD_FILE)" >> "$MEMCACHED_SASL_PWDB" + exec memcached -S + '' + ]; + secrets = [ "zulip__memcached_password" ]; + environment = { + SASL_CONF_PATH = "/home/memcache/memcached.conf"; + MEMCACHED_SASL_PWDB = "/home/memcache/memcached-sasl-db"; + MEMCACHED_PASSWORD_FILE = "/run/secrets/zulip__memcached_password"; + }; + attach = false; + }; + rabbitmq = { + image = "rabbitmq:4.2"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + '' + export RABBITMQ_DEFAULT_PASS="$(cat "$RABBITMQ_PASSWORD_FILE")" + echo "default_user = $RABBITMQ_DEFAULT_USER" >> /etc/rabbitmq/rabbitmq.conf + echo "default_pass = $RABBITMQ_DEFAULT_PASS" >> /etc/rabbitmq/rabbitmq.conf + exec docker-entrypoint.sh rabbitmq-server + '' + ]; + secrets = [ "zulip__rabbitmq_password" ]; + environment = { + RABBITMQ_DEFAULT_USER = "zulip"; + RABBITMQ_PASSWORD_FILE = "/run/secrets/zulip__rabbitmq_password"; + }; + volumes = [ "rabbitmq:/var/lib/rabbitmq:rw" ]; + attach = false; + }; + redis = { + image = "redis:alpine"; + restart = "unless-stopped"; + command = [ + "sh" + "-euc" + "/usr/local/bin/docker-entrypoint.sh --requirepass \"$(cat \"$REDIS_PASSWORD_FILE\")\"" + ]; + secrets = [ "zulip__redis_password" ]; + environment = { + REDIS_PASSWORD_FILE = "/run/secrets/zulip__redis_password"; + }; + volumes = [ "redis:/data:rw" ]; + attach = false; + }; + zulip = { + image = "ghcr.io/zulip/zulip-server:11.6-1"; + restart = "unless-stopped"; + secrets = [ + "zulip__postgres_password" + "zulip__memcached_password" + "zulip__rabbitmq_password" + "zulip__redis_password" + "zulip__secret_key" + "zulip__email_password" + ]; + environment = { + SETTING_REMOTE_POSTGRES_HOST = "database"; + SETTING_MEMCACHED_LOCATION = "memcached:11211"; + SETTING_RABBITMQ_HOST = "rabbitmq"; + SETTING_REDIS_HOST = "redis"; + }; + volumes = [ "zulip:/data:rw" ]; + ulimits.nofile = { + soft = 1000000; + hard = 1048576; + }; + depends_on = [ + "database" + "memcached" + "rabbitmq" + "redis" + ]; + }; + }; + + volumes = { + zulip = { }; + postgresql-14 = { }; + rabbitmq = { }; + redis = { }; + }; + }; +in +{ + options.services.burrow.zulip = { + enable = lib.mkEnableOption "the Burrow Zulip deployment"; + + domain = lib.mkOption { + type = lib.types.str; + default = "chat.burrow.net"; + description = "Public Zulip domain."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 18090; + description = "Local loopback port Caddy should proxy to."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/burrow/zulip"; + description = "Host directory storing Zulip compose state and generated runtime files."; + }; + + administratorEmail = lib.mkOption { + type = lib.types.str; + default = "contact@burrow.net"; + description = "Operational Zulip administrator email."; + }; + + authentikDomain = lib.mkOption { + type = lib.types.str; + default = config.services.burrow.authentik.domain; + description = "Authentik domain Zulip should trust as its SAML IdP."; + }; + + authentikProviderSlug = lib.mkOption { + type = lib.types.str; + default = config.services.burrow.authentik.zulipProviderSlug; + description = "Authentik SAML application slug used for Zulip."; + }; + + postgresPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip PostgreSQL password."; + }; + + memcachedPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip memcached password."; + }; + + rabbitmqPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip RabbitMQ password."; + }; + + redisPasswordFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip Redis password."; + }; + + secretKeyFile = lib.mkOption { + type = lib.types.str; + description = "File containing the Zulip Django secret key."; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ + pkgs.podman + pkgs.podman-compose + ]; + + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' + encode gzip zstd + reverse_proxy 127.0.0.1:${toString cfg.port} + ''; + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0755 root root - -" + "d ${cfg.dataDir}/secrets 0700 root root - -" + "d ${cfg.dataDir}/logs 0755 root root - -" + ]; + + systemd.services.burrow-zulip-runtime = { + description = "Prepare Burrow Zulip compose and SAML runtime files"; + after = [ + "burrow-authentik-ready.service" + "burrow-authentik-zulip-saml.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "burrow-authentik-zulip-saml.service" + "network-online.target" + ]; + requiredBy = [ "burrow-zulip.service" ]; + before = [ "burrow-zulip.service" ]; + path = [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.python3 + ]; + restartTriggers = [ + composeFile + cfg.postgresPasswordFile + cfg.memcachedPasswordFile + cfg.rabbitmqPasswordFile + cfg.redisPasswordFile + cfg.secretKeyFile + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + }; + script = '' + set -euo pipefail + + install -d -m 0755 ${lib.escapeShellArg cfg.dataDir} + install -d -m 0700 ${lib.escapeShellArg "${cfg.dataDir}/secrets"} + install -d -m 0755 ${lib.escapeShellArg "${cfg.dataDir}/logs"} + install -m 0644 ${composeFile} ${lib.escapeShellArg "${cfg.dataDir}/compose.yaml"} + : > ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + chmod 0600 ${lib.escapeShellArg "${cfg.dataDir}/secrets/email-password"} + + metadata_xml="$(${pkgs.curl}/bin/curl -fsS https://${cfg.authentikDomain}/application/saml/${cfg.authentikProviderSlug}/metadata/)" + saml_cert="$(printf '%s' "$metadata_xml" | ${pkgs.python3}/bin/python3 -c ' +import re, sys, xml.etree.ElementTree as ET +xml = sys.stdin.read() +root = ET.fromstring(xml) +ns = {"md": "urn:oasis:names:tc:SAML:2.0:metadata", "ds": "http://www.w3.org/2000/09/xmldsig#"} +node = root.find(".//ds:X509Certificate", ns) +if node is None or not (node.text or "").strip(): + raise SystemExit("missing X509 certificate in Authentik metadata") +print((node.text or "").strip()) +')" + + cat > ${lib.escapeShellArg "${cfg.dataDir}/compose.override.yaml"} < ssh-ed25519 ux4N8Q x0r1UHgSibFIvKU34kP0+mnvQa5xXnac3P5fyqb7qFc +MfKnr5N0DV2NIoo4MFVFV0ULMayy0zzZqIq4FDzgDGc +-> ssh-ed25519 IrZmAg rzoR8knGrsTGuh9Hqg/NB0NQKI1vx1WI0ZRyrLIPwVY +7gV/d1slrIT+W0+iX5YK/uUWjHGJfee6vA+f9a35nEY +-> ssh-ed25519 0kWPgQ SyuEAfqmBAqLcuuQUHM5OzAv2hoquMMYtVdbKpBVhjI +7QqXens2363ln0euoormMh9a3Csh+nS2eBkHuQJmOWc +-> X25519 qDjNNkYBUhWTYyBhrw9tYl8a7G6TCkVZbR4aPcP+J0c +QF33V6hFUuYRj0B8Eo4jqyyvCpBbpD2ViVWoS8A8f3E +--- 1/Jb0nvWlcszMmxI0yVr6kfexDN0sSk1p+wsTUL4WvU +9a5IكV[f,Db \v&LZ7!?4=JxFeV \ No newline at end of file diff --git a/secrets/infra/zulip-postgres-password.age b/secrets/infra/zulip-postgres-password.age new file mode 100644 index 0000000..b03556c Binary files /dev/null and b/secrets/infra/zulip-postgres-password.age differ diff --git a/secrets/infra/zulip-rabbitmq-password.age b/secrets/infra/zulip-rabbitmq-password.age new file mode 100644 index 0000000..9b1f6ec --- /dev/null +++ b/secrets/infra/zulip-rabbitmq-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q s1hLIWvkXmlIv/VeHXpDSCe+dh09mE+iZd7xJiQccy0 +8WosTJQLGRPhTR06SIDjgtXNebcf+H/pFzY/lBCjXcs +-> ssh-ed25519 IrZmAg zBNlK+o/RCTCyp8BRkoAYqsDn//kIKtYk3SICkMu3BA +EhBQy8QdSnCZKkdGzQho7zEMmAbJVoU5jZOMPN6tHG0 +-> ssh-ed25519 0kWPgQ hv06idPXqAATkLeUC5vILdEO2NXNWPczlWnwMFvOdkA +3EeajviunGlcfcF1QlRJrVA9bwPT+fJZFX0uneYVs0c +-> X25519 vm9rPYnQB16VSidi7+nr70lFaH0W/jIGY8zwUObZUV8 +jFgPy/w4j0/p1USKGjQY+coo1OUFXiIjJ5apIZCrZVI +--- Cf2c6WzLYOi8xE/sIn7ZtUqBy5AToASDUNpAxyjrI9M +:,+!ϨϬB4DmH|(9l9LPZ^zed=imz? \ No newline at end of file diff --git a/secrets/infra/zulip-redis-password.age b/secrets/infra/zulip-redis-password.age new file mode 100644 index 0000000..2aff8b6 --- /dev/null +++ b/secrets/infra/zulip-redis-password.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q DqDE3ZZlPUWUyyLA185xsOmfGi146SNk+hENMQXaiFY +D6FhZgynbdccPJQiFRJ18EYvCyDLz3cak0YuQa4f5p4 +-> ssh-ed25519 IrZmAg lXgVeADmgjeHeVOOIS5oHqrhkN59ZWDemMOBJo3ubH8 +AQ24P+DnxNoHEguNnLaROIW4/Sq96w/UxzzQwEOyGRc +-> ssh-ed25519 0kWPgQ 8x0pMohdACYueLY6jbNwg7MYVaZcjwBU4axthvDoFx4 +SgUVnd6MK1MccWVYOu9R3PtoMCBBNGKQ7jt5MSA+KkI +-> X25519 UaO5huJPx8d8eMUnGhbI77tZjsFlIPWEffT4fgoO22w +DVz016ibRxJoa4TDmb2m0Qu9Dn8jpjWEBVtdm2TZx0c +--- 5+MHuvC26SjEBFSmRm0kXjiI27QnJGxvPl2w13EkMrw +FoQ]ȟeU//no.XGJ Э|+ž \ No newline at end of file diff --git a/secrets/infra/zulip-secret-key.age b/secrets/infra/zulip-secret-key.age new file mode 100644 index 0000000..d903d66 --- /dev/null +++ b/secrets/infra/zulip-secret-key.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q ml+kmLmuRb2nMXJyhKigby2+lPddxM/U7tjhGGQ/JGk +B3UCv/3+4GHeKR964o/m0CoicHwDgWQGEarPW94tb3I +-> ssh-ed25519 IrZmAg AO0ELOuGGj+WanDZFRkHKUEJyZqJYFdhWbqmUfwbpiM +5RZMxVBvW5+TzCBFnn66ry3o5V5cJykweyoYMVBgczY +-> ssh-ed25519 0kWPgQ gqQ/S33Re2OYLz1D9LoSAoqOKxuL4aUes8r6+NyAoXw +NHo2xFsxxJO1ZjnG9r3oxMuvjOUsCyyPvcar2ejZp9w +-> X25519 vUAjBCE197YsckVNM4SYVIPBEESTWnBPCWnUlEwYs1I +L3l85DXFoAVm2ssHfjBeqRpWGlo1UGbmcNkEgoUB9fM +--- X/2O8ufjbTGrt2zCm4gSRqqoxT5v6a+13XjH4dpRsHs +Mkf"(qxF2BdMRYji ܴ<ґb_.!r+<Ussu?gD\V am(Ȉ&.& c/|w(WH4rѠ+j"B  \ No newline at end of file