feat: run outline-role-sync as a cloudflare worker (#1698)

This commit is contained in:
Zack Pollard
2026-06-19 11:37:53 +01:00
committed by GitHub
parent d5ba37bfd8
commit 2057325ec6
39 changed files with 732 additions and 438 deletions
@@ -1,81 +0,0 @@
name: Build and Push outline-role-sync Image
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
release:
types: [published]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
with:
github-token: ${{ github.token }}
force-events: 'workflow_dispatch,release'
filters: |
outline-role-sync:
- 'services/outline-role-sync/**'
- '.github/workflows/build-outline-role-sync.yml'
build_and_push:
needs: [pre-job]
permissions:
packages: write
if: ${{ fromJSON(needs.pre-job.outputs.should_run).outline-role-sync == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
flavor: |
# Disable latest tag
latest=false
images: |
name=ghcr.io/${{ github.repository_owner }}/outline-role-sync
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=release,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: ./services/outline-role-sync
platforms: linux/amd64
push: ${{ !github.event.pull_request.head.repo.fork && steps.metadata.outputs.tags != '' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
+73
View File
@@ -0,0 +1,73 @@
name: Outline Role Sync Worker
on:
workflow_dispatch:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
pre-job:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
- name: Check what should run
id: check
uses: immich-app/devtools/actions/pre-job@91f342bb4477c4bc10c576ae739da875d85aa164 # pre-job-action-v2.0.4
with:
github-token: ${{ github.token }}
filters: |
worker:
- 'services/outline-role-sync/**'
force-filters: |
- '.github/workflows/outline-role-sync.yml'
check:
name: Check & Test
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).worker == true }}
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./services/outline-role-sync
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Deno
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
with:
deno-version: "2.8.3" # keep in sync with .mise/config.toml
- name: Format
run: deno task fmt
if: ${{ !cancelled() }}
- name: Lint
run: deno task lint
if: ${{ !cancelled() }}
- name: Type check
run: deno task check
if: ${{ !cancelled() }}
- name: Test
run: deno task test
if: ${{ !cancelled() }}
- name: Build (bundle)
run: deno task build > /dev/null
if: ${{ !cancelled() }}
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DenoSettings">
<option name="useDenoValue" value="ENABLE" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<files-pattern value="**/*.{js,ts,jsx,tsx,html,vue,yml,json}" />
<option name="fix-on-save" value="true" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OpenTofuProjectSettings">
<option name="toolPath" value="/usr/bin/tofu" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
<option name="myFilesPattern" value="**/*.{js,ts,jsx,tsx,vue,astro,yml}" />
</component>
</project>
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="true">
<option name="arguments" value="fmt $FilePath$" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="tofu" />
<option name="immediateSync" value="false" />
<option name="name" value="tofu fmt" />
<option name="output" value="$FilePath$" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="tofu" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="" />
<envs />
</TaskOptions>
<TaskOptions isEnabled="false">
<option name="arguments" value="init -backend=false" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="tofu" />
<option name="immediateSync" value="false" />
<option name="name" value="tofu init" />
<option name="output" value="" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="tofu" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="Project Files" />
<option name="trackOnlyRoot" value="false" />
<option name="workingDir" value="" />
<envs />
</TaskOptions>
</component>
</project>
+1 -1
View File
@@ -1,4 +1,4 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html
[[tools.deno]]
version = "2.8.3"
-1
View File
@@ -2,7 +2,6 @@
"actions/image-build": "0.1.10",
"actions/success-check": "0.0.6",
"actions/use-mise": "3.1.0",
"services/outline-role-sync": "1.1.0",
".github/workflows": "3.1.0",
"actions/pre-job": "2.0.4",
"actions/create-workflow-token": "2.0.1",
-1
View File
@@ -7,4 +7,3 @@ resources:
- ./discord-bot/ks.yaml
- ./containerssh/ks.yaml
- ./outline/ks.yaml
- ./outline-role-sync/ks.yaml
@@ -1,67 +0,0 @@
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: outline-role-sync
namespace: tools
spec:
interval: 30m
chart:
spec:
chart: app-template
version: 4.6.2
sourceRef:
kind: HelmRepository
name: bjw-s
namespace: flux-system
maxHistory: 2
install:
remediation:
retries: 3
upgrade:
cleanupOnFail: true
remediation:
strategy: rollback
retries: 3
values:
defaultPodOptions:
labels:
podbump.bo0tzz.me/enabled: 'true'
controllers:
outline-role-sync:
containers:
app:
image:
repository: ghcr.io/immich-app/outline-role-sync
pullPolicy: Always
tag: release
env:
OUTLINE_BASE_URL: "https://outline.immich.cloud"
ZITADEL_BASE_URL: "https://zitadel.internal.immich.cloud"
PORT: "8080"
envFrom:
- secretRef:
name: outline-role-sync
probes:
liveness:
enabled: true
custom: true
spec:
httpGet:
path: /health
port: 8080
periodSeconds: 30
readiness:
enabled: true
custom: true
spec:
httpGet:
path: /health
port: 8080
periodSeconds: 10
service:
app:
controller: outline-role-sync
ports:
http:
port: 8080
@@ -1,5 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./helmrelease.yaml
@@ -1,44 +0,0 @@
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: &app outline-role-sync-secrets
namespace: flux-system
spec:
commonMetadata:
labels:
app.kubernetes.io/name: *app
dependsOn:
- name: external-secrets-stores
path: ./kubernetes/apps/tools/outline-role-sync/secrets
prune: true
sourceRef:
kind: GitRepository
name: immich-kubernetes
wait: true
interval: 30m
retryInterval: 1m
timeout: 5m
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: &app outline-role-sync
namespace: flux-system
spec:
targetNamespace: tools
commonMetadata:
labels:
app.kubernetes.io/name: *app
dependsOn:
- name: outline-role-sync-secrets
- name: outline
path: ./kubernetes/apps/tools/outline-role-sync/app
prune: true
sourceRef:
kind: GitRepository
name: immich-kubernetes
wait: true
interval: 30m
retryInterval: 1m
timeout: 5m
@@ -1,5 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./secret.yaml
@@ -1,23 +0,0 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: outline-role-sync
namespace: tools
spec:
secretStoreRef:
kind: ClusterSecretStore
name: 1p-tf
refreshInterval: "20s"
data:
- secretKey: OUTLINE_API_TOKEN
remoteRef:
key: OUTLINE_ROLE_SYNC_OUTLINE_API_TOKEN
- secretKey: OUTLINE_WEBHOOK_SECRET
remoteRef:
key: OUTLINE_ROLE_SYNC_WEBHOOK_SECRET
- secretKey: ZITADEL_SERVICE_ACCOUNT_TOKEN
remoteRef:
key: OUTLINE_ROLE_SYNC_ZITADEL_TOKEN
- secretKey: ZITADEL_OUTLINE_PROJECT_ID
remoteRef:
key: OUTLINE_ROLE_SYNC_ZITADEL_PROJECT_ID
-4
View File
@@ -22,15 +22,11 @@
"actions/sticky-comment": {
"component": "sticky-comment-action"
},
"services/outline-role-sync": {
"component": "outline-role-sync"
},
".github/workflows": {
"component": "multi-runner-build-workflow",
"exclude-paths": [
".github/workflows/build-actions-runner.yaml",
".github/workflows/build-mdq.yml",
".github/workflows/build-outline-role-sync.yml",
".github/workflows/flux-diff.yml",
".github/workflows/org-pr-require-conventional-commit.yml",
".github/workflows/org-zizmor.yml",
+12 -7
View File
@@ -2,20 +2,25 @@
## [1.1.0](https://github.com/immich-app/devtools/compare/outline-role-sync-v1.0.0...outline-role-sync-v1.1.0) (2026-06-11)
### Features
* support both github and gitlab logins to internal futo auth service ([#1676](https://github.com/immich-app/devtools/issues/1676)) ([f3ab76d](https://github.com/immich-app/devtools/commit/f3ab76dffa9d323aadc56e5c0d507a815595e60d))
- support both github and gitlab logins to internal futo auth service
([#1676](https://github.com/immich-app/devtools/issues/1676))
([f3ab76d](https://github.com/immich-app/devtools/commit/f3ab76dffa9d323aadc56e5c0d507a815595e60d))
### Chores
* **deps:** update denoland/deno docker tag to v2.7.12 ([#1487](https://github.com/immich-app/devtools/issues/1487)) ([b441d11](https://github.com/immich-app/devtools/commit/b441d1125cec34fb91f038fdc9fcf67a0b89a391))
* **deps:** update denoland/deno docker tag to v2.7.14 ([#1527](https://github.com/immich-app/devtools/issues/1527)) ([f5a30f3](https://github.com/immich-app/devtools/commit/f5a30f3a0b106fbde1c2226dcad321f936e64f63))
- **deps:** update denoland/deno docker tag to v2.7.12
([#1487](https://github.com/immich-app/devtools/issues/1487))
([b441d11](https://github.com/immich-app/devtools/commit/b441d1125cec34fb91f038fdc9fcf67a0b89a391))
- **deps:** update denoland/deno docker tag to v2.7.14
([#1527](https://github.com/immich-app/devtools/issues/1527))
([f5a30f3](https://github.com/immich-app/devtools/commit/f5a30f3a0b106fbde1c2226dcad321f936e64f63))
## 1.0.0 (2026-03-31)
### Features
* **zitadel:** gitlab oauth and role mapping ([#1383](https://github.com/immich-app/devtools/issues/1383)) ([5d02b07](https://github.com/immich-app/devtools/commit/5d02b075c652fe225c3e95c896739d85e7d7659a))
- **zitadel:** gitlab oauth and role mapping
([#1383](https://github.com/immich-app/devtools/issues/1383))
([5d02b07](https://github.com/immich-app/devtools/commit/5d02b075c652fe225c3e95c896739d85e7d7659a))
-14
View File
@@ -1,14 +0,0 @@
FROM denoland/deno:2.7.14@sha256:564e989f4a93371e70fd8720e5dbe3e027fd4a0daad71a2b008008596ffa6492
WORKDIR /app
COPY deno.json .
COPY src/ src/
RUN deno cache src/main.ts
USER deno
EXPOSE 8080
CMD ["deno", "run", "--allow-net", "--allow-env", "src/main.ts"]
+9 -2
View File
@@ -1,7 +1,14 @@
{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.19",
"@deno/emit": "jsr:@deno/emit@^0.46.0"
},
"tasks": {
"start": "deno run --allow-net --allow-env src/main.ts",
"dev": "deno run --watch --allow-net --allow-env src/main.ts"
"test": "deno test",
"check": "deno check src/index.ts test/index.test.ts scripts/build.ts",
"lint": "deno lint",
"fmt": "deno fmt --check",
"build": "deno run --allow-read --allow-net --allow-env scripts/build.ts"
},
"compilerOptions": {
"strict": true
+78
View File
@@ -0,0 +1,78 @@
{
"version": "5",
"specifiers": {
"jsr:@deno/cache-dir@0.13.2": "0.13.2",
"jsr:@deno/emit@0.46": "0.46.0",
"jsr:@deno/graph@~0.73.1": "0.73.1",
"jsr:@std/assert@0.223": "0.223.0",
"jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/bytes@0.223": "0.223.0",
"jsr:@std/fmt@0.223": "0.223.0",
"jsr:@std/fs@0.223": "0.223.0",
"jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/io@0.223": "0.223.0",
"jsr:@std/path@0.223": "0.223.0"
},
"jsr": {
"@deno/cache-dir@0.13.2": {
"integrity": "c22419dfe27ab85f345bee487aaaadba498b005cce3644e9d2528db035c5454d",
"dependencies": [
"jsr:@deno/graph",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/io",
"jsr:@std/path"
]
},
"@deno/emit@0.46.0": {
"integrity": "e276be2c77bac1b93caf775762e2a49a54cb00da2d48ca2b01ed8d7cba9d082c",
"dependencies": [
"jsr:@deno/cache-dir",
"jsr:@std/path"
]
},
"@deno/graph@0.73.1": {
"integrity": "cd69639d2709d479037d5ce191a422eabe8d71bb68b0098344f6b07411c84d41"
},
"@std/assert@0.223.0": {
"integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24"
},
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/bytes@0.223.0": {
"integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8"
},
"@std/fmt@0.223.0": {
"integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208"
},
"@std/fs@0.223.0": {
"integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c"
},
"@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/io@0.223.0": {
"integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1",
"dependencies": [
"jsr:@std/assert@0.223",
"jsr:@std/bytes"
]
},
"@std/path@0.223.0": {
"integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989",
"dependencies": [
"jsr:@std/assert@0.223"
]
}
},
"workspace": {
"dependencies": [
"jsr:@deno/emit@0.46",
"jsr:@std/assert@^1.0.19"
]
}
}
@@ -0,0 +1,15 @@
// Bundles the TypeScript worker (src/index.ts + its imports) into a single JS
// module for terraform's content_base64 — Cloudflare runs JS. Invoked by the
// data.external in terraform, so it must print ONLY a JSON object with the JS
// string on stdout (deno's own download/progress noise goes to stderr).
import { bundle } from "@deno/emit";
const entry = new URL("../src/index.ts", import.meta.url);
const result = await bundle(entry);
if (!result.code) {
console.error("bundle produced no output");
Deno.exit(1);
}
console.log(JSON.stringify({ js: result.code }));
+205
View File
@@ -0,0 +1,205 @@
// Cloudflare Worker that syncs ZITADEL project roles onto Outline groups.
// Triggered by Outline's `users.signin` webhook: it looks up the user's grants
// for the Outline project in ZITADEL and reconciles their Outline group
// membership + admin role. Replaces the in-cluster deno service.
import { OutlineClient } from "./outline.ts";
import { ZitadelClient } from "./zitadel.ts";
export interface Env {
OUTLINE_BASE_URL: string;
OUTLINE_API_TOKEN: string;
OUTLINE_WEBHOOK_SECRET: string;
ZITADEL_BASE_URL: string;
ZITADEL_SERVICE_ACCOUNT_TOKEN: string;
ZITADEL_OUTLINE_PROJECT_ID: string;
}
// Minimal shape of the Workers execution context (for ctx.waitUntil).
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
interface WebhookPayload {
event: string;
// Outline sends payload.id (the affected model's id) plus a presented model;
// for users.signin both carry the signing-in user's id.
payload: { id: string; model?: { id?: string; email?: string } };
}
// ZITADEL role keys on the Outline project that map 1:1 to Outline groups.
const MANAGED_GROUPS = ["Leadership", "Team", "Contributor", "Support Crew"];
export default {
async fetch(
req: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/health") {
return new Response("OK");
}
if (url.pathname === "/webhook" && req.method === "POST") {
return await handleWebhook(req, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
};
async function handleWebhook(
req: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const body = await req.text();
const signature = req.headers.get("outline-signature") ?? "";
if (!(await verifySignature(body, signature, env.OUTLINE_WEBHOOK_SECRET))) {
console.error("invalid webhook signature");
return new Response("Invalid signature", { status: 401 });
}
const webhook = JSON.parse(body) as WebhookPayload;
if (webhook.event !== "users.signin") {
return new Response("OK");
}
const outlineUserId = webhook.payload.model?.id ?? webhook.payload.id;
if (!outlineUserId) {
console.error("users.signin webhook missing a user id");
return new Response("OK");
}
console.log(`received users.signin webhook for user ${outlineUserId}`);
// Reconcile after responding so Outline's webhook delivery isn't blocked.
ctx.waitUntil(
syncUserRoles(outlineUserId, env).catch((err) =>
console.error(`failed to sync roles for user ${outlineUserId}:`, err)
),
);
return new Response("OK");
}
export async function verifySignature(
body: string,
signatureHeader: string,
secret: string,
): Promise<boolean> {
// Outline signs `${timestamp}.${body}` with HMAC-SHA256 and sends the result
// as `Outline-Signature: t=<timestamp>,s=<hex>`.
const parts = new Map<string, string>();
for (const part of signatureHeader.split(",")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
parts.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
}
const timestamp = parts.get("t");
const signature = parts.get("s");
if (!timestamp || !signature) {
return false;
}
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`${timestamp}.${body}`),
);
const expected = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return expected === signature;
}
async function syncUserRoles(outlineUserId: string, env: Env): Promise<void> {
const outline = new OutlineClient(
env.OUTLINE_BASE_URL,
env.OUTLINE_API_TOKEN,
);
const zitadel = new ZitadelClient(
env.ZITADEL_BASE_URL,
env.ZITADEL_SERVICE_ACCOUNT_TOKEN,
);
const user = await outline.getUserInfo(outlineUserId);
console.log(`syncing roles for ${user.email} (${user.name})`);
const zitadelUser = await zitadel.findUserByEmail(user.email);
if (!zitadelUser) {
console.log(`user ${user.email} not found in zitadel, skipping`);
return;
}
const zitadelRoles = await zitadel.getUserGrants(
zitadelUser.userId,
env.ZITADEL_OUTLINE_PROJECT_ID,
);
console.log(
`zitadel roles for ${user.email}: ${JSON.stringify(zitadelRoles)}`,
);
if (zitadelRoles.length === 0) {
console.log("no zitadel grants for the outline project, skipping");
return;
}
const allGroups = await outline.listAllGroups();
const groupsByName = new Map(allGroups.map((g) => [g.name, g]));
const userGroups = await outline.getUserGroups(outlineUserId);
const currentGroupNames = new Set(userGroups.map((g) => g.name));
const targetGroupNames = new Set(
zitadelRoles.filter((role) => MANAGED_GROUPS.includes(role)),
);
// Create any target group that doesn't exist yet.
for (const groupName of targetGroupNames) {
if (!groupsByName.has(groupName)) {
console.log(`creating outline group ${groupName}`);
groupsByName.set(groupName, await outline.createGroup(groupName));
}
}
// Add the user to target groups they're not in.
for (const groupName of targetGroupNames) {
if (!currentGroupNames.has(groupName)) {
console.log(`adding ${user.email} to ${groupName}`);
await outline.addUserToGroup(
groupsByName.get(groupName)!.id,
outlineUserId,
);
}
}
// Remove the user from managed groups they should no longer be in.
for (const group of userGroups) {
if (
MANAGED_GROUPS.includes(group.name) && !targetGroupNames.has(group.name)
) {
console.log(`removing ${user.email} from ${group.name}`);
await outline.removeUserFromGroup(group.id, outlineUserId);
}
}
// Leadership grants Outline admin.
const shouldBeAdmin = zitadelRoles.includes("Leadership");
if (shouldBeAdmin && user.role !== "admin") {
console.log(`promoting ${user.email} to admin`);
await outline.updateUserRole(outlineUserId, "admin");
} else if (!shouldBeAdmin && user.role === "admin") {
console.log(`demoting ${user.email} to member`);
await outline.updateUserRole(outlineUserId, "member");
}
console.log(`role sync complete for ${user.email}`);
}
-165
View File
@@ -1,165 +0,0 @@
import { OutlineClient } from "./outline.ts";
import { ZitadelClient } from "./zitadel.ts";
const MANAGED_GROUPS = ["Leadership", "Team", "Contributor", "Support Crew"];
function requireEnv(name: string): string {
const value = Deno.env.get(name);
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const config = {
outlineBaseUrl: requireEnv("OUTLINE_BASE_URL"),
outlineApiToken: requireEnv("OUTLINE_API_TOKEN"),
outlineWebhookSecret: requireEnv("OUTLINE_WEBHOOK_SECRET"),
zitadelBaseUrl: requireEnv("ZITADEL_BASE_URL"),
zitadelToken: requireEnv("ZITADEL_SERVICE_ACCOUNT_TOKEN"),
zitadelOutlineProjectId: requireEnv("ZITADEL_OUTLINE_PROJECT_ID"),
port: parseInt(Deno.env.get("PORT") ?? "8080"),
};
const outline = new OutlineClient(config.outlineBaseUrl, config.outlineApiToken);
const zitadel = new ZitadelClient(config.zitadelBaseUrl, config.zitadelToken);
async function verifySignature(body: string, signature: string): Promise<boolean> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(config.outlineWebhookSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
const expected = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `sha256=${expected}` === signature;
}
interface WebhookPayload {
event: string;
payload: {
id: string;
model: {
id: string;
email?: string;
};
};
}
async function syncUserRoles(outlineUserId: string): Promise<void> {
const user = await outline.getUserInfo(outlineUserId);
console.log(`Syncing roles for user: ${user.email} (${user.name})`);
const zitadelUser = await zitadel.findUserByEmail(user.email);
if (!zitadelUser) {
console.log(`User ${user.email} not found in Zitadel, skipping role sync`);
return;
}
const zitadelRoles = await zitadel.getUserGrants(
zitadelUser.userId,
config.zitadelOutlineProjectId,
);
console.log(`Zitadel roles for ${user.email}: ${JSON.stringify(zitadelRoles)}`);
if (zitadelRoles.length === 0) {
console.log(`No Zitadel grants for Outline project, skipping`);
return;
}
// Sync Outline groups
const allGroups = await outline.listAllGroups();
const groupsByName = new Map(allGroups.map((g) => [g.name, g]));
const userGroups = await outline.getUserGroups(outlineUserId);
const currentGroupNames = new Set(userGroups.map((g) => g.name));
const targetGroupNames = new Set(
zitadelRoles.filter((role) => MANAGED_GROUPS.includes(role)),
);
// Create missing groups
for (const groupName of targetGroupNames) {
if (!groupsByName.has(groupName)) {
console.log(`Creating Outline group: ${groupName}`);
const newGroup = await outline.createGroup(groupName);
groupsByName.set(groupName, newGroup);
}
}
// Add user to groups they should be in
for (const groupName of targetGroupNames) {
if (!currentGroupNames.has(groupName)) {
const group = groupsByName.get(groupName)!;
console.log(`Adding ${user.email} to group: ${groupName}`);
await outline.addUserToGroup(group.id, outlineUserId);
}
}
// Remove user from managed groups they shouldn't be in
for (const group of userGroups) {
if (MANAGED_GROUPS.includes(group.name) && !targetGroupNames.has(group.name)) {
console.log(`Removing ${user.email} from group: ${group.name}`);
await outline.removeUserFromGroup(group.id, outlineUserId);
}
}
// Sync admin role
const shouldBeAdmin = zitadelRoles.includes("Leadership");
if (shouldBeAdmin && user.role !== "admin") {
console.log(`Promoting ${user.email} to admin`);
await outline.updateUserRole(outlineUserId, "admin");
} else if (!shouldBeAdmin && user.role === "admin") {
console.log(`Demoting ${user.email} to member`);
await outline.updateUserRole(outlineUserId, "member");
}
console.log(`Role sync complete for ${user.email}`);
}
async function handleWebhook(request: Request): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("outline-signature") ?? "";
if (!await verifySignature(body, signature)) {
console.error("Invalid webhook signature");
return new Response("Invalid signature", { status: 401 });
}
const webhook: WebhookPayload = JSON.parse(body);
if (webhook.event !== "users.signin") {
return new Response("OK", { status: 200 });
}
const outlineUserId = webhook.payload.model.id;
console.log(`Received users.signin webhook for user: ${outlineUserId}`);
// Process async so the webhook response isn't delayed
syncUserRoles(outlineUserId).catch((err) => {
console.error(`Failed to sync roles for user ${outlineUserId}:`, err);
});
return new Response("OK", { status: 200 });
}
function handleRequest(request: Request): Response | Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/health") {
return new Response("OK", { status: 200 });
}
if (url.pathname === "/webhook" && request.method === "POST") {
return handleWebhook(request);
}
return new Response("Not Found", { status: 404 });
}
console.log(`Starting outline-role-sync on port ${config.port}`);
Deno.serve({ port: config.port }, handleRequest);
+28 -8
View File
@@ -23,7 +23,10 @@ export class OutlineClient {
private apiToken: string,
) {}
private async request<T>(path: string, body: Record<string, unknown> = {}): Promise<T> {
private async request<T>(
path: string,
body: Record<string, unknown> = {},
): Promise<T> {
const response = await fetch(`${this.baseUrl}/api${path}`, {
method: "POST",
headers: {
@@ -35,7 +38,9 @@ export class OutlineClient {
if (!response.ok) {
const text = await response.text();
throw new Error(`Outline API error ${response.status} on ${path}: ${text}`);
throw new Error(
`Outline API error ${response.status} on ${path}: ${text}`,
);
}
return (await response.json() as { data: T }).data;
@@ -46,14 +51,20 @@ export class OutlineClient {
}
async getUserGroups(userId: string): Promise<OutlineGroupMembership[]> {
const result = await this.request<{ groups: OutlineGroupMembership[] }>("/groups.list", {
userId,
});
const result = await this.request<{ groups: OutlineGroupMembership[] }>(
"/groups.list",
{
userId,
},
);
return result.groups;
}
async listAllGroups(): Promise<OutlineGroup[]> {
const result = await this.request<{ groups: OutlineGroup[] }>("/groups.list", {});
const result = await this.request<{ groups: OutlineGroup[] }>(
"/groups.list",
{},
);
return result.groups;
}
@@ -69,7 +80,16 @@ export class OutlineClient {
await this.request("/groups.remove_user", { id: groupId, userId });
}
async updateUserRole(userId: string, role: "admin" | "member" | "viewer"): Promise<void> {
await this.request("/users.update", { id: userId, role });
async updateUserRole(
userId: string,
role: "admin" | "member" | "viewer",
): Promise<void> {
// Outline ignores `role` on users.update — role changes go through the
// dedicated promote/demote endpoints.
if (role === "admin") {
await this.request("/users.promote", { id: userId });
} else {
await this.request("/users.demote", { id: userId, to: role });
}
}
}
+11 -3
View File
@@ -15,7 +15,11 @@ export class ZitadelClient {
private token: string,
) {}
private async request<T>(method: string, path: string, body?: Record<string, unknown>): Promise<T> {
private async request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
@@ -27,7 +31,9 @@ export class ZitadelClient {
if (!response.ok) {
const text = await response.text();
throw new Error(`Zitadel API error ${response.status} on ${path}: ${text}`);
throw new Error(
`Zitadel API error ${response.status} on ${path}: ${text}`,
);
}
return await response.json() as T;
@@ -71,7 +77,9 @@ export class ZitadelClient {
},
);
const grants = (result.result ?? []).filter((grant) => grant.projectId === projectId);
const grants = (result.result ?? []).filter((grant) =>
grant.projectId === projectId
);
return grants.flatMap((grant) => grant.roleKeys);
}
}
@@ -0,0 +1,64 @@
import { assertEquals } from "@std/assert";
import { verifySignature } from "../src/index.ts";
const TIMESTAMP = "1706609916240";
// Mirror Outline's signing: HMAC-SHA256 over `${timestamp}.${body}`, emitted as
// `t=<timestamp>,s=<hex>`.
async function sign(
body: string,
secret: string,
timestamp = TIMESTAMP,
): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`${timestamp}.${body}`),
);
const hex = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `t=${timestamp},s=${hex}`;
}
Deno.test("verifySignature: valid", async () => {
const body = '{"event":"users.signin"}', secret = "shh";
assertEquals(
await verifySignature(body, await sign(body, secret), secret),
true,
);
});
Deno.test("verifySignature: tampered body -> false", async () => {
const secret = "shh";
const good = await sign('{"event":"users.signin"}', secret);
assertEquals(
await verifySignature('{"event":"documents.update"}', good, secret),
false,
);
});
Deno.test("verifySignature: tampered timestamp -> false", async () => {
const body = '{"event":"users.signin"}', secret = "shh";
const good = await sign(body, secret);
const tampered = good.replace(`t=${TIMESTAMP}`, "t=1799999999999");
assertEquals(await verifySignature(body, tampered, secret), false);
});
Deno.test("verifySignature: wrong secret -> false", async () => {
const body = "x";
assertEquals(await verifySignature(body, await sign(body, "a"), "b"), false);
});
Deno.test("verifySignature: missing/legacy header -> false", async () => {
assertEquals(await verifySignature("x", "", "shh"), false);
assertEquals(await verifySignature("x", "sha256=abc", "shh"), false);
});
+12
View File
@@ -0,0 +1,12 @@
{
// Syncs ZITADEL project roles to Outline groups on the users.signin webhook.
// Deployed via terraform (cloudflare_worker_version) from the zitadel cloud
// module; this config is for local `wrangler dev` and the worker name/entry.
// Bindings (OUTLINE_*/ZITADEL_*) are injected by terraform, not here.
"name": "outline-role-sync",
"main": "src/index.ts",
"compatibility_date": "2026-06-01",
"observability": {
"enabled": true
}
}
@@ -41,6 +41,29 @@ provider "registry.opentofu.org/cloudflare/cloudflare" {
]
}
provider "registry.opentofu.org/hashicorp/external" {
version = "2.4.0"
constraints = "~> 2.3"
hashes = [
"h1:0SJyK1GT4ma4uTSUz8Y619fc1xRTzJ9FKFvcpvZlSIo=",
"zh:483962b782cee2c970f4bdf6118e4bb665f37a0d488024c660b7a7c9853afc93",
"zh:4a8b42651f6de0ea93854ece3ccdf8e2b21b911145859402a9a6ec6ecf31a23c",
"zh:56836ea1468cb328e98cccc76cccc208fb7336513bc76de76309541b8e5ceef2",
"zh:6d30c2c1aff7e0ddcfffdf815e570f5ed8b77d1ce2d31440c7c50c6f3e7cbe97",
"zh:80a21a23bd9bfafb74e4fc5f2a0e2bc33517c418d5f16dc020b7febba9ef6cd2",
"zh:95d9ce0e6407f199f7e9d3aefc3345a6e67c18f0c8280207417272cbb3f973ad",
"zh:98e46c6504da5f1020489730d23f03a1337e643ed452f271c2a13e6af4687a16",
"zh:a3f59b81da319a87bb3ac4a31e669874a090f082959da29975fa6f11f53b4731",
"zh:a5793f88bb25000d6e6b8b2cd5ca71366a8d4e373e17a247089a80331ccf824e",
"zh:cfc9af183162936b2e9a726c4a68d773b4511ada23b2c764f5add90f080e747d",
"zh:d85e722d771fb7865d9d5b591334d0f2b7598b7e66f534f93923effe6d5134ed",
"zh:de6e5d954ef91bb0ad41f30305ab8c31718ea39308523529f528babd0b27db71",
"zh:fca19dda5e05221e231d400fc39fda4f9e9abfe33e8e833a215eb094fe226ed8",
"zh:fcf67b347f2dc3c609c819ad5f27078fa3d0235311cfe5e33242eb49b3a4a6a8",
"zh:fea69a81ffcd63cb776b0f2c13a99a8a6d64e0b9de111ea1926bc4e713023ece",
]
}
provider "registry.opentofu.org/hashicorp/http" {
version = "3.6.0"
constraints = "~> 3.5"
@@ -0,0 +1,70 @@
// Cloudflare Worker for outline-role-sync (replaces the in-cluster deno
// service). It receives Outline's users.signin webhook and reconciles the
// user's Outline groups/role from their ZITADEL grants. Deployed the same way
// as the zitadel-actions worker: deno transpiles the TS at plan time and the
// content is embedded so a code change forces a new version.
locals {
outline_role_sync_worker_host = "outline-role-sync.internal.immich.cloud"
}
# Outline API token + webhook secret live in 1Password (not created here).
data "onepassword_item" "outline_role_sync_api_token" {
vault = data.onepassword_vault.tf.uuid
title = "OUTLINE_ROLE_SYNC_OUTLINE_API_TOKEN"
}
data "onepassword_item" "outline_role_sync_webhook_secret" {
vault = data.onepassword_vault.tf.uuid
title = "OUTLINE_ROLE_SYNC_WEBHOOK_SECRET"
}
data "external" "outline_role_sync_worker_build" {
program = ["deno", "run", "--allow-read", "--allow-net", "--allow-env", "${var.outline_role_sync_worker_dir}/scripts/build.ts"]
}
resource "cloudflare_worker" "outline_role_sync" {
account_id = var.cloudflare_account_id
name = "outline-role-sync"
}
resource "cloudflare_worker_version" "outline_role_sync" {
account_id = var.cloudflare_account_id
worker_id = cloudflare_worker.outline_role_sync.id
compatibility_date = "2026-06-01"
main_module = "index.js"
modules = [{
name = "index.js"
content_base64 = base64encode(data.external.outline_role_sync_worker_build.result.js)
content_type = "application/javascript+module"
}]
bindings = [
{ name = "OUTLINE_BASE_URL", type = "plain_text", text = "https://outline.immich.cloud" },
{ name = "ZITADEL_BASE_URL", type = "plain_text", text = "https://auth.internal.futo.org" },
{ name = "ZITADEL_OUTLINE_PROJECT_ID", type = "plain_text", text = zitadel_project.projects["Outline"].id },
{ name = "ZITADEL_SERVICE_ACCOUNT_TOKEN", type = "secret_text", text = zitadel_personal_access_token.outline_role_sync.token },
{ name = "OUTLINE_API_TOKEN", type = "secret_text", text = data.onepassword_item.outline_role_sync_api_token.password },
{ name = "OUTLINE_WEBHOOK_SECRET", type = "secret_text", text = data.onepassword_item.outline_role_sync_webhook_secret.password },
]
}
resource "cloudflare_workers_deployment" "outline_role_sync" {
account_id = var.cloudflare_account_id
script_name = cloudflare_worker.outline_role_sync.name
strategy = "percentage"
versions = [{
percentage = 100
version_id = cloudflare_worker_version.outline_role_sync.id
}]
}
resource "cloudflare_workers_custom_domain" "outline_role_sync" {
account_id = var.cloudflare_account_id
hostname = local.outline_role_sync_worker_host
service = cloudflare_worker.outline_role_sync.name
zone_name = "immich.cloud"
depends_on = [cloudflare_workers_deployment.outline_role_sync]
}
@@ -8,13 +8,20 @@ locals {
]
])
# For each user+project, grant only the highest-priority role (first match in the ordered list)
# Per user+project: multi_role projects grant every role the user matches;
# the rest grant only the highest-priority one (first match in the ordered list).
project_user_grants = flatten([
for project in local.projects : [
for key, user in local.zitadel_users : {
project_name = project.name
role_key = [for role in project.roles : role.key if length(setintersection(toset(role.grants_to), toset(user.roles))) > 0][0]
user_key = key
role_keys = project.multi_role ? [
for role in project.roles : role.key
if length(setintersection(toset(role.grants_to), toset(user.roles))) > 0
] : [[
for role in project.roles : role.key
if length(setintersection(toset(role.grants_to), toset(user.roles))) > 0
][0]]
user_key = key
}
if length([for role in project.roles : role.key if length(setintersection(toset(role.grants_to), toset(user.roles))) > 0]) > 0
]
@@ -42,5 +49,5 @@ resource "zitadel_user_grant" "project_grants" {
org_id = zitadel_org.immich.id
project_id = zitadel_project.projects[each.value.project_name].id
user_id = zitadel_human_user.users[each.value.user_key].id
role_keys = [each.value.role_key]
role_keys = each.value.role_keys
}
@@ -6,6 +6,9 @@ locals {
grantTypes = ["AUTHORIZATION_CODE"]
protocol = "oidc"
metadataUrl = ""
# When true, a user is granted every role they match (not just the
# highest-priority one) — e.g. Outline admins land in Leadership and Team.
multi_role = false
}
projects_data = [
{
@@ -27,10 +30,11 @@ locals {
name = "Outline"
roles = [
{ key = "Leadership", grants_to = ["immich_admin"] },
{ key = "Team", grants_to = ["team"] },
{ key = "Team", grants_to = ["team", "immich_admin"] },
{ key = "Contributor", grants_to = ["contributor"] },
{ key = "Support Crew", grants_to = ["support"] }
]
multi_role = true
authMethod = "BASIC"
redirectUris = ["https://outline.immich.cloud/auth/oidc.callback"]
},
@@ -13,8 +13,9 @@ include "root" {
}
inputs = {
users_data_file_path = "${get_repo_root()}/tf/deployment/data/users.json"
zitadel_actions_worker_dir = "${get_repo_root()}/services/zitadel-actions"
users_data_file_path = "${get_repo_root()}/tf/deployment/data/users.json"
zitadel_actions_worker_dir = "${get_repo_root()}/services/zitadel-actions"
outline_role_sync_worker_dir = "${get_repo_root()}/services/outline-role-sync"
}
dependencies {
@@ -32,3 +32,8 @@ variable "zitadel_actions_worker_dir" {
description = "Absolute path to the zitadel-actions worker service directory (injected by terragrunt via get_repo_root()); its scripts/build.ts transpiles the TS worker for deployment."
type = string
}
variable "outline_role_sync_worker_dir" {
description = "Absolute path to the outline-role-sync worker service directory (injected by terragrunt via get_repo_root()); its scripts/build.ts bundles the TS worker for deployment."
type = string
}