From ebcfc4bf8d157395fc23bb0f0ccabb53a3910b82 Mon Sep 17 00:00:00 2001 From: Conrad Kramer Date: Sat, 18 Apr 2026 19:23:53 -0700 Subject: [PATCH] Add Linear SCIM role sync --- Scripts/authentik-sync-linear-scim.sh | 311 ++++++++++++++++++ contributors.nix | 5 + ...ntik-backed-team-chat-and-workspace-sso.md | 8 + nixos/hosts/burrow-forge/default.nix | 14 + nixos/modules/burrow-authentik.nix | 90 +++++ secrets.nix | 1 + secrets/infra/linear-scim-token.age | 11 + 7 files changed, 440 insertions(+) create mode 100644 Scripts/authentik-sync-linear-scim.sh create mode 100644 secrets/infra/linear-scim-token.age diff --git a/Scripts/authentik-sync-linear-scim.sh b/Scripts/authentik-sync-linear-scim.sh new file mode 100644 index 0000000..b689212 --- /dev/null +++ b/Scripts/authentik-sync-linear-scim.sh @@ -0,0 +1,311 @@ +#!/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}" +provider_name="${AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME:-Linear SCIM}" +scim_url="${AUTHENTIK_LINEAR_SCIM_URL:-}" +scim_token_file="${AUTHENTIK_LINEAR_SCIM_TOKEN_FILE:-}" +user_identifier="${AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER:-email}" +owner_group="${AUTHENTIK_LINEAR_OWNER_GROUP:-linear-owners}" +admin_group="${AUTHENTIK_LINEAR_ADMIN_GROUP:-linear-admins}" +guest_group="${AUTHENTIK_LINEAR_GUEST_GROUP:-linear-guests}" + +usage() { + cat <<'EOF' +Usage: Scripts/authentik-sync-linear-scim.sh + +Required environment: + AUTHENTIK_BOOTSTRAP_TOKEN + AUTHENTIK_LINEAR_SCIM_URL + AUTHENTIK_LINEAR_SCIM_TOKEN_FILE + +Optional environment: + AUTHENTIK_URL + AUTHENTIK_LINEAR_APPLICATION_SLUG + AUTHENTIK_LINEAR_SCIM_PROVIDER_NAME + AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER + AUTHENTIK_LINEAR_OWNER_GROUP + AUTHENTIK_LINEAR_ADMIN_GROUP + AUTHENTIK_LINEAR_GUEST_GROUP +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 "$scim_url" ]]; then + echo "error: AUTHENTIK_LINEAR_SCIM_URL is required" >&2 + exit 1 +fi + +if [[ -z "$scim_token_file" || ! -s "$scim_token_file" ]]; then + echo "error: AUTHENTIK_LINEAR_SCIM_TOKEN_FILE is required and must be readable" >&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 reconcile Authentik group ${group_name}" >&2 + exit 1 + fi + + printf '%s\n' "$group_pk" +} + +lookup_application() { + api GET "/api/v3/core/applications/?page_size=200" \ + | jq -c --arg slug "$application_slug" '.results[]? | select(.slug == $slug)' \ + | head -n1 +} + +lookup_scim_provider() { + api GET "/api/v3/providers/scim/?page_size=200" \ + | jq -c \ + --arg application_slug "$application_slug" \ + --arg provider_name "$provider_name" \ + '.results[]? | select(.assigned_backchannel_application_slug == $application_slug or .name == $provider_name)' \ + | head -n1 +} + +lookup_scim_mapping_pk() { + local managed_name="$1" + + api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ + | jq -r --arg managed "$managed_name" '.results[]? | select(.managed == $managed) | .pk // empty' \ + | head -n1 +} + +reconcile_property_mapping() { + local name="$1" + local expression="$2" + local payload existing_pk + + payload="$( + jq -n \ + --arg name "$name" \ + --arg expression "$expression" \ + '{ + name: $name, + expression: $expression + }' + )" + + existing_pk="$( + api GET "/api/v3/propertymappings/provider/scim/?page_size=200" \ + | jq -r --arg name "$name" '.results[]? | select(.name == $name) | .pk // empty' \ + | head -n1 + )" + + if [[ -n "$existing_pk" ]]; then + api PATCH "/api/v3/propertymappings/provider/scim/${existing_pk}/" "$payload" >/dev/null + printf '%s\n' "$existing_pk" + else + api POST "/api/v3/propertymappings/provider/scim/" "$payload" \ + | jq -r '.pk // empty' + fi +} + +sync_object() { + local provider_pk="$1" + local model="$2" + local object_id="$3" + + api POST "/api/v3/providers/scim/${provider_pk}/sync/object/" "$( + jq -cn \ + --arg model "$model" \ + --arg object_id "$object_id" \ + '{ + sync_object_model: $model, + sync_object_id: $object_id, + override_dry_run: false + }' + )" >/dev/null +} + +wait_for_authentik + +group_mapping_pk="$(lookup_scim_mapping_pk "goauthentik.io/providers/scim/group")" +case "$user_identifier" in + email) + user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\n\nidentifier = request.user.email\nif identifier == "":\n identifier = request.user.username\n\nreturn {\n "userName": identifier,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' + ;; + username) + user_mapping_expression=$'# Some implementations require givenName and familyName to be set\ngivenName, familyName = request.user.name, " "\nformatted = request.user.name + " "\nif " " in request.user.name:\n givenName, _, familyName = request.user.name.partition(" ")\n formatted = request.user.name\n\navatar = request.user.avatar\nphotos = None\nif "://" in avatar:\n photos = [{"value": avatar, "type": "photo"}]\n\nlocale = request.user.locale()\nif locale == "":\n locale = None\n\nemails = []\nif request.user.email != "":\n emails = [{\n "value": request.user.email,\n "type": "other",\n "primary": True,\n }]\nreturn {\n "userName": request.user.username,\n "name": {\n "formatted": formatted,\n "givenName": givenName,\n "familyName": familyName,\n },\n "displayName": request.user.name,\n "photos": photos,\n "locale": locale,\n "active": request.user.is_active,\n "emails": emails,\n}' + ;; + *) + echo "error: unsupported AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER value: ${user_identifier}" >&2 + exit 1 + ;; +esac +user_mapping_pk="$(reconcile_property_mapping "Burrow Linear SCIM User" "$user_mapping_expression")" + +if [[ -z "$user_mapping_pk" || -z "$group_mapping_pk" ]]; then + echo "error: could not resolve managed Authentik SCIM property mappings" >&2 + exit 1 +fi + +owner_group_pk="$(ensure_group "$owner_group")" +admin_group_pk="$(ensure_group "$admin_group")" +guest_group_pk="$(ensure_group "$guest_group")" + +provider_payload="$( + jq -n \ + --arg name "$provider_name" \ + --arg url "$scim_url" \ + --arg token "$(tr -d '\r\n' < "$scim_token_file")" \ + --arg user_mapping_pk "$user_mapping_pk" \ + --arg group_mapping_pk "$group_mapping_pk" \ + --arg owner_group_pk "$owner_group_pk" \ + --arg admin_group_pk "$admin_group_pk" \ + --arg guest_group_pk "$guest_group_pk" \ + '{ + name: $name, + url: $url, + token: $token, + auth_mode: "token", + verify_certificates: true, + compatibility_mode: "default", + property_mappings: [$user_mapping_pk], + property_mappings_group: [$group_mapping_pk], + group_filters: [ + $owner_group_pk, + $admin_group_pk, + $guest_group_pk + ], + dry_run: false + }' +)" + +existing_provider="$(lookup_scim_provider)" +if [[ -n "$existing_provider" ]]; then + provider_pk="$(printf '%s\n' "$existing_provider" | jq -r '.pk')" + api PATCH "/api/v3/providers/scim/${provider_pk}/" "$provider_payload" >/dev/null +else + provider_pk="$( + api POST "/api/v3/providers/scim/" "$provider_payload" \ + | jq -r '.pk // empty' + )" +fi + +if [[ -z "${provider_pk:-}" ]]; then + echo "error: Linear SCIM provider did not return a primary key" >&2 + exit 1 +fi + +application="$(lookup_application)" +if [[ -z "$application" ]]; then + echo "error: could not resolve Authentik application ${application_slug}" >&2 + exit 1 +fi + +application_pk="$(printf '%s\n' "$application" | jq -r '.pk')" +application_payload="$( + printf '%s\n' "$application" \ + | jq \ + --arg provider_pk "$provider_pk" \ + '{ + name: .name, + slug: .slug, + provider: .provider, + backchannel_providers: ((.backchannel_providers // []) + [($provider_pk | tonumber)] | unique), + open_in_new_tab: .open_in_new_tab, + meta_launch_url: .meta_launch_url, + policy_engine_mode: .policy_engine_mode + }' +)" +api PATCH "/api/v3/core/applications/${application_pk}/" "$application_payload" >/dev/null + +group_pks_json="$(jq -cn --arg owner "$owner_group_pk" --arg admin "$admin_group_pk" --arg guest "$guest_group_pk" '[$owner, $admin, $guest]')" +user_pks_json="$( + api GET "/api/v3/core/users/?page_size=200" \ + | jq -c \ + --argjson group_pks "$group_pks_json" \ + '[.results[]? + | select( + ([((.groups // [])[] | tostring)] as $user_groups + | ($group_pks | map(. as $wanted | ($user_groups | index($wanted)) != null) | any)) + ) + | .pk]' +)" + +while IFS= read -r group_pk; do + [[ -z "$group_pk" ]] && continue + sync_object "$provider_pk" "authentik.core.models.Group" "$group_pk" +done < <(printf '%s\n' "$group_pks_json" | jq -r '.[]') + +while IFS= read -r user_pk; do + [[ -z "$user_pk" ]] && continue + sync_object "$provider_pk" "authentik.core.models.User" "$user_pk" +done < <(printf '%s\n' "$user_pks_json" | jq -r '.[]') + +status_json="$(api GET "/api/v3/providers/scim/${provider_pk}/sync/status/")" +if ! printf '%s\n' "$status_json" | jq -e '.task_count >= 0' >/dev/null 2>&1; then + echo "error: could not read Linear SCIM sync status for provider ${provider_pk}" >&2 + exit 1 +fi + +echo "Synced Authentik Linear SCIM provider ${provider_name} (${provider_pk}) with groups ${owner_group}, ${admin_group}, ${guest_group}." diff --git a/contributors.nix b/contributors.nix index df76a01..60501d1 100644 --- a/contributors.nix +++ b/contributors.nix @@ -2,6 +2,11 @@ groups = { users = "burrow-users"; admins = "burrow-admins"; + linear = { + owners = "linear-owners"; + admins = "linear-admins"; + guests = "linear-guests"; + }; }; identities = { 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 6c11dbc..63e0994 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 @@ -55,6 +55,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - Add Authentik-managed SAML applications for: - Zulip at `chat.burrow.net` - Linear using Burrow's claimed domains and Authentik metadata +- Add an Authentik-managed SCIM backchannel for Linear so Burrow can push + role groups declaratively instead of hand-maintaining workspace roles. - 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 @@ -66,6 +68,10 @@ 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. +- 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 + second manual roster. - Model 1Password according to the vendor's actual integration contract: - OIDC Authorization Code Flow with PKCE - public client rather than a confidential client @@ -82,6 +88,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP. - 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. +- Linear SCIM group push should be role-scoped and explicit. Burrow should + avoid blanket ownership mapping unless that intent is recorded in the repo. - 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. diff --git a/nixos/hosts/burrow-forge/default.nix b/nixos/hosts/burrow-forge/default.nix index 3f73346..0121f92 100644 --- a/nixos/hosts/burrow-forge/default.nix +++ b/nixos/hosts/burrow-forge/default.nix @@ -3,6 +3,7 @@ let contributors = import ../../../contributors.nix; identities = contributors.identities; + linearGroups = contributors.groups.linear; stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value; authentikPasswordSecretPath = identity: if identity ? authentikPasswordSecret @@ -15,6 +16,7 @@ let name = identity.displayName; email = identity.canonicalEmail; isAdmin = identity.isAdmin or false; + groups = lib.optionals (identity.isAdmin or false) [ linearGroups.owners ]; passwordFile = authentikPasswordSecretPath identity; } ) @@ -111,6 +113,12 @@ in group = "root"; mode = "0400"; }; + age.secrets.burrowLinearScimToken = { + file = ../../../secrets/infra/linear-scim-token.age; + owner = "root"; + group = "root"; + mode = "0400"; + }; age.secrets.burrowAuthentikGoogleClientId = { file = ../../../secrets/infra/authentik-google-client-id.age; owner = "root"; @@ -210,6 +218,12 @@ in 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"; + linearScimUrl = "https://api.linear.app/auth/scim/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; + linearScimTokenFile = config.age.secrets.burrowLinearScimToken.path; + linearScimUserIdentifier = "email"; + linearOwnerGroupName = linearGroups.owners; + linearAdminGroupName = linearGroups.admins; + linearGuestGroupName = linearGroups.guests; }; services.burrow.headscale = { diff --git a/nixos/modules/burrow-authentik.nix b/nixos/modules/burrow-authentik.nix index 5b04de2..772adc4 100644 --- a/nixos/modules/burrow-authentik.nix +++ b/nixos/modules/burrow-authentik.nix @@ -13,6 +13,7 @@ let tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; + linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' @@ -209,6 +210,42 @@ in description = "Optional Linear relay state or login URL for IdP-initiated launches."; }; + linearScimUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Linear SCIM base connector URL."; + }; + + linearScimTokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Host-local file containing the Linear SCIM bearer token."; + }; + + linearScimUserIdentifier = lib.mkOption { + type = lib.types.str; + default = "email"; + description = "Linear SCIM unique identifier field for users."; + }; + + linearOwnerGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-owners"; + description = "Authentik group name that should map to Linear owners."; + }; + + linearAdminGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-admins"; + description = "Authentik group name that should map to Linear admins."; + }; + + linearGuestGroupName = lib.mkOption { + type = lib.types.str; + default = "linear-guests"; + description = "Authentik group name that should map to Linear guests."; + }; + forgejoClientId = lib.mkOption { type = lib.types.str; default = "git.burrow.net"; @@ -871,6 +908,59 @@ EOF ''; }; + systemd.services.burrow-authentik-linear-scim = lib.mkIf ( + cfg.linearScimUrl != null && cfg.linearScimTokenFile != null + ) { + description = "Reconcile the Burrow Authentik Linear SCIM provider"; + after = [ + "burrow-authentik-ready.service" + "burrow-authentik-directory.service" + "burrow-authentik-linear-saml.service" + "network-online.target" + ]; + wants = [ + "burrow-authentik-ready.service" + "burrow-authentik-directory.service" + "burrow-authentik-linear-saml.service" + "network-online.target" + ]; + wantedBy = [ "multi-user.target" ]; + restartTriggers = [ + linearScimSyncScript + cfg.envFile + cfg.linearScimTokenFile + ]; + 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_SCIM_PROVIDER_NAME="Linear SCIM" + export AUTHENTIK_LINEAR_SCIM_URL=${lib.escapeShellArg cfg.linearScimUrl} + export AUTHENTIK_LINEAR_SCIM_TOKEN_FILE=${lib.escapeShellArg cfg.linearScimTokenFile} + export AUTHENTIK_LINEAR_SCIM_USER_IDENTIFIER=${lib.escapeShellArg cfg.linearScimUserIdentifier} + export AUTHENTIK_LINEAR_OWNER_GROUP=${lib.escapeShellArg cfg.linearOwnerGroupName} + export AUTHENTIK_LINEAR_ADMIN_GROUP=${lib.escapeShellArg cfg.linearAdminGroupName} + export AUTHENTIK_LINEAR_GUEST_GROUP=${lib.escapeShellArg cfg.linearGuestGroupName} + + ${pkgs.bash}/bin/bash ${linearScimSyncScript} + ''; + }; + services.caddy.virtualHosts."${cfg.domain}".extraConfig = '' encode gzip zstd reverse_proxy 127.0.0.1:${toString cfg.port} diff --git a/secrets.nix b/secrets.nix index 32d7882..1a6dce0 100644 --- a/secrets.nix +++ b/secrets.nix @@ -23,5 +23,6 @@ in "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/headscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; + "secrets/infra/linear-scim-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients; } diff --git a/secrets/infra/linear-scim-token.age b/secrets/infra/linear-scim-token.age new file mode 100644 index 0000000..677a475 --- /dev/null +++ b/secrets/infra/linear-scim-token.age @@ -0,0 +1,11 @@ +age-encryption.org/v1 +-> ssh-ed25519 ux4N8Q 6LanICpiWi1sozNr5HJDWCGb6QFBktRQ0dH2wfFSu2g +jc83UfFoFvxAXcu4O/b6KC+1AyZq/k9IHzx6fL8DHoQ +-> ssh-ed25519 IrZmAg r1ggts4fiWOGHoD7IY+cVEgECOUFaulJ1ATSX6/wB2Q +NnKRd8FNKXpCrANK2q2mFJjWYccqInzGNHjK7oJNNS0 +-> ssh-ed25519 0kWPgQ G3i+VXIhED5crwLZoF8cTcaljYENq7K0DAy5mTHsNkk ++eJThDXro6DpNghlcziQv64rg8j0mcm3UfGVHcctI6w +-> X25519 2yw5RabY1hp/of6RLpKI2ao0AwBOzNdeOR4M9YRwmhY +vCe9r9ayAsDcLkyt4/c9EBZpU/DrkGKj8KLbSF9YCHo +--- Lgi0Th/QpSFhDP7JK+jenEIvI0aQfQ3oQ6sl2homLu4 +i?-d:͂ܝYǿ* \ No newline at end of file