diff --git a/.github/workflows/build-outline-role-sync.yml b/.github/workflows/build-outline-role-sync.yml new file mode 100644 index 00000000..1304d06d --- /dev/null +++ b/.github/workflows/build-outline-role-sync.yml @@ -0,0 +1,72 @@ +name: Build and Push outline-role-sync Image + +on: + workflow_dispatch: + push: + branches: [main] + pull_request: + 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@eed0f8b8165ffcb951f2ba854b2dd031935e1d73 # pre-job-action-v2.0.2 + with: + github-token: ${{ github.token }} + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.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@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + flavor: | + # Disable latest tag + latest=false + images: | + name=ghcr.io/${{ github.repository_owner }}/outline-role-sync + + - name: Build and push image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + 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/kubernetes/apps/tools/kustomization.yaml b/kubernetes/apps/tools/kustomization.yaml index 6490f731..d09da9aa 100644 --- a/kubernetes/apps/tools/kustomization.yaml +++ b/kubernetes/apps/tools/kustomization.yaml @@ -7,3 +7,4 @@ 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 new file mode 100644 index 00000000..81312fa0 --- /dev/null +++ b/kubernetes/apps/tools/outline-role-sync/app/helmrelease.yaml @@ -0,0 +1,64 @@ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: outline-role-sync + namespace: tools +spec: + interval: 30m + chart: + spec: + chart: app-template + version: 3.5.0 + sourceRef: + kind: HelmRepository + name: bjw-s + namespace: flux-system + maxHistory: 2 + install: + remediation: + retries: 3 + upgrade: + cleanupOnFail: true + remediation: + strategy: rollback + retries: 3 + values: + controllers: + outline-role-sync: + containers: + app: + image: + repository: ghcr.io/immich-app/outline-role-sync + tag: main + pullPolicy: Always + 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 new file mode 100644 index 00000000..5dd7baca --- /dev/null +++ b/kubernetes/apps/tools/outline-role-sync/app/kustomization.yaml @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000..04b2b71f --- /dev/null +++ b/kubernetes/apps/tools/outline-role-sync/ks.yaml @@ -0,0 +1,44 @@ +--- +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 new file mode 100644 index 00000000..844bf0b3 --- /dev/null +++ b/kubernetes/apps/tools/outline-role-sync/secrets/kustomization.yaml @@ -0,0 +1,5 @@ +--- +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 new file mode 100644 index 00000000..7121c831 --- /dev/null +++ b/kubernetes/apps/tools/outline-role-sync/secrets/secret.yaml @@ -0,0 +1,23 @@ +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/services/outline-role-sync/Dockerfile b/services/outline-role-sync/Dockerfile new file mode 100644 index 00000000..2a3483af --- /dev/null +++ b/services/outline-role-sync/Dockerfile @@ -0,0 +1,14 @@ +FROM denoland/deno:2.1.4 + +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 new file mode 100644 index 00000000..34e7f7b2 --- /dev/null +++ b/services/outline-role-sync/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "start": "deno run --allow-net --allow-env src/main.ts", + "dev": "deno run --watch --allow-net --allow-env src/main.ts" + }, + "compilerOptions": { + "strict": true + } +} diff --git a/services/outline-role-sync/src/main.ts b/services/outline-role-sync/src/main.ts new file mode 100644 index 00000000..bfda7850 --- /dev/null +++ b/services/outline-role-sync/src/main.ts @@ -0,0 +1,165 @@ +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 new file mode 100644 index 00000000..2f686910 --- /dev/null +++ b/services/outline-role-sync/src/outline.ts @@ -0,0 +1,75 @@ +export interface OutlineUser { + id: string; + name: string; + email: string; + role: string; + isSuspended: boolean; +} + +interface OutlineGroup { + id: string; + name: string; +} + +interface OutlineGroupMembership { + id: string; + name: string; + memberCount: number; +} + +export class OutlineClient { + constructor( + private baseUrl: string, + private apiToken: string, + ) {} + + private async request(path: string, body: Record = {}): Promise { + const response = await fetch(`${this.baseUrl}/api${path}`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Outline API error ${response.status} on ${path}: ${text}`); + } + + return (await response.json() as { data: T }).data; + } + + async getUserInfo(userId: string): Promise { + return await this.request("/users.info", { id: userId }); + } + + async getUserGroups(userId: string): Promise { + 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", {}); + return result.groups; + } + + async createGroup(name: string): Promise { + return await this.request("/groups.create", { name }); + } + + async addUserToGroup(groupId: string, userId: string): Promise { + await this.request("/groups.add_user", { id: groupId, userId }); + } + + async removeUserFromGroup(groupId: string, userId: string): Promise { + 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 }); + } +} diff --git a/services/outline-role-sync/src/zitadel.ts b/services/outline-role-sync/src/zitadel.ts new file mode 100644 index 00000000..a7ba6352 --- /dev/null +++ b/services/outline-role-sync/src/zitadel.ts @@ -0,0 +1,74 @@ +interface ZitadelUser { + userId: string; + username: string; +} + +interface ZitadelUserGrant { + id: string; + projectId: string; + roleKeys: string[]; +} + +export class ZitadelClient { + constructor( + private baseUrl: string, + private token: string, + ) {} + + private async request(method: string, path: string, body?: Record): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Zitadel API error ${response.status} on ${path}: ${text}`); + } + + return await response.json() as T; + } + + async findUserByEmail(email: string): Promise { + const result = await this.request<{ result?: ZitadelUser[] }>( + "POST", + "/v2/users", + { + queries: [ + { + emailQuery: { + emailAddress: email, + method: "TEXT_QUERY_METHOD_EQUALS", + }, + }, + ], + }, + ); + + const users = result.result ?? []; + return users.length > 0 ? users[0] : null; + } + + async getUserGrants(userId: string, projectId: string): Promise { + const result = await this.request<{ result?: ZitadelUserGrant[] }>( + "POST", + `/management/v1/users/${userId}/grants/_search`, + { + queries: [ + { + projectIdQuery: { + projectId, + }, + }, + ], + }, + ); + + const grants = result.result ?? []; + return grants.flatMap((grant) => grant.roleKeys); + } +} diff --git a/tf/deployment/.env b/tf/deployment/.env index 68cc2e4e..52eb3062 100644 --- a/tf/deployment/.env +++ b/tf/deployment/.env @@ -11,6 +11,9 @@ export TF_VAR_discord_token="op://tf/IMMICH_TF_DISCORD_BOT_TOKEN/password" export TF_VAR_zitadel_profile_json="op://tf/ZITADEL_PROFILE_JSON/password" export TF_VAR_zitadel_github_client_id="op://tf/GITHUB_OAUTH_APP_IMMICH_ZITADEL_CLIENT_ID/password" export TF_VAR_zitadel_github_client_secret="op://tf/GITHUB_OAUTH_APP_IMMICH_ZITADEL_CLIENT_SECRET/password" +export TF_VAR_zitadel_gitlab_client_id="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_ID/password" +export TF_VAR_zitadel_gitlab_client_secret="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_SECRET/password" +export TF_VAR_zitadel_gitlab_issuer="op://tf/GITLAB_OAUTH_APP_FUTO_ZITADEL_ISSUER/password" export DOCKER_USERNAME="op://tf/dockerhub/username" export DOCKER_PASSWORD="op://tf/dockerhub/password" diff --git a/tf/deployment/data/users.json b/tf/deployment/data/users.json index 496929bd..af6930c0 100644 --- a/tf/deployment/data/users.json +++ b/tf/deployment/data/users.json @@ -167,9 +167,10 @@ ] }, { - "github": { - "username": "", - "id": 0 + "github": null, + "gitlab": { + "username": "eron", + "id": 2 }, "discord": { "username": "eronwolf", diff --git a/tf/deployment/modules/shared/1password/account/secrets.tf b/tf/deployment/modules/shared/1password/account/secrets.tf index 3540f31a..7fcb66ac 100644 --- a/tf/deployment/modules/shared/1password/account/secrets.tf +++ b/tf/deployment/modules/shared/1password/account/secrets.tf @@ -41,7 +41,11 @@ module "manual-secrets" { "IOS_PROVISIONING_PROFILE_SHARE_EXTENSION", "IOS_DEVELOPMENT_PROVISIONING_PROFILE", "IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION", - "IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION" + "IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION", + "OUTLINE_ROLE_SYNC_OUTLINE_API_TOKEN", + "GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_ID", + "GITLAB_OAUTH_APP_FUTO_ZITADEL_CLIENT_SECRET", + "GITLAB_OAUTH_APP_FUTO_ZITADEL_ISSUER" ] dev = [ "IMMICH_DISCORD_BOT_TOKEN", @@ -75,7 +79,8 @@ module "generated-secrets" { { name = "OUTLINE_VOLSYNC_BACKUPS_RESTIC_SECRET" }, { name = "VICTORIALOGS_VOLSYNC_BACKUPS_RESTIC_SECRET" }, { name = "OAUTH2_PROXY_COOKIE_SECRET", length = 32 }, - { name = "IMMICH_GITHUB_ACTION_CHECKS_WEBHOOK_SECRET" } + { name = "IMMICH_GITHUB_ACTION_CHECKS_WEBHOOK_SECRET" }, + { name = "OUTLINE_ROLE_SYNC_WEBHOOK_SECRET" } ] dev = [ { name = "METRICS_READ_TOKEN" }, diff --git a/tf/deployment/modules/shared/zitadel/actions.tf b/tf/deployment/modules/shared/zitadel/actions.tf index e4de2547..954986b6 100644 --- a/tf/deployment/modules/shared/zitadel/actions.tf +++ b/tf/deployment/modules/shared/zitadel/actions.tf @@ -62,9 +62,53 @@ resource "zitadel_action" "map_github_oauth" { timeout = "10s" } -resource "zitadel_trigger_actions" "map_github_oauth" { +resource "zitadel_action" "map_gitlab_oauth" { + org_id = zitadel_org.immich.id + name = "mapGitLabOAuth" + script = <<-EOT + let logger = require("zitadel/log"); + + function mapGitLabOAuth(ctx, api) { + if (ctx.v1.externalUser.externalIdpId !== "${zitadel_idp_gitlab_self_hosted.gitlab.id}") { + return; + } + + // GitLab uses OIDC so email is available via providerInfo + if (ctx.v1.providerInfo.email) { + api.setEmail(ctx.v1.providerInfo.email); + api.setEmailVerified(ctx.v1.providerInfo.email_verified || false); + } else { + logger.warn("No email found in GitLab response for user: " + ctx.v1.externalUser.human.displayName); + } + + let firstName = ctx.v1.providerInfo.nickname || ctx.v1.providerInfo.preferred_username; + let lastName = " "; + + const name = ctx.v1.providerInfo.name || ""; + const nameParts = name.trim().split(" "); + if (nameParts.length > 0 && nameParts[0].length > 0) { + firstName = nameParts[0]; + } + if (nameParts.length > 1) { + lastName = nameParts.slice(1).join(" "); + } + + api.setFirstName(firstName); + api.setLastName(lastName); + } + EOT + allowed_to_fail = true + timeout = "10s" +} + +moved { + from = zitadel_trigger_actions.map_github_oauth + to = zitadel_trigger_actions.map_external_oauth +} + +resource "zitadel_trigger_actions" "map_external_oauth" { org_id = zitadel_org.immich.id - action_ids = [zitadel_action.map_github_oauth.id] + action_ids = [zitadel_action.map_github_oauth.id, zitadel_action.map_gitlab_oauth.id] trigger_type = "TRIGGER_TYPE_POST_AUTHENTICATION" flow_type = "FLOW_TYPE_EXTERNAL_AUTHENTICATION" } diff --git a/tf/deployment/modules/shared/zitadel/defaults.tf b/tf/deployment/modules/shared/zitadel/defaults.tf index 22964be1..bff11c4c 100644 --- a/tf/deployment/modules/shared/zitadel/defaults.tf +++ b/tf/deployment/modules/shared/zitadel/defaults.tf @@ -15,7 +15,7 @@ resource "zitadel_default_login_policy" "default" { default_redirect_uri = "" second_factors = [] multi_factors = [] - idps = [zitadel_idp_github.github.id] + idps = [zitadel_idp_github.github.id, zitadel_idp_gitlab_self_hosted.gitlab.id] allow_domain_discovery = false disable_login_with_email = true disable_login_with_phone = true @@ -40,8 +40,8 @@ resource "zitadel_project" "zitadel" { resource "zitadel_instance_member" "superusers" { for_each = { - for user in local.users_data : user.github.id => user - if user.github.username != null && user.github.username != "" && contains(user.roles, "admin") + for key, user in local.zitadel_users : key => user + if contains(user.roles, "admin") } user_id = zitadel_human_user.users[each.key].id roles = ["IAM_OWNER"] @@ -56,8 +56,8 @@ resource "zitadel_project_role" "zitadel_admin" { resource "zitadel_user_grant" "superusers" { for_each = { - for user in local.users_data : user.github.id => user - if user.github.username != null && user.github.username != "" && contains(user.roles, "admin") + for key, user in local.zitadel_users : key => user + if contains(user.roles, "admin") } org_id = zitadel_project.zitadel.org_id project_id = zitadel_project.zitadel.id diff --git a/tf/deployment/modules/shared/zitadel/idp.tf b/tf/deployment/modules/shared/zitadel/idp.tf index 616f7cce..481d5d67 100644 --- a/tf/deployment/modules/shared/zitadel/idp.tf +++ b/tf/deployment/modules/shared/zitadel/idp.tf @@ -9,3 +9,16 @@ resource "zitadel_idp_github" "github" { is_linking_allowed = true auto_linking = "AUTO_LINKING_OPTION_USERNAME" } + +resource "zitadel_idp_gitlab_self_hosted" "gitlab" { + name = "FUTO GitLab" + client_id = var.zitadel_gitlab_client_id + client_secret = var.zitadel_gitlab_client_secret + issuer = var.zitadel_gitlab_issuer + scopes = ["openid", "profile", "email"] + is_auto_creation = false + is_auto_update = true + is_creation_allowed = false + is_linking_allowed = true + auto_linking = "AUTO_LINKING_OPTION_USERNAME" +} diff --git a/tf/deployment/modules/shared/zitadel/outline-role-sync.tf b/tf/deployment/modules/shared/zitadel/outline-role-sync.tf new file mode 100644 index 00000000..5d385a66 --- /dev/null +++ b/tf/deployment/modules/shared/zitadel/outline-role-sync.tf @@ -0,0 +1,32 @@ +resource "zitadel_machine_user" "outline_role_sync" { + org_id = zitadel_org.immich.id + user_name = "outline-role-sync" + name = "Outline Role Sync" + description = "Service account for syncing Zitadel roles to Outline groups" +} + +resource "zitadel_personal_access_token" "outline_role_sync" { + org_id = zitadel_org.immich.id + user_id = zitadel_machine_user.outline_role_sync.id + expiration_date = "2030-01-01T00:00:00Z" +} + +resource "zitadel_org_member" "outline_role_sync" { + org_id = zitadel_org.immich.id + user_id = zitadel_machine_user.outline_role_sync.id + roles = ["ORG_USER_MANAGER"] +} + +resource "onepassword_item" "outline_role_sync_zitadel_token" { + vault = data.onepassword_vault.tf.uuid + title = "OUTLINE_ROLE_SYNC_ZITADEL_TOKEN" + category = "password" + password = zitadel_personal_access_token.outline_role_sync.token +} + +resource "onepassword_item" "outline_role_sync_zitadel_project_id" { + vault = data.onepassword_vault.tf.uuid + title = "OUTLINE_ROLE_SYNC_ZITADEL_PROJECT_ID" + category = "password" + password = zitadel_project.projects["Outline"].id +} diff --git a/tf/deployment/modules/shared/zitadel/permissions.tf b/tf/deployment/modules/shared/zitadel/permissions.tf index 6ec0c713..127241dd 100644 --- a/tf/deployment/modules/shared/zitadel/permissions.tf +++ b/tf/deployment/modules/shared/zitadel/permissions.tf @@ -11,13 +11,12 @@ locals { # For each user+project, grant only the highest-priority role (first match in the ordered list) project_user_grants = flatten([ for project in local.projects : [ - for user in local.users_data : { - 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] - github_user_id = user.github.id + 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 } if length([for role in project.roles : role.key if length(setintersection(toset(role.grants_to), toset(user.roles))) > 0]) > 0 - && user.github.username != null && user.github.username != "" ] ]) } @@ -36,12 +35,12 @@ resource "zitadel_project_role" "project_roles" { resource "zitadel_user_grant" "project_grants" { for_each = { - for grant in local.project_user_grants : "${grant.project_name}_${grant.github_user_id}" => grant + for grant in local.project_user_grants : "${grant.project_name}_${grant.user_key}" => grant } depends_on = [zitadel_project_role.project_roles] org_id = zitadel_org.immich.id project_id = zitadel_project.projects[each.value.project_name].id - user_id = zitadel_human_user.users[each.value.github_user_id].id + user_id = zitadel_human_user.users[each.value.user_key].id role_keys = [each.value.role_key] } diff --git a/tf/deployment/modules/shared/zitadel/users.tf b/tf/deployment/modules/shared/zitadel/users.tf index c451b5a0..6b522948 100644 --- a/tf/deployment/modules/shared/zitadel/users.tf +++ b/tf/deployment/modules/shared/zitadel/users.tf @@ -1,25 +1,36 @@ locals { users_data = jsondecode(file(var.users_data_file_path)) + + github_users = { + for user in local.users_data : tostring(user.github.id) => user + if try(user.github.username, "") != "" + } + + gitlab_only_users = { + for user in local.users_data : "gitlab-${user.gitlab.id}" => user + if try(user.github.username, "") == "" && try(user.gitlab.username, "") != "" + } + + zitadel_users = merge(local.github_users, local.gitlab_only_users) } resource "random_password" "zitadel_user_initial_password" { - for_each = { - for user in local.users_data : user.github.id => user - if user.github.username != null && user.github.username != "" - } - length = 64 - special = true + for_each = local.zitadel_users + length = 64 + special = true } resource "zitadel_human_user" "users" { - for_each = { - for user in local.users_data : user.github.id => user - if user.github.username != null && user.github.username != "" - } - email = "${each.value.github.id}+${each.value.github.username}@users.noreply.github.com" - first_name = each.value.github.username + for_each = local.zitadel_users + + email = ( + try(each.value.github.username, "") != "" + ? "${each.value.github.id}+${each.value.github.username}@users.noreply.github.com" + : "${each.value.gitlab.username}@${replace(var.zitadel_gitlab_issuer, "https://", "")}" + ) + first_name = try(each.value.github.username, "") != "" ? each.value.github.username : each.value.gitlab.username last_name = "last_name" - user_name = each.value.github.username + user_name = try(each.value.github.username, "") != "" ? each.value.github.username : each.value.gitlab.username org_id = zitadel_org.immich.id initial_password = random_password.zitadel_user_initial_password[each.key].result initial_skip_password_change = true @@ -37,12 +48,9 @@ resource "zitadel_human_user" "users" { } resource "zitadel_user_metadata" "role" { - for_each = { - for user in local.users_data : user.github.id => user - if user.github.username != null && user.github.username != "" - } - org_id = zitadel_org.immich.id - user_id = zitadel_human_user.users[each.key].id - key = "role" - value = jsonencode(each.value.roles) + for_each = local.zitadel_users + org_id = zitadel_org.immich.id + user_id = zitadel_human_user.users[each.key].id + key = "role" + value = jsonencode(each.value.roles) } diff --git a/tf/deployment/modules/shared/zitadel/variables.tf b/tf/deployment/modules/shared/zitadel/variables.tf index 72f776de..4e74001a 100644 --- a/tf/deployment/modules/shared/zitadel/variables.tf +++ b/tf/deployment/modules/shared/zitadel/variables.tf @@ -13,3 +13,11 @@ variable "zitadel_github_client_id" {} variable "zitadel_github_client_secret" { sensitive = true } + +variable "zitadel_gitlab_client_id" {} +variable "zitadel_gitlab_client_secret" { + sensitive = true +} +variable "zitadel_gitlab_issuer" { + description = "The base URL of the FUTO self-hosted GitLab instance" +}