mirror of
https://github.com/immich-app/devtools.git
synced 2026-09-30 13:23:00 +08:00
feat: run outline-role-sync as a cloudflare worker (#1698)
This commit is contained in:
@@ -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 }}
|
||||
@@ -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() }}
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Ask2AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="EditMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -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
@@ -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>
|
||||
Generated
+7
@@ -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>
|
||||
Generated
+6
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+45
@@ -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
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Generated
+78
@@ -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 }));
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user