Add Linear SCIM role sync

This commit is contained in:
Conrad Kramer 2026-04-18 19:23:53 -07:00
parent 4d3257995b
commit ebcfc4bf8d
7 changed files with 440 additions and 0 deletions

View file

@ -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}."

View file

@ -2,6 +2,11 @@
groups = { groups = {
users = "burrow-users"; users = "burrow-users";
admins = "burrow-admins"; admins = "burrow-admins";
linear = {
owners = "linear-owners";
admins = "linear-admins";
guests = "linear-guests";
};
}; };
identities = { identities = {

View file

@ -55,6 +55,8 @@ across vendor-native Google auth flows when Burrow already operates an IdP.
- Add Authentik-managed SAML applications for: - Add Authentik-managed SAML applications for:
- Zulip at `chat.burrow.net` - Zulip at `chat.burrow.net`
- Linear using Burrow's claimed domains and Authentik metadata - 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 - Add an Authentik-managed OIDC application for 1Password Business under the
Burrow team sign-in address. Burrow team sign-in address.
- Treat Zulip and Linear as downstream applications of the same identity - 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. options instead of hand-edited UI state.
- Prefer service-specific reconciliation over ad hoc manual setup so rebuilds - Prefer service-specific reconciliation over ad hoc manual setup so rebuilds
and host replacement converge automatically. 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: - Model 1Password according to the vendor's actual integration contract:
- OIDC Authorization Code Flow with PKCE - OIDC Authorization Code Flow with PKCE
- public client rather than a confidential client - 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 - 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 owner login path outside the enforced SAML flow should remain available until
rollout is proven. 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. - 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 Burrow should preserve the owner recovery path and treat OIDC rollout as a
scoped migration for non-owner users first. scoped migration for non-owner users first.

View file

@ -3,6 +3,7 @@
let let
contributors = import ../../../contributors.nix; contributors = import ../../../contributors.nix;
identities = contributors.identities; identities = contributors.identities;
linearGroups = contributors.groups.linear;
stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value; stripNewline = value: lib.replaceStrings [ "\n" ] [ "" ] value;
authentikPasswordSecretPath = identity: authentikPasswordSecretPath = identity:
if identity ? authentikPasswordSecret if identity ? authentikPasswordSecret
@ -15,6 +16,7 @@ let
name = identity.displayName; name = identity.displayName;
email = identity.canonicalEmail; email = identity.canonicalEmail;
isAdmin = identity.isAdmin or false; isAdmin = identity.isAdmin or false;
groups = lib.optionals (identity.isAdmin or false) [ linearGroups.owners ];
passwordFile = authentikPasswordSecretPath identity; passwordFile = authentikPasswordSecretPath identity;
} }
) )
@ -111,6 +113,12 @@ in
group = "root"; group = "root";
mode = "0400"; mode = "0400";
}; };
age.secrets.burrowLinearScimToken = {
file = ../../../secrets/infra/linear-scim-token.age;
owner = "root";
group = "root";
mode = "0400";
};
age.secrets.burrowAuthentikGoogleClientId = { age.secrets.burrowAuthentikGoogleClientId = {
file = ../../../secrets/infra/authentik-google-client-id.age; file = ../../../secrets/infra/authentik-google-client-id.age;
owner = "root"; owner = "root";
@ -210,6 +218,12 @@ in
linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs"; linearAcsUrl = "https://api.linear.app/auth/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de/acs";
linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de"; linearAudience = "https://auth.linear.app/sso/d0ca13dc-ac41-4824-8aab-e0ca352fc3de";
linearDefaultRelayState = "https://linear.app/auth/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 = { services.burrow.headscale = {

View file

@ -13,6 +13,7 @@ let
tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh; tailscaleOidcSyncScript = ../../Scripts/authentik-sync-tailscale-oidc.sh;
onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh; onePasswordOidcSyncScript = ../../Scripts/authentik-sync-1password-oidc.sh;
linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh; linearSamlSyncScript = ../../Scripts/authentik-sync-linear-saml.sh;
linearScimSyncScript = ../../Scripts/authentik-sync-linear-scim.sh;
googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh; googleSourceSyncScript = ../../Scripts/authentik-sync-google-source.sh;
tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh; tailnetAuthFlowSyncScript = ../../Scripts/authentik-sync-tailnet-auth-flow.sh;
authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" '' authentikBlueprint = pkgs.writeText "burrow-authentik-blueprint.yaml" ''
@ -209,6 +210,42 @@ in
description = "Optional Linear relay state or login URL for IdP-initiated launches."; 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 { forgejoClientId = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "git.burrow.net"; 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 = '' services.caddy.virtualHosts."${cfg.domain}".extraConfig = ''
encode gzip zstd encode gzip zstd
reverse_proxy 127.0.0.1:${toString cfg.port} reverse_proxy 127.0.0.1:${toString cfg.port}

View file

@ -23,5 +23,6 @@ in
"secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-nsc-dispatcher-config.age".publicKeys = burrowForgeRecipients;
"secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients; "secrets/infra/forgejo-nsc-token.age".publicKeys = burrowForgeRecipients;
"secrets/infra/headscale-oidc-client-secret.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; "secrets/infra/tailscale-oidc-client-secret.age".publicKeys = burrowForgeRecipients;
} }

View file

@ -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
礽?-d<>:橥備<E6A9A5>濙咩怄蘖腨强*狺獯