From 4d3257995b2f2f4681aa41a3ec167238345bbde1 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:10:18 -0700 Subject: [PATCH] Add Authentik SSO apps for Linear and 1Password --- Scripts/authentik-sync-1password-oidc.sh | 243 +++++++++++++ Scripts/authentik-sync-linear-saml.sh | 334 ++++++++++++++++++ ...ntik-backed-team-chat-and-workspace-sso.md | 152 ++++++++ nixos/hosts/burrow-forge/default.nix | 3 + nixos/modules/burrow-authentik.nix | 153 ++++++++ 5 files changed, 885 insertions(+) create mode 100755 Scripts/authentik-sync-1password-oidc.sh create mode 100755 Scripts/authentik-sync-linear-saml.sh create mode 100644 evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md diff --git a/Scripts/authentik-sync-1password-oidc.sh b/Scripts/authentik-sync-1password-oidc.sh new file mode 100755 index 0000000..f523d9a --- /dev/null +++ b/Scripts/authentik-sync-1password-oidc.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG:-onepassword}" +application_name="${AUTHENTIK_ONEPASSWORD_APPLICATION_NAME:-1Password}" +provider_name="${AUTHENTIK_ONEPASSWORD_PROVIDER_NAME:-1Password}" +template_slug="${AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG:-ts}" +client_id="${AUTHENTIK_ONEPASSWORD_CLIENT_ID:-1password.burrow.net}" +launch_url="${AUTHENTIK_ONEPASSWORD_LAUNCH_URL:-https://burrow-team.1password.com/}" +redirect_uris_json="${AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON:-[ + \"https://burrow-team.1password.com/sso/oidc/redirect/\", + \"onepassword://sso/oidc/redirect\" +]}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-1password-oidc.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_ONEPASSWORD_APPLICATION_SLUG + AUTHENTIK_ONEPASSWORD_APPLICATION_NAME + AUTHENTIK_ONEPASSWORD_PROVIDER_NAME + AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG + AUTHENTIK_ONEPASSWORD_CLIENT_ID + AUTHENTIK_ONEPASSWORD_LAUNCH_URL + AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON +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' "$redirect_uris_json" | jq -e 'type == "array" and length > 0' >/dev/null; then + echo "error: AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON must be a non-empty 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 +} + +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 +} + +wait_for_authentik + +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)' \ + | head -n1 +)" + +if [[ -z "$template_provider" ]]; then + echo "error: could not resolve the Authentik OAuth provider template ${template_slug}" >&2 + exit 1 +fi + +authorization_flow="$(printf '%s\n' "$template_provider" | jq -r '.authorization_flow')" +invalidation_flow="$(printf '%s\n' "$template_provider" | jq -r '.invalidation_flow')" +property_mappings="$(printf '%s\n' "$template_provider" | jq -c '.property_mappings')" +signing_key="$(printf '%s\n' "$template_provider" | jq -r '.signing_key')" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg authorization_flow "$authorization_flow" \ + --arg invalidation_flow "$invalidation_flow" \ + --arg client_id "$client_id" \ + --arg signing_key "$signing_key" \ + --argjson property_mappings "$property_mappings" \ + --argjson redirect_uris "$redirect_uris_json" \ + '{ + name: $name, + authorization_flow: $authorization_flow, + invalidation_flow: $invalidation_flow, + client_type: "public", + client_id: $client_id, + include_claims_in_id_token: true, + redirect_uris: ($redirect_uris | map({matching_mode: "strict", url: .})), + property_mappings: $property_mappings, + signing_key: $signing_key, + issuer_mode: "per_provider", + sub_mode: "hashed_user_id" + }' +)" + +existing_provider="$( + api GET "/api/v3/providers/oauth2/?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/oauth2/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/oauth2/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: 1Password OIDC 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="$(printf '%s\n' "$existing_application" | jq -r '.pk')" +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: 1Password OIDC application did not return a primary key" >&2 + exit 1 +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 1Password OIDC application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: 1Password OIDC issuer document for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik 1Password OIDC application ${application_slug} (${application_name})." diff --git a/Scripts/authentik-sync-linear-saml.sh b/Scripts/authentik-sync-linear-saml.sh new file mode 100755 index 0000000..9bead9f --- /dev/null +++ b/Scripts/authentik-sync-linear-saml.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +set -euo pipefail + +authentik_url="${AUTHENTIK_URL:-https://auth.burrow.net}" +bootstrap_token="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" +application_slug="${AUTHENTIK_LINEAR_APPLICATION_SLUG:-linear}" +application_name="${AUTHENTIK_LINEAR_APPLICATION_NAME:-Linear}" +provider_name="${AUTHENTIK_LINEAR_PROVIDER_NAME:-Linear}" +launch_url="${AUTHENTIK_LINEAR_LAUNCH_URL:-https://linear.app/burrownet}" +acs_url="${AUTHENTIK_LINEAR_ACS_URL:-}" +audience="${AUTHENTIK_LINEAR_AUDIENCE:-}" +issuer="${AUTHENTIK_LINEAR_ISSUER:-${authentik_url}/application/saml/${application_slug}/metadata/}" +default_relay_state="${AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE:-}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-linear-saml.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_LINEAR_ACS_URL + AUTHENTIK_LINEAR_AUDIENCE + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_LINEAR_APPLICATION_SLUG + AUTHENTIK_LINEAR_APPLICATION_NAME + AUTHENTIK_LINEAR_PROVIDER_NAME + AUTHENTIK_LINEAR_LAUNCH_URL + AUTHENTIK_LINEAR_ISSUER + AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE +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 [[ -z "$acs_url" ]]; then + echo "error: AUTHENTIK_LINEAR_ACS_URL is required" >&2 + exit 1 +fi + +if [[ -z "$audience" ]]; then + echo "error: AUTHENTIK_LINEAR_AUDIENCE 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 +} + +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 Linear SAML Email" \ + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" \ + "email" \ + 'return request.user.email' +)" + +name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear SAML Name" \ + "name" \ + "name" \ + 'return request.user.name or request.user.username' +)" + +first_name_mapping_pk="$( + reconcile_property_mapping \ + "Burrow Linear 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 Linear 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 Linear 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 default_relay_state "$default_relay_state" \ + --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 + ] + } + + (if $default_relay_state == "" then {} else {default_relay_state: $default_relay_state} end)' +)" + +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: Linear 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="$(printf '%s\n' "$existing_application" | jq -r '.pk')" + api PATCH "/api/v3/core/applications/${application_pk}/" "$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: Linear SAML application did not return a primary key" >&2 + exit 1 +fi + +for _ in $(seq 1 30); do + if curl -fsS "${authentik_url}/application/saml/${application_slug}/metadata/" >/dev/null 2>&1; then + echo "Synced Authentik Linear SAML application ${application_slug} (${application_name})." + exit 0 + fi + sleep 2 +done + +echo "warning: Linear SAML metadata for ${application_slug} was not immediately readable; keeping reconciled config." >&2 +echo "Synced Authentik Linear 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 new file mode 100644 index 0000000..6c11dbc --- /dev/null +++ b/evolution/proposals/BEP-0008-authentik-backed-team-chat-and-workspace-sso.md @@ -0,0 +1,152 @@ +# `BEP-0008` - Authentik-Backed Team Chat and Workspace Identity + +```text +Status: Draft +Proposal: BEP-0008 +Authors: gpt-5.4 +Coordinator: gpt-5.4 +Reviewers: Pending +Constitution Sections: II, III, V +Implementation PRs: Pending +Decision Date: Pending +``` + +## Summary + +Burrow should add a self-hosted team chat surface at `chat.burrow.net` and +continue the project-wide move toward Authentik as the identity authority for +external work systems. The immediate targets are a self-hosted Zulip +deployment rooted in Authentik SAML, a Linear SAML configuration when the +workspace plan supports it, and a 1Password Unlock-with-SSO deployment rooted +in the same Authentik-backed OIDC authority. + +This keeps Burrow's day-to-day coordination surfaces aligned with the same +admin groups, canonical users, and secret-handling model already used for +Forgejo, Headscale, and Tailscale. It also avoids fragmenting login state +across vendor-native Google auth flows when Burrow already operates an IdP. + +## Motivation + +- Forge, Tailnet, operator identity, and Tailscale custom OIDC are already + rooted in Authentik. Team chat, work tracking, and password-manager access + should not become separate authority islands. +- Zulip provides a self-hosted chat system under Burrow's control, which fits + the constitution better than adding another hosted chat dependency. +- Linear remains a SaaS dependency, but its workspace access should still be + derived from Burrow-managed identities and domains when the vendor plan + exposes SAML configuration. +- 1Password Business is another external work surface where Burrow-controlled + identities are preferable to vendor-native Google-only auth. Its current + vendor flow is OIDC-based Unlock with SSO rather than SAML, so the proposal + needs to preserve protocol accuracy instead of flattening everything into + one SAML bucket. +- Burrow already has a canonical public identity registry and a secret-backed + external-email alias map. Reusing that structure is lower-risk than + inventing per-app user bootstrap logic. + +## Detailed Design + +- Add a Burrow-managed Zulip workload on the forge host at `chat.burrow.net`. + The deployment should be repo-owned and rebuildable from Nix, even if the + runtime uses vendor-supported container images internally. +- Zulip should authenticate through Authentik SAML rather than local passwords + as the primary path. Initial bootstrap may still keep an operational escape + hatch while the deployment is being validated. +- Add Authentik-managed SAML applications for: + - Zulip at `chat.burrow.net` + - Linear using Burrow's claimed domains and Authentik metadata +- Add an Authentik-managed OIDC application for 1Password Business under the + Burrow team sign-in address. +- Treat Zulip and Linear as downstream applications of the same identity + authority, and treat 1Password as part of that same authority even though + its vendor protocol is OIDC rather than SAML. The source of truth remains: + - public identities and admin intent in `contributors.nix` + - private alias mappings and external accounts in agenix-encrypted secrets +- Keep app-specific configuration in dedicated reconciliation code or module + options instead of hand-edited UI state. +- Prefer service-specific reconciliation over ad hoc manual setup so rebuilds + and host replacement converge automatically. +- Model 1Password according to the vendor's actual integration contract: + - OIDC Authorization Code Flow with PKCE + - public client rather than a confidential client + - no Burrow-side dependence on a stored client secret unless the vendor flow + changes + +## Security and Operational Considerations + +- Do not store external personal email mappings in public registry files. + Public tree data may include Burrow usernames and canonical `@burrow.net` + addresses, but external aliases must stay in encrypted secrets. +- Zulip internal service credentials, Django secret material, and any mail + credentials must have explicit storage and rotation paths. +- Linear SAML must not become Burrow's only admin recovery path. At least one + owner login path outside the enforced SAML flow should remain available until + rollout is proven. +- 1Password Owners cannot be forced onto Unlock with SSO during initial setup. + Burrow should preserve the owner recovery path and treat OIDC rollout as a + scoped migration for non-owner users first. +- If Zulip is deployed without production-grade outbound email at first, that + limitation must be documented and treated as an operational constraint, not a + hidden assumption. +- Rollback should be straightforward: + - disable or stop the Zulip module + - remove the Authentik SAML apps + - remove the Authentik OIDC app used for 1Password if necessary + - leave the underlying Burrow identities unchanged + +## Contributor Playbook + +- Define the app and identity intent in the repository before modifying the + forge host. +- Add or update Nix modules so `burrow-forge` can rebuild Zulip and the + corresponding Authentik SAML configuration from the tree. +- 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 + - users in Burrow admin groups receive the expected access on first login +- Record concrete evidence for: + - host deployment generation + - Authentik reconciliation success + - Zulip login success + - Linear SAML configuration state + - 1Password Unlock with SSO configuration state + +## Alternatives Considered + +- Use Zulip Cloud instead of self-hosting. Rejected because the ask is to host + chat under `chat.burrow.net`, and Burrow already operates a forge host with a + self-managed identity plane. +- Keep Linear on Google-native login. Rejected because it leaves Burrow work + access outside the project's operator and group model. +- Treat 1Password as a SAML app for consistency. Rejected because the live + vendor flow is OIDC and Burrow should not pretend otherwise in repo-owned + infrastructure. +- Add per-app manual Authentik configuration without repository automation. + Rejected because it violates Burrow's infrastructure-in-repo commitment. + +## Impact on Other Work + +- Extends Burrow's Authentik role from control-plane identity into team-work + surfaces. +- Introduces a persistent chat workload on the forge host, with resource and + monitoring implications. +- Creates a likely follow-up for SCIM or richer group synchronization if Linear + or Zulip role mapping needs to become fully declarative later. +- Adds a second OIDC relying party beyond Forgejo, Headscale, and Tailscale, + which raises the importance of keeping Burrow's Authentik scope mappings and + redirect handling consistent across applications. + +## Decision + +Pending. + +## References + +- `CONSTITUTION.md` +- `contributors.nix` +- `evolution/proposals/BEP-0004-hosted-mail-and-saas-identity.md` +- Authentik docs: SAML provider and metadata endpoints +- Zulip docs: SAML authentication and docker deployment +- Linear docs: SAML and access control +- 1Password docs: Unlock with SSO using OpenID Connect diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 96eca4f..3f73346 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -207,6 +207,9 @@ in userGroupName = contributors.groups.users; adminGroupName = contributors.groups.admins; bootstrapUsers = bootstrapUsers; + linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; + linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; + linearDefaultRelayState = "https://linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 2fa83da..5b04de2 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -11,6 +11,8 @@ let directorySyncScript = ../../Scripts/authentik-sync-burrow-directory.sh; forgejoOidcSyncScript = ../../Scripts/authentik-sync-forgejo-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; + onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; + linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -150,6 +152,63 @@ in description = "Host-local file containing the Authentik Tailscale OIDC client secret."; }; + onePasswordDomain = lib.mkOption { + type = lib.types.str; + default = "burrow-team.1password.com"; + description = "1Password team sign-in domain used for Burrow Unlock with SSO."; + }; + + onePasswordProviderSlug = lib.mkOption { + type = lib.types.str; + default = "onepassword"; + description = "Authentik application slug for 1Password Unlock with SSO."; + }; + + onePasswordClientId = lib.mkOption { + type = lib.types.str; + default = "1password.burrow.net"; + description = "Public OIDC client ID Authentik should present to 1Password."; + }; + + onePasswordRedirectUris = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ + "https://burrow-team.1password.com/sso/oidc/redirect/" + "onepassword://sso/oidc/redirect" + ]; + description = "Allowed 1Password OIDC redirect URIs."; + }; + + linearProviderSlug = lib.mkOption { + type = lib.types.str; + default = "linear"; + description = "Authentik application slug for Linear SAML."; + }; + + linearAcsUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SAML ACS URL."; + }; + + linearAudience = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SAML audience/entity identifier."; + }; + + linearLaunchUrl = lib.mkOption { + type = lib.types.str; + default = "https://linear.app/burrownet"; + description = "Linear workspace URL exposed in Authentik."; + }; + + linearDefaultRelayState = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional Linear relay state or login URL for IdP-initiated launches."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -718,6 +777,100 @@ EOF ''; }; + systemd.services.burrow-authentik-1password-oidc = { + description = "Reconcile the Burrow Authentik 1Password OIDC application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + onePasswordOidcSyncScript + 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_ONEPASSWORD_APPLICATION_SLUG=${lib.escapeShellArg cfg.onePasswordProviderSlug} + export AUTHENTIK_ONEPASSWORD_APPLICATION_NAME=1Password + export AUTHENTIK_ONEPASSWORD_PROVIDER_NAME=1Password + export AUTHENTIK_ONEPASSWORD_TEMPLATE_SLUG=${lib.escapeShellArg cfg.headscaleProviderSlug} + export AUTHENTIK_ONEPASSWORD_CLIENT_ID=${lib.escapeShellArg cfg.onePasswordClientId} + export AUTHENTIK_ONEPASSWORD_LAUNCH_URL=https://${cfg.onePasswordDomain}/ + export AUTHENTIK_ONEPASSWORD_REDIRECT_URIS_JSON='${builtins.toJSON cfg.onePasswordRedirectUris}' + + ${pkgs.bash}/bin/bash ${onePasswordOidcSyncScript} + ''; + }; + + systemd.services.burrow-authentik-linear-saml = lib.mkIf ( + cfg.linearAcsUrl != null && cfg.linearAudience != null + ) { + description = "Reconcile the Burrow Authentik Linear SAML application"; + after = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + linearSamlSyncScript + 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_LINEAR_APPLICATION_SLUG=${lib.escapeShellArg cfg.linearProviderSlug} + export AUTHENTIK_LINEAR_APPLICATION_NAME=Linear + export AUTHENTIK_LINEAR_PROVIDER_NAME=Linear + export AUTHENTIK_LINEAR_ACS_URL=${lib.escapeShellArg cfg.linearAcsUrl} + export AUTHENTIK_LINEAR_AUDIENCE=${lib.escapeShellArg cfg.linearAudience} + export AUTHENTIK_LINEAR_LAUNCH_URL=${lib.escapeShellArg cfg.linearLaunchUrl} + ${lib.optionalString (cfg.linearDefaultRelayState != null) '' + export AUTHENTIK_LINEAR_DEFAULT_RELAY_STATE=${lib.escapeShellArg cfg.linearDefaultRelayState} + ''} + + ${pkgs.bash}/bin/bash ${linearSamlSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port}