From 2057325ec6302b71fd9933de980a85b850da74b7 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 19 Jun 2026 11:37:53 +0100 Subject: [PATCH] feat: run outline-role-sync as a cloudflare worker (#1698) --- .github/workflows/build-outline-role-sync.yml | 81 ------- .github/workflows/outline-role-sync.yml | 73 +++++++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.ask.xml | 6 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + .idea/deno.xml | 6 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/jsLinters/eslint.xml | 7 + .idea/opentofu_settings.xml | 6 + .idea/prettier.xml | 8 + .idea/watcherTasks.xml | 45 ++++ .mise/mise.lock | 2 +- .release-please-manifest.json | 1 - kubernetes/apps/tools/kustomization.yaml | 1 - .../outline-role-sync/app/helmrelease.yaml | 67 ------ .../outline-role-sync/app/kustomization.yaml | 5 - .../apps/tools/outline-role-sync/ks.yaml | 44 ---- .../secrets/kustomization.yaml | 5 - .../outline-role-sync/secrets/secret.yaml | 23 -- release-please-config.json | 4 - services/outline-role-sync/CHANGELOG.md | 19 +- services/outline-role-sync/Dockerfile | 14 -- services/outline-role-sync/deno.json | 11 +- services/outline-role-sync/deno.lock | 78 +++++++ services/outline-role-sync/scripts/build.ts | 15 ++ services/outline-role-sync/src/index.ts | 205 ++++++++++++++++++ services/outline-role-sync/src/main.ts | 165 -------------- services/outline-role-sync/src/outline.ts | 36 ++- services/outline-role-sync/src/zitadel.ts | 14 +- services/outline-role-sync/test/index.test.ts | 64 ++++++ services/outline-role-sync/wrangler.jsonc | 12 + .../shared/zitadel/cloud/.terraform.lock.hcl | 23 ++ .../zitadel/cloud/outline-role-sync-worker.tf | 70 ++++++ .../shared/zitadel/cloud/permissions.tf | 15 +- .../modules/shared/zitadel/cloud/project.tf | 6 +- .../shared/zitadel/cloud/terragrunt.hcl | 5 +- .../modules/shared/zitadel/cloud/variables.tf | 5 + 39 files changed, 732 insertions(+), 438 deletions(-) delete mode 100644 .github/workflows/build-outline-role-sync.yml create mode 100644 .github/workflows/outline-role-sync.yml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 .idea/deno.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/jsLinters/eslint.xml create mode 100644 .idea/opentofu_settings.xml create mode 100644 .idea/prettier.xml create mode 100644 .idea/watcherTasks.xml delete mode 100644 kubernetes/apps/tools/outline-role-sync/app/helmrelease.yaml delete mode 100644 kubernetes/apps/tools/outline-role-sync/app/kustomization.yaml delete mode 100644 kubernetes/apps/tools/outline-role-sync/ks.yaml delete mode 100644 kubernetes/apps/tools/outline-role-sync/secrets/kustomization.yaml delete mode 100644 kubernetes/apps/tools/outline-role-sync/secrets/secret.yaml delete mode 100644 services/outline-role-sync/Dockerfile create mode 100644 services/outline-role-sync/deno.lock create mode 100644 services/outline-role-sync/scripts/build.ts create mode 100644 services/outline-role-sync/src/index.ts delete mode 100644 services/outline-role-sync/src/main.ts create mode 100644 services/outline-role-sync/test/index.test.ts create mode 100644 services/outline-role-sync/wrangler.jsonc create mode 100644 tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf diff --git a/.github/workflows/build-outline-role-sync.yml b/.github/workflows/build-outline-role-sync.yml deleted file mode 100644 index a796b1d3..00000000 --- a/.github/workflows/build-outline-role-sync.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/outline-role-sync.yml b/.github/workflows/outline-role-sync.yml new file mode 100644 index 00000000..b0e8bc3e --- /dev/null +++ b/.github/workflows/outline-role-sync.yml @@ -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() }} diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 00000000..a55e7a17 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 00000000..4ea72a91 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 00000000..7ef04e2e --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 00000000..1f2ea11e --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 00000000..8648f940 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deno.xml b/.idea/deno.xml new file mode 100644 index 00000000..2e4b1457 --- /dev/null +++ b/.idea/deno.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..03d9549e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/jsLinters/eslint.xml b/.idea/jsLinters/eslint.xml new file mode 100644 index 00000000..e924b549 --- /dev/null +++ b/.idea/jsLinters/eslint.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/opentofu_settings.xml b/.idea/opentofu_settings.xml new file mode 100644 index 00000000..4117ae50 --- /dev/null +++ b/.idea/opentofu_settings.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 00000000..372917c7 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml new file mode 100644 index 00000000..d8c340fa --- /dev/null +++ b/.idea/watcherTasks.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.mise/mise.lock b/.mise/mise.lock index 40f4cd1f..b70c6c82 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -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" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c263da4b..da2afa6e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -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", diff --git a/kubernetes/apps/tools/kustomization.yaml b/kubernetes/apps/tools/kustomization.yaml index d09da9aa..6490f731 100644 --- a/kubernetes/apps/tools/kustomization.yaml +++ b/kubernetes/apps/tools/kustomization.yaml @@ -7,4 +7,3 @@ resources: - ./discord-bot/ks.yaml - ./containerssh/ks.yaml - ./outline/ks.yaml - - ./outline-role-sync/ks.yaml diff --git a/kubernetes/apps/tools/outline-role-sync/app/helmrelease.yaml b/kubernetes/apps/tools/outline-role-sync/app/helmrelease.yaml deleted file mode 100644 index f38efe8a..00000000 --- a/kubernetes/apps/tools/outline-role-sync/app/helmrelease.yaml +++ /dev/null @@ -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 diff --git a/kubernetes/apps/tools/outline-role-sync/app/kustomization.yaml b/kubernetes/apps/tools/outline-role-sync/app/kustomization.yaml deleted file mode 100644 index 5dd7baca..00000000 --- a/kubernetes/apps/tools/outline-role-sync/app/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ./helmrelease.yaml diff --git a/kubernetes/apps/tools/outline-role-sync/ks.yaml b/kubernetes/apps/tools/outline-role-sync/ks.yaml deleted file mode 100644 index 04b2b71f..00000000 --- a/kubernetes/apps/tools/outline-role-sync/ks.yaml +++ /dev/null @@ -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 diff --git a/kubernetes/apps/tools/outline-role-sync/secrets/kustomization.yaml b/kubernetes/apps/tools/outline-role-sync/secrets/kustomization.yaml deleted file mode 100644 index 844bf0b3..00000000 --- a/kubernetes/apps/tools/outline-role-sync/secrets/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ ---- -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ./secret.yaml diff --git a/kubernetes/apps/tools/outline-role-sync/secrets/secret.yaml b/kubernetes/apps/tools/outline-role-sync/secrets/secret.yaml deleted file mode 100644 index 7121c831..00000000 --- a/kubernetes/apps/tools/outline-role-sync/secrets/secret.yaml +++ /dev/null @@ -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 diff --git a/release-please-config.json b/release-please-config.json index 295b087f..3bd8f8c5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -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", diff --git a/services/outline-role-sync/CHANGELOG.md b/services/outline-role-sync/CHANGELOG.md index 63749acf..67aefd43 100644 --- a/services/outline-role-sync/CHANGELOG.md +++ b/services/outline-role-sync/CHANGELOG.md @@ -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)) diff --git a/services/outline-role-sync/Dockerfile b/services/outline-role-sync/Dockerfile deleted file mode 100644 index 0d0010b5..00000000 --- a/services/outline-role-sync/Dockerfile +++ /dev/null @@ -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"] diff --git a/services/outline-role-sync/deno.json b/services/outline-role-sync/deno.json index 34e7f7b2..563fc7f7 100644 --- a/services/outline-role-sync/deno.json +++ b/services/outline-role-sync/deno.json @@ -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 diff --git a/services/outline-role-sync/deno.lock b/services/outline-role-sync/deno.lock new file mode 100644 index 00000000..4e3807fe --- /dev/null +++ b/services/outline-role-sync/deno.lock @@ -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" + ] + } +} diff --git a/services/outline-role-sync/scripts/build.ts b/services/outline-role-sync/scripts/build.ts new file mode 100644 index 00000000..906f2bc7 --- /dev/null +++ b/services/outline-role-sync/scripts/build.ts @@ -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 })); diff --git a/services/outline-role-sync/src/index.ts b/services/outline-role-sync/src/index.ts new file mode 100644 index 00000000..a36b4516 --- /dev/null +++ b/services/outline-role-sync/src/index.ts @@ -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): 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 { + 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 { + 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 { + // Outline signs `${timestamp}.${body}` with HMAC-SHA256 and sends the result + // as `Outline-Signature: t=,s=`. + const parts = new Map(); + 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 { + 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}`); +} diff --git a/services/outline-role-sync/src/main.ts b/services/outline-role-sync/src/main.ts deleted file mode 100644 index bfda7850..00000000 --- a/services/outline-role-sync/src/main.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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); diff --git a/services/outline-role-sync/src/outline.ts b/services/outline-role-sync/src/outline.ts index 2f686910..2a49b109 100644 --- a/services/outline-role-sync/src/outline.ts +++ b/services/outline-role-sync/src/outline.ts @@ -23,7 +23,10 @@ export class OutlineClient { private apiToken: string, ) {} - private async request(path: string, body: Record = {}): Promise { + private async request( + path: string, + body: Record = {}, + ): Promise { 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 { - 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 { - 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 { - await this.request("/users.update", { id: userId, role }); + async updateUserRole( + userId: string, + role: "admin" | "member" | "viewer", + ): Promise { + // 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 }); + } } } diff --git a/services/outline-role-sync/src/zitadel.ts b/services/outline-role-sync/src/zitadel.ts index 9eb221a1..70807848 100644 --- a/services/outline-role-sync/src/zitadel.ts +++ b/services/outline-role-sync/src/zitadel.ts @@ -15,7 +15,11 @@ export class ZitadelClient { private token: string, ) {} - private async request(method: string, path: string, body?: Record): Promise { + private async request( + method: string, + path: string, + body?: Record, + ): Promise { 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); } } diff --git a/services/outline-role-sync/test/index.test.ts b/services/outline-role-sync/test/index.test.ts new file mode 100644 index 00000000..8a31fbb4 --- /dev/null +++ b/services/outline-role-sync/test/index.test.ts @@ -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=,s=`. +async function sign( + body: string, + secret: string, + timestamp = TIMESTAMP, +): Promise { + 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); +}); diff --git a/services/outline-role-sync/wrangler.jsonc b/services/outline-role-sync/wrangler.jsonc new file mode 100644 index 00000000..b42a5e69 --- /dev/null +++ b/services/outline-role-sync/wrangler.jsonc @@ -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 + } +} diff --git a/tf/deployment/modules/shared/zitadel/cloud/.terraform.lock.hcl b/tf/deployment/modules/shared/zitadel/cloud/.terraform.lock.hcl index 84bcf591..968e63cd 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/.terraform.lock.hcl +++ b/tf/deployment/modules/shared/zitadel/cloud/.terraform.lock.hcl @@ -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" diff --git a/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf b/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf new file mode 100644 index 00000000..7120a825 --- /dev/null +++ b/tf/deployment/modules/shared/zitadel/cloud/outline-role-sync-worker.tf @@ -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] +} diff --git a/tf/deployment/modules/shared/zitadel/cloud/permissions.tf b/tf/deployment/modules/shared/zitadel/cloud/permissions.tf index 127241dd..c22c71d8 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/permissions.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/permissions.tf @@ -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 } diff --git a/tf/deployment/modules/shared/zitadel/cloud/project.tf b/tf/deployment/modules/shared/zitadel/cloud/project.tf index d24229ca..791704a4 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/project.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/project.tf @@ -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"] }, diff --git a/tf/deployment/modules/shared/zitadel/cloud/terragrunt.hcl b/tf/deployment/modules/shared/zitadel/cloud/terragrunt.hcl index 79095f78..64ec9694 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/terragrunt.hcl +++ b/tf/deployment/modules/shared/zitadel/cloud/terragrunt.hcl @@ -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 { diff --git a/tf/deployment/modules/shared/zitadel/cloud/variables.tf b/tf/deployment/modules/shared/zitadel/cloud/variables.tf index 2c047c70..1cbdcd99 100644 --- a/tf/deployment/modules/shared/zitadel/cloud/variables.tf +++ b/tf/deployment/modules/shared/zitadel/cloud/variables.tf @@ -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 +}