From 58f5dce02b8d418eba18cabc5e68814e4dc297e2 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Mon, 6 Jul 2026 13:26:00 +0200 Subject: [PATCH 1/3] feat: add stackit org user roles --- modules/stackit/meshstack_integration.tf | 34 +++ modules/stackit/project/backplane/README.md | 2 + modules/stackit/project/backplane/main.tf | 6 + .../project/buildingblock/APP_TEAM_README.md | 8 +- .../stackit/project/buildingblock/README.md | 2 +- .../stackit/project/buildingblock/prerun.sh | 224 ++++++++++++++++++ 6 files changed, 274 insertions(+), 2 deletions(-) create mode 100755 modules/stackit/project/buildingblock/prerun.sh diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index ba588900..49882375 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -8,6 +8,12 @@ variable "stackit_organization_id" { description = "STACKIT organization ID where the service account will be granted permissions." } +variable "stackit_organization_member_role" { + type = string + default = "organization.viewer" + description = "STACKIT organization role assigned best-effort to all meshStack project users before project role assignments are applied." +} + variable "stackit_parent_container_id" { type = string description = "Default parent container ID (organization or folder) for project creation." @@ -179,6 +185,7 @@ resource "meshstack_building_block_definition" "this" { ref_name = var.hub.git_ref async = false use_mesh_http_backend_fallback = true + pre_run_script = file("${path.module}/project/buildingblock/prerun.sh") } } @@ -217,6 +224,33 @@ resource "meshstack_building_block_definition" "this" { argument = jsonencode("/var/run/secrets/workload-identity/azure/token") } + STACKIT_SERVICE_ACCOUNT_EMAIL = { + display_name = "STACKIT Service Account Email" + description = "Service account email used by the pre-run script for WIF token exchange." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.service_account_email) + } + + STACKIT_ORGANIZATION_ID = { + display_name = "STACKIT Organization ID" + description = "STACKIT organization where meshStack project users are added best-effort before project role assignments are applied." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.stackit_organization_id) + } + + STACKIT_ORGANIZATION_MEMBER_ROLE = { + display_name = "STACKIT Organization Member Role" + description = "STACKIT organization role assigned best-effort to all meshStack project users." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.stackit_organization_member_role) + } + project_name = { display_name = "Project Name" description = "Name of the STACKIT project." diff --git a/modules/stackit/project/backplane/README.md b/modules/stackit/project/backplane/README.md index f054d9b7..f2b06204 100644 --- a/modules/stackit/project/backplane/README.md +++ b/modules/stackit/project/backplane/README.md @@ -5,6 +5,7 @@ It creates a dedicated service account with a Workload Identity Federation (WIF) and the permissions required to create and manage STACKIT projects under a given organization: - **`resource-manager.admin`** — allows creating and managing projects within the organization. +- **`iam.member-admin`** — allows the building block pre-run script to add meshStack project users to the STACKIT organization before project-level role assignments are applied. Authentication uses WIF (OIDC token exchange) — no long-lived service account key is created or stored. @@ -51,6 +52,7 @@ No modules. | Name | Type | |------|------| +| [stackit_authorization_organization_role_assignment.member_admin](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/authorization_organization_role_assignment) | resource | | [stackit_authorization_organization_role_assignment.project_admin](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/authorization_organization_role_assignment) | resource | | [stackit_service_account.building_block](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account) | resource | | [stackit_service_account_federated_identity_provider.building_block](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs/resources/service_account_federated_identity_provider) | resource | diff --git a/modules/stackit/project/backplane/main.tf b/modules/stackit/project/backplane/main.tf index 6e4eab0b..4bf3d684 100644 --- a/modules/stackit/project/backplane/main.tf +++ b/modules/stackit/project/backplane/main.tf @@ -30,3 +30,9 @@ resource "stackit_authorization_organization_role_assignment" "project_admin" { role = "resource-manager.admin" subject = stackit_service_account.building_block.email } + +resource "stackit_authorization_organization_role_assignment" "member_admin" { + resource_id = var.organization_id + role = "iam.member-admin" + subject = stackit_service_account.building_block.email +} diff --git a/modules/stackit/project/buildingblock/APP_TEAM_README.md b/modules/stackit/project/buildingblock/APP_TEAM_README.md index a70ecdfe..1aa73be2 100644 --- a/modules/stackit/project/buildingblock/APP_TEAM_README.md +++ b/modules/stackit/project/buildingblock/APP_TEAM_README.md @@ -1,7 +1,7 @@ # StackIt Project ## Description -This building block creates a new STACKIT project and manages user access permissions with configurable role mapping. It provides application teams with a secure, isolated environment for deploying their workloads while ensuring proper access controls. +This building block creates a new STACKIT project and manages user access permissions with configurable role mapping. It also performs best-effort STACKIT organization onboarding for all assigned users before project permissions are applied. It provides application teams with a secure, isolated environment for deploying their workloads while ensuring proper access controls. ## Usage Motivation This building block is designed for application teams that need to: @@ -85,6 +85,12 @@ role_mapping = { Unknown meshStack roles are ignored. If a user has multiple meshStack roles, all mapped STACKIT roles are assigned once. +### STACKIT Organization Membership + +Before applying project-level role assignments, the building block runs a best-effort pre-run step that adds all assigned meshStack users to the STACKIT organization with the organization role configured by the platform team. The default organization role is `organization.viewer`. + +This onboarding step is apply-only and does not remove organization memberships during destroy because organization membership can be shared by multiple projects. If the onboarding request fails, the building block logs a warning and continues; project-level role assignment remains authoritative and may still fail if STACKIT rejects a user that is not an organization member. + ### Environment-Based Parent Container Selection The building block supports automatic parent container selection based on environment type: diff --git a/modules/stackit/project/buildingblock/README.md b/modules/stackit/project/buildingblock/README.md index 4c762461..108151f8 100644 --- a/modules/stackit/project/buildingblock/README.md +++ b/modules/stackit/project/buildingblock/README.md @@ -8,7 +8,7 @@ description: | # STACKIT Project Building Block -This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. +This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users before project-level role assignments are applied. ## Requirements diff --git a/modules/stackit/project/buildingblock/prerun.sh b/modules/stackit/project/buildingblock/prerun.sh new file mode 100755 index 00000000..eedd2691 --- /dev/null +++ b/modules/stackit/project/buildingblock/prerun.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash + +# Best-effort STACKIT organization membership onboarding. +# Runs after OpenTofu init and before apply/destroy. This script must never block +# the building block run: all errors are logged as warnings and the script exits 0. + +RUN_INPUT_FILE="" + +info() { + echo "[stackit-org-membership] $*" +} + +warn() { + echo "[stackit-org-membership] WARN: $*" >&2 + if [[ -n "${MESHSTACK_USER_MESSAGE:-}" ]]; then + echo "STACKIT organization membership warning: $*" >> "$MESHSTACK_USER_MESSAGE" 2>/dev/null || true + fi +} + +cleanup() { + if [[ -n "$RUN_INPUT_FILE" && -f "$RUN_INPUT_FILE" ]]; then + rm -f "$RUN_INPUT_FILE" + fi +} + +is_destroy_run() { + local mode + mode=$(printf '%s' "${1:-APPLY}" | tr '[:lower:]' '[:upper:]') + [[ "$mode" == "DESTROY" ]] +} + +capture_run_input() { + RUN_INPUT_FILE=$(mktemp) + cat > "$RUN_INPUT_FILE" 2>/dev/null || true +} + +extract_users_json() { + if [[ ! -s "$RUN_INPUT_FILE" ]]; then + printf '[]\n' + return 0 + fi + + jq -c '[.spec.buildingBlock.spec.inputs[]? | select(.key == "users") | .value | fromjson][0] // []' "$RUN_INPUT_FILE" 2>/dev/null \ + || printf '[]\n' +} + +extract_emails_json() { + local users_json="$1" + + printf '%s' "$users_json" \ + | jq -c '[.[]? | .email? // empty | strings | ascii_downcase | select(length > 0)] | unique' 2>/dev/null \ + || printf '[]' +} + +get_access_token() { + local service_account_email="$1" + + if [[ -n "${STACKIT_ACCESS_TOKEN:-}" ]]; then + printf '%s' "$STACKIT_ACCESS_TOKEN" + return 0 + fi + + if [[ -z "$service_account_email" ]]; then + warn "STACKIT_SERVICE_ACCOUNT_EMAIL is not set; cannot exchange WIF token" + return 1 + fi + + local token_file="${STACKIT_FEDERATED_TOKEN_FILE:-/var/run/secrets/stackit.cloud/serviceaccount/token}" + if [[ ! -r "$token_file" ]]; then + warn "STACKIT federated token file '$token_file' is not readable" + return 1 + fi + + local assertion + assertion=$(cat "$token_file" 2>/dev/null || true) + if [[ -z "$assertion" ]]; then + warn "STACKIT federated token file '$token_file' is empty" + return 1 + fi + + local token_endpoint="${STACKIT_IDP_TOKEN_ENDPOINT:-https://accounts.stackit.cloud/oauth/v2/token}" + local response_file error_file http_code rc access_token + response_file=$(mktemp) + error_file=$(mktemp) + + http_code=$(curl -sS -o "$response_file" -w '%{http_code}' \ + -X POST "$token_endpoint" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'grant_type=client_credentials' \ + --data-urlencode 'client_assertion_type=urn:schwarz:params:oauth:client-assertion-type:workload-jwt' \ + --data-urlencode "client_assertion=$assertion" \ + --data-urlencode "client_id=$service_account_email" \ + 2>"$error_file") + rc=$? + + if [[ "$rc" -ne 0 ]]; then + warn "token exchange request failed: $(cat "$error_file" 2>/dev/null)" + return 1 + fi + + case "$http_code" in + 2*) ;; + *) + warn "token exchange returned HTTP $http_code: $(cat "$response_file" 2>/dev/null | cut -c1-500)" + return 1 + ;; + esac + + access_token=$(jq -r '.access_token // empty' "$response_file" 2>/dev/null || true) + if [[ -z "$access_token" ]]; then + warn "token exchange response did not contain an access_token" + return 1 + fi + + printf '%s' "$access_token" +} + +build_membership_payload() { + local organization_role="$1" + local emails_json="$2" + + jq -n \ + --arg role "$organization_role" \ + --argjson emails "$emails_json" \ + '{resourceType: "organization", members: [$emails[] | {subject: ., role: $role}]}' +} + +call_membership_api_with_curl() { + local url="$1" + local payload="$2" + local access_token="$3" + local response_file error_file http_code rc + + response_file=$(mktemp) + error_file=$(mktemp) + http_code=$(curl -sS -o "$response_file" -w '%{http_code}' \ + -X PATCH "$url" \ + -H "Authorization: Bearer $access_token" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + 2>"$error_file") + rc=$? + + if [[ "$rc" -ne 0 ]]; then + warn "Membership API request failed: $(cat "$error_file" 2>/dev/null)" + return 0 + fi + + case "$http_code" in + 2*) + info "organization membership onboarding request completed" + ;; + *) + warn "Membership API returned HTTP $http_code: $(cat "$response_file" 2>/dev/null | cut -c1-500)" + ;; + esac + + return 0 +} + +onboard_organization_members() { + local emails_json="$1" + local email_count="$2" + local organization_id="${STACKIT_ORGANIZATION_ID:-}" + local organization_role="${STACKIT_ORGANIZATION_MEMBER_ROLE:-organization.viewer}" + local service_account_email="${STACKIT_SERVICE_ACCOUNT_EMAIL:-}" + local encoded_organization_id url payload access_token + + if [[ -z "$organization_id" ]]; then + warn "STACKIT_ORGANIZATION_ID is not set; cannot add organization memberships" + return 0 + fi + + if [[ -z "$organization_role" ]]; then + warn "STACKIT_ORGANIZATION_MEMBER_ROLE is empty; cannot add organization memberships" + return 0 + fi + + if ! command -v curl >/dev/null 2>&1; then + warn "curl is not available; cannot call STACKIT Membership API" + return 0 + fi + + payload=$(build_membership_payload "$organization_role" "$emails_json") + encoded_organization_id=$(printf '%s' "$organization_id" | jq -sRr @uri) + url="https://authorization.api.stackit.cloud/v2/${encoded_organization_id}/members" + + info "ensuring $email_count user(s) have organization role '$organization_role' on '$organization_id'" + + access_token=$(get_access_token "$service_account_email" || true) + if [[ -z "$access_token" ]]; then + warn "could not obtain STACKIT access token; skipping organization membership onboarding" + return 0 + fi + + call_membership_api_with_curl "$url" "$payload" "$access_token" +} + +main() { + local users_json emails_json email_count + + trap cleanup EXIT + + if is_destroy_run "${1:-APPLY}"; then + info "destroy run detected; skipping organization membership onboarding" + return 0 + fi + + capture_run_input + + users_json=$(extract_users_json) + emails_json=$(extract_emails_json "$users_json") + email_count=$(printf '%s' "$emails_json" | jq -r 'length' 2>/dev/null || printf '0') + + if [[ "$email_count" == "0" ]]; then + info "no users found; skipping organization membership onboarding" + return 0 + fi + + onboard_organization_members "$emails_json" "$email_count" + return 0 +} + +main "$@" || true From cfbf33d2081bbfacb710a515efa0d8cb8701077a Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 8 Jul 2026 11:18:23 +0200 Subject: [PATCH 2/3] feat: provide stackit org user status as summary --- modules/stackit/meshstack_integration.tf | 10 +- .../project/buildingblock/APP_TEAM_README.md | 2 + .../stackit/project/buildingblock/README.md | 3 +- .../stackit/project/buildingblock/outputs.tf | 6 +- .../stackit/project/buildingblock/prerun.py | 279 ++++++++++++++++++ .../stackit/project/buildingblock/prerun.sh | 225 +------------- 6 files changed, 298 insertions(+), 227 deletions(-) create mode 100755 modules/stackit/project/buildingblock/prerun.py diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index 49882375..ac409331 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -185,7 +185,9 @@ resource "meshstack_building_block_definition" "this" { ref_name = var.hub.git_ref async = false use_mesh_http_backend_fallback = true - pre_run_script = file("${path.module}/project/buildingblock/prerun.sh") + pre_run_script = <<-SH + exec python3 "./prerun.py" "$@" + SH } } @@ -296,6 +298,12 @@ resource "meshstack_building_block_definition" "this" { project_name = { display_name = "Project Name" type = "STRING" + assignment_type = "NONE" + } + + summary = { + display_name = "Summary" + type = "STRING" assignment_type = "SUMMARY" } } diff --git a/modules/stackit/project/buildingblock/APP_TEAM_README.md b/modules/stackit/project/buildingblock/APP_TEAM_README.md index 1aa73be2..f3199be7 100644 --- a/modules/stackit/project/buildingblock/APP_TEAM_README.md +++ b/modules/stackit/project/buildingblock/APP_TEAM_README.md @@ -91,6 +91,8 @@ Before applying project-level role assignments, the building block runs a best-e This onboarding step is apply-only and does not remove organization memberships during destroy because organization membership can be shared by multiple projects. If the onboarding request fails, the building block logs a warning and continues; project-level role assignment remains authoritative and may still fail if STACKIT rejects a user that is not an organization member. +The building block summary shows which organization add requests succeeded or failed and whether each assigned user currently has the required organization role. If access is missing or could not be checked, the summary includes remediation guidance for the platform team. + ### Environment-Based Parent Container Selection The building block supports automatic parent container selection based on environment type: diff --git a/modules/stackit/project/buildingblock/README.md b/modules/stackit/project/buildingblock/README.md index 108151f8..5dddbe87 100644 --- a/modules/stackit/project/buildingblock/README.md +++ b/modules/stackit/project/buildingblock/README.md @@ -8,7 +8,7 @@ description: | # STACKIT Project Building Block -This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users before project-level role assignments are applied. +This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users and writes a summary that reports which add requests succeeded or failed and whether the required organization role is currently assigned. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users before project-level role assignments are applied. ## Requirements @@ -78,4 +78,5 @@ No modules. | [project\_id](#output\_project\_id) | The UUID of the created StackIt project. | | [project\_name](#output\_project\_name) | The name of the created StackIt project. | | [project\_url](#output\_project\_url) | The deep link URL to access the project in the StackIt portal. | +| [summary](#output\_summary) | Summary of STACKIT organization membership onboarding for assigned project users. | \ No newline at end of file diff --git a/modules/stackit/project/buildingblock/outputs.tf b/modules/stackit/project/buildingblock/outputs.tf index 177df76d..9bec4a92 100644 --- a/modules/stackit/project/buildingblock/outputs.tf +++ b/modules/stackit/project/buildingblock/outputs.tf @@ -13,8 +13,12 @@ output "project_name" { description = "The name of the created StackIt project." } - output "project_url" { value = "https://portal.stackit.cloud/projects/${stackit_resourcemanager_project.project.project_id}" description = "The deep link URL to access the project in the StackIt portal." +} + +output "summary" { + value = fileexists("${path.module}/stackit_organization_membership_summary.md") ? file("${path.module}/stackit_organization_membership_summary.md") : "STACKIT organization membership summary was not generated." + description = "Summary of STACKIT organization membership onboarding for assigned project users." } \ No newline at end of file diff --git a/modules/stackit/project/buildingblock/prerun.py b/modules/stackit/project/buildingblock/prerun.py new file mode 100755 index 00000000..c4b1f60d --- /dev/null +++ b/modules/stackit/project/buildingblock/prerun.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Best-effort STACKIT organization membership onboarding.""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +SUMMARY_FILE = Path("stackit_organization_membership_summary.md") +AUTHORIZATION_ENDPOINT = "https://authorization.api.stackit.cloud" +TOKEN_ENDPOINT = "https://accounts.stackit.cloud/oauth/v2/token" +DEFAULT_ORGANIZATION_ROLE = "organization.viewer" +DEFAULT_FEDERATED_TOKEN_FILE = "/var/run/secrets/stackit.cloud/serviceaccount/token" +WIF_CLIENT_ASSERTION_TYPE = "urn:schwarz:params:oauth:client-assertion-type:workload-jwt" + + +def info(message: str) -> None: + print(f"[stackit-org-membership] {message}") + + +def warn(message: str) -> None: + print(f"[stackit-org-membership] WARN: {message}", file=sys.stderr) + if user_message_file := os.environ.get("MESHSTACK_USER_MESSAGE"): + with open(user_message_file, "a", encoding="utf-8") as handle: + handle.write(f"STACKIT organization membership warning: {message}\n") + + +def request( + method: str, + url: str, + *, + token: str | None = None, + json_body: dict | None = None, + form_body: dict | None = None, +) -> tuple[int, str]: + headers = {} + data = None + + if token: + headers["Authorization"] = f"Bearer {token}" + + if json_body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(json_body).encode("utf-8") + + if form_body is not None: + headers["Content-Type"] = "application/x-www-form-urlencoded" + data = urllib.parse.urlencode(form_body).encode("utf-8") + + try: + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=30) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as error: + return error.code, error.read().decode("utf-8", errors="replace") + except urllib.error.URLError as error: + return 0, str(error.reason) + + +def extract_emails(run_input: str) -> list[str]: + run = json.loads(run_input) + inputs = run["spec"]["buildingBlock"]["spec"]["inputs"] + users_input = next(item for item in inputs if item["key"] == "users") + users = json.loads(users_input["value"]) + return sorted({user["email"].strip().lower() for user in users}) + + +def get_access_token() -> str: + if token := os.environ.get("STACKIT_ACCESS_TOKEN"): + return token + + token_file = Path(os.environ.get("STACKIT_FEDERATED_TOKEN_FILE", DEFAULT_FEDERATED_TOKEN_FILE)) + code, body = request( + "POST", + os.environ.get("STACKIT_IDP_TOKEN_ENDPOINT", TOKEN_ENDPOINT), + form_body={ + "grant_type": "client_credentials", + "client_assertion_type": WIF_CLIENT_ASSERTION_TYPE, + "client_assertion": token_file.read_text(encoding="utf-8").strip(), + "client_id": os.environ["STACKIT_SERVICE_ACCOUNT_EMAIL"], + }, + ) + + if not 200 <= code < 300: + warn(f"token exchange returned HTTP {code}: {body[:500]}") + return "" + + token = json.loads(body).get("access_token", "") + if not token: + warn("token exchange response did not contain an access_token") + return token + + +def add_member(organization_id: str, role: str, email: str, token: str) -> dict[str, str]: + code, body = request( + "PATCH", + f"{AUTHORIZATION_ENDPOINT}/v2/{urllib.parse.quote(organization_id, safe='')}/members", + token=token, + json_body={"resourceType": "organization", "members": [{"subject": email, "role": role}]}, + ) + + if 200 <= code < 300: + return {"status": "succeeded", "details": "Add request succeeded."} + + details = f"Add request returned HTTP {code}: {body[:300]}" + warn(f"{email}: {details}") + return {"status": "failed", "details": details} + + +def get_assignment(organization_id: str, role: str, email: str, token: str) -> dict[str, str]: + org = urllib.parse.quote(organization_id, safe="") + subject = urllib.parse.quote(email, safe="") + code, body = request( + "GET", + f"{AUTHORIZATION_ENDPOINT}/v2/organization/{org}/members?subject={subject}", + token=token, + ) + + if not 200 <= code < 300: + return { + "status": "unknown", + "details": f"Could not query organization membership. STACKIT API returned HTTP '{code}'.", + } + + roles = sorted( + { + member["role"] + for member in json.loads(body).get("members", []) + if member["subject"].lower() == email + } + ) + + if role in roles: + return {"status": "assigned", "details": "Required organization role is assigned."} + + if roles: + current_roles = ", ".join(f"`{current_role}`" for current_role in roles) + return { + "status": "missing", + "details": f"Current organization roles: {current_roles}; missing required role `{role}`.", + } + + return { + "status": "missing", + "details": "No organization membership for this user was returned by STACKIT.", + } + + +def make_row( + email: str, + role: str, + add_status: str, + add_details: str, + assignment_status: str, + assignment_details: str, +) -> dict[str, str]: + return { + "email": email, + "role": role, + "add_status": add_status, + "add_details": add_details, + "assignment_status": assignment_status, + "assignment_details": assignment_details, + } + + +def markdown_cell(value: str) -> str: + return value.replace("|", "\\|").replace("\n", " ") + + +def write_summary(organization_id: str, role: str, rows: list[dict[str, str]]) -> None: + lines = [ + "# STACKIT Organization Membership", + "", + "The pre-run script adds all meshStack users assigned to this project to the STACKIT organization with the required organization role, then checks the resulting membership status before project-level role assignments are applied.", + "", + f"- **Organization ID**: `{organization_id}`", + f"- **Required organization role**: `{role}`", + "", + "| User | Required role | Add request | Current assignment | Details |", + "|------|---------------|-------------|--------------------|---------|", + ] + + for row in rows: + add_icon = {"succeeded": "✅", "failed": "⚠️", "skipped": "❓"}[row["add_status"]] + assignment_icon = {"assigned": "✅", "missing": "⚠️", "unknown": "❓"}[ + row["assignment_status"] + ] + details = " ".join([row["add_details"], row["assignment_details"]]).strip() + lines.append( + "| " + f"`{markdown_cell(row['email'])}` | " + f"`{markdown_cell(row['role'])}` | " + f"{add_icon} {markdown_cell(row['add_status'])} | " + f"{assignment_icon} {markdown_cell(row['assignment_status'])} | " + f"{markdown_cell(details)} |" + ) + + has_problem = any( + row["add_status"] == "failed" or row["assignment_status"] in {"missing", "unknown"} + for row in rows + ) + if has_problem: + lines += [ + "", + "## How to fix missing access", + "", + "- If **Add request** is **failed**, verify that the building block service account has the STACKIT organization role `iam.member-admin` and that Workload Identity Federation is configured correctly. Then re-run this building block.", + f"- If **Current assignment** is **missing**, ask a STACKIT organization administrator to add the user to organization `{organization_id}` with role `{role}`, then re-run this building block.", + "- If **Current assignment** is **unknown**, verify API access for querying organization members and re-run this building block.", + ] + + SUMMARY_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1].upper() == "DESTROY": + info("destroy run detected; skipping organization membership onboarding") + return + + organization_id = os.environ["STACKIT_ORGANIZATION_ID"] + role = os.environ.get("STACKIT_ORGANIZATION_MEMBER_ROLE", DEFAULT_ORGANIZATION_ROLE) + emails = extract_emails(sys.stdin.read()) + + if not emails: + info("no users found; skipping organization membership onboarding") + write_summary(organization_id, role, []) + return + + info(f"ensuring {len(emails)} user(s) have organization role '{role}' on '{organization_id}'") + + token = get_access_token() + if not token: + write_summary( + organization_id, + role, + [ + make_row( + email, + role, + "skipped", + "Could not obtain a STACKIT access token, so no add request was made.", + "unknown", + "Membership could not be checked.", + ) + for email in emails + ], + ) + return + + rows = [] + for email in emails: + add = add_member(organization_id, role, email, token) + assignment = get_assignment(organization_id, role, email, token) + rows.append( + make_row( + email, + role, + add["status"], + add["details"], + assignment["status"], + assignment["details"], + ) + ) + + write_summary(organization_id, role, rows) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: # best-effort pre-run must not block apply + warn(f"unexpected pre-run failure: {exc}") + sys.exit(0) diff --git a/modules/stackit/project/buildingblock/prerun.sh b/modules/stackit/project/buildingblock/prerun.sh index eedd2691..dc64855d 100755 --- a/modules/stackit/project/buildingblock/prerun.sh +++ b/modules/stackit/project/buildingblock/prerun.sh @@ -1,224 +1 @@ -#!/usr/bin/env bash - -# Best-effort STACKIT organization membership onboarding. -# Runs after OpenTofu init and before apply/destroy. This script must never block -# the building block run: all errors are logged as warnings and the script exits 0. - -RUN_INPUT_FILE="" - -info() { - echo "[stackit-org-membership] $*" -} - -warn() { - echo "[stackit-org-membership] WARN: $*" >&2 - if [[ -n "${MESHSTACK_USER_MESSAGE:-}" ]]; then - echo "STACKIT organization membership warning: $*" >> "$MESHSTACK_USER_MESSAGE" 2>/dev/null || true - fi -} - -cleanup() { - if [[ -n "$RUN_INPUT_FILE" && -f "$RUN_INPUT_FILE" ]]; then - rm -f "$RUN_INPUT_FILE" - fi -} - -is_destroy_run() { - local mode - mode=$(printf '%s' "${1:-APPLY}" | tr '[:lower:]' '[:upper:]') - [[ "$mode" == "DESTROY" ]] -} - -capture_run_input() { - RUN_INPUT_FILE=$(mktemp) - cat > "$RUN_INPUT_FILE" 2>/dev/null || true -} - -extract_users_json() { - if [[ ! -s "$RUN_INPUT_FILE" ]]; then - printf '[]\n' - return 0 - fi - - jq -c '[.spec.buildingBlock.spec.inputs[]? | select(.key == "users") | .value | fromjson][0] // []' "$RUN_INPUT_FILE" 2>/dev/null \ - || printf '[]\n' -} - -extract_emails_json() { - local users_json="$1" - - printf '%s' "$users_json" \ - | jq -c '[.[]? | .email? // empty | strings | ascii_downcase | select(length > 0)] | unique' 2>/dev/null \ - || printf '[]' -} - -get_access_token() { - local service_account_email="$1" - - if [[ -n "${STACKIT_ACCESS_TOKEN:-}" ]]; then - printf '%s' "$STACKIT_ACCESS_TOKEN" - return 0 - fi - - if [[ -z "$service_account_email" ]]; then - warn "STACKIT_SERVICE_ACCOUNT_EMAIL is not set; cannot exchange WIF token" - return 1 - fi - - local token_file="${STACKIT_FEDERATED_TOKEN_FILE:-/var/run/secrets/stackit.cloud/serviceaccount/token}" - if [[ ! -r "$token_file" ]]; then - warn "STACKIT federated token file '$token_file' is not readable" - return 1 - fi - - local assertion - assertion=$(cat "$token_file" 2>/dev/null || true) - if [[ -z "$assertion" ]]; then - warn "STACKIT federated token file '$token_file' is empty" - return 1 - fi - - local token_endpoint="${STACKIT_IDP_TOKEN_ENDPOINT:-https://accounts.stackit.cloud/oauth/v2/token}" - local response_file error_file http_code rc access_token - response_file=$(mktemp) - error_file=$(mktemp) - - http_code=$(curl -sS -o "$response_file" -w '%{http_code}' \ - -X POST "$token_endpoint" \ - -H 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'grant_type=client_credentials' \ - --data-urlencode 'client_assertion_type=urn:schwarz:params:oauth:client-assertion-type:workload-jwt' \ - --data-urlencode "client_assertion=$assertion" \ - --data-urlencode "client_id=$service_account_email" \ - 2>"$error_file") - rc=$? - - if [[ "$rc" -ne 0 ]]; then - warn "token exchange request failed: $(cat "$error_file" 2>/dev/null)" - return 1 - fi - - case "$http_code" in - 2*) ;; - *) - warn "token exchange returned HTTP $http_code: $(cat "$response_file" 2>/dev/null | cut -c1-500)" - return 1 - ;; - esac - - access_token=$(jq -r '.access_token // empty' "$response_file" 2>/dev/null || true) - if [[ -z "$access_token" ]]; then - warn "token exchange response did not contain an access_token" - return 1 - fi - - printf '%s' "$access_token" -} - -build_membership_payload() { - local organization_role="$1" - local emails_json="$2" - - jq -n \ - --arg role "$organization_role" \ - --argjson emails "$emails_json" \ - '{resourceType: "organization", members: [$emails[] | {subject: ., role: $role}]}' -} - -call_membership_api_with_curl() { - local url="$1" - local payload="$2" - local access_token="$3" - local response_file error_file http_code rc - - response_file=$(mktemp) - error_file=$(mktemp) - http_code=$(curl -sS -o "$response_file" -w '%{http_code}' \ - -X PATCH "$url" \ - -H "Authorization: Bearer $access_token" \ - -H 'Content-Type: application/json' \ - -d "$payload" \ - 2>"$error_file") - rc=$? - - if [[ "$rc" -ne 0 ]]; then - warn "Membership API request failed: $(cat "$error_file" 2>/dev/null)" - return 0 - fi - - case "$http_code" in - 2*) - info "organization membership onboarding request completed" - ;; - *) - warn "Membership API returned HTTP $http_code: $(cat "$response_file" 2>/dev/null | cut -c1-500)" - ;; - esac - - return 0 -} - -onboard_organization_members() { - local emails_json="$1" - local email_count="$2" - local organization_id="${STACKIT_ORGANIZATION_ID:-}" - local organization_role="${STACKIT_ORGANIZATION_MEMBER_ROLE:-organization.viewer}" - local service_account_email="${STACKIT_SERVICE_ACCOUNT_EMAIL:-}" - local encoded_organization_id url payload access_token - - if [[ -z "$organization_id" ]]; then - warn "STACKIT_ORGANIZATION_ID is not set; cannot add organization memberships" - return 0 - fi - - if [[ -z "$organization_role" ]]; then - warn "STACKIT_ORGANIZATION_MEMBER_ROLE is empty; cannot add organization memberships" - return 0 - fi - - if ! command -v curl >/dev/null 2>&1; then - warn "curl is not available; cannot call STACKIT Membership API" - return 0 - fi - - payload=$(build_membership_payload "$organization_role" "$emails_json") - encoded_organization_id=$(printf '%s' "$organization_id" | jq -sRr @uri) - url="https://authorization.api.stackit.cloud/v2/${encoded_organization_id}/members" - - info "ensuring $email_count user(s) have organization role '$organization_role' on '$organization_id'" - - access_token=$(get_access_token "$service_account_email" || true) - if [[ -z "$access_token" ]]; then - warn "could not obtain STACKIT access token; skipping organization membership onboarding" - return 0 - fi - - call_membership_api_with_curl "$url" "$payload" "$access_token" -} - -main() { - local users_json emails_json email_count - - trap cleanup EXIT - - if is_destroy_run "${1:-APPLY}"; then - info "destroy run detected; skipping organization membership onboarding" - return 0 - fi - - capture_run_input - - users_json=$(extract_users_json) - emails_json=$(extract_emails_json "$users_json") - email_count=$(printf '%s' "$emails_json" | jq -r 'length' 2>/dev/null || printf '0') - - if [[ "$email_count" == "0" ]]; then - info "no users found; skipping organization membership onboarding" - return 0 - fi - - onboard_organization_members "$emails_json" "$email_count" - return 0 -} - -main "$@" || true +exec python3 "./prerun.py" "$@" From dcc9d6110f6d4f1a06c8f589c998f47dbb3bda79 Mon Sep 17 00:00:00 2001 From: Henry Dettmer Date: Wed, 8 Jul 2026 11:37:38 +0200 Subject: [PATCH 3/3] feat: make stackit org onboarding optional --- modules/stackit/meshstack_integration.tf | 22 ++++++-- modules/stackit/project/backplane/README.md | 3 +- modules/stackit/project/backplane/main.tf | 7 +++ .../stackit/project/backplane/variables.tf | 7 +++ .../project/buildingblock/APP_TEAM_README.md | 4 +- .../stackit/project/buildingblock/README.md | 2 +- .../stackit/project/buildingblock/prerun.py | 52 +++++++++++-------- .../buildingblock/README.md | 1 + .../buildingblock/main.tf | 11 ++-- .../buildingblock/variables.tf | 6 +++ .../meshstack_integration.tf | 8 +++ 11 files changed, 88 insertions(+), 35 deletions(-) diff --git a/modules/stackit/meshstack_integration.tf b/modules/stackit/meshstack_integration.tf index ac409331..060e259d 100644 --- a/modules/stackit/meshstack_integration.tf +++ b/modules/stackit/meshstack_integration.tf @@ -14,6 +14,12 @@ variable "stackit_organization_member_role" { description = "STACKIT organization role assigned best-effort to all meshStack project users before project role assignments are applied." } +variable "stackit_organization_onboarding_enabled" { + type = bool + default = true + description = "Whether the building block adds meshStack project users to the STACKIT organization (with `stackit_organization_member_role`) before applying project-level role assignments. Disable if organization membership is managed outside this building block." +} + variable "stackit_parent_container_id" { type = string description = "Default parent container ID (organization or folder) for project creation." @@ -74,9 +80,10 @@ variable "hub" { module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/stackit/project/backplane?ref=${var.hub.git_ref}" - project_id = var.stackit_project_id - organization_id = var.stackit_organization_id - service_account_name = coalesce(var.stackit_service_account_name, "mesh-project") + project_id = var.stackit_project_id + organization_id = var.stackit_organization_id + service_account_name = coalesce(var.stackit_service_account_name, "mesh-project") + organization_onboarding_enabled = var.stackit_organization_onboarding_enabled workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -253,6 +260,15 @@ resource "meshstack_building_block_definition" "this" { argument = jsonencode(var.stackit_organization_member_role) } + STACKIT_ORGANIZATION_ONBOARDING_ENABLED = { + display_name = "STACKIT Organization Onboarding Enabled" + description = "Whether the pre-run script adds meshStack project users to the STACKIT organization before project role assignments are applied." + type = "STRING" + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.stackit_organization_onboarding_enabled ? "1" : "0") + } + project_name = { display_name = "Project Name" description = "Name of the STACKIT project." diff --git a/modules/stackit/project/backplane/README.md b/modules/stackit/project/backplane/README.md index f2b06204..8ddd2dfe 100644 --- a/modules/stackit/project/backplane/README.md +++ b/modules/stackit/project/backplane/README.md @@ -5,7 +5,7 @@ It creates a dedicated service account with a Workload Identity Federation (WIF) and the permissions required to create and manage STACKIT projects under a given organization: - **`resource-manager.admin`** — allows creating and managing projects within the organization. -- **`iam.member-admin`** — allows the building block pre-run script to add meshStack project users to the STACKIT organization before project-level role assignments are applied. +- **`iam.member-admin`** — allows the building block pre-run script to add meshStack project users to the STACKIT organization before project-level role assignments are applied. Only granted when `organization_onboarding_enabled` is `true` (the default). Authentication uses WIF (OIDC token exchange) — no long-lived service account key is created or stored. @@ -62,6 +62,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [organization\_id](#input\_organization\_id) | STACKIT organization ID where the service account will be granted permissions to create and manage projects. | `string` | n/a | yes | +| [organization\_onboarding\_enabled](#input\_organization\_onboarding\_enabled) | Whether the service account is granted `iam.member-admin` so the building block pre-run script can add meshStack project users to the STACKIT organization. | `bool` | `true` | no | | [project\_id](#input\_project\_id) | STACKIT project ID where the service account will be created. | `string` | n/a | yes | | [service\_account\_name](#input\_service\_account\_name) | Name of the service account created in the STACKIT project. Override when deploying multiple backplane instances in the same project. | `string` | `"mesh-project"` | no | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | WIF issuer URL and subject list for the meshStack building block identity provider. |
object({
issuer = string
subjects = list(string)
})
| n/a | yes | diff --git a/modules/stackit/project/backplane/main.tf b/modules/stackit/project/backplane/main.tf index 4bf3d684..fb6851dc 100644 --- a/modules/stackit/project/backplane/main.tf +++ b/modules/stackit/project/backplane/main.tf @@ -31,7 +31,14 @@ resource "stackit_authorization_organization_role_assignment" "project_admin" { subject = stackit_service_account.building_block.email } +moved { + from = stackit_authorization_organization_role_assignment.member_admin + to = stackit_authorization_organization_role_assignment.member_admin[0] +} + resource "stackit_authorization_organization_role_assignment" "member_admin" { + count = var.organization_onboarding_enabled ? 1 : 0 + resource_id = var.organization_id role = "iam.member-admin" subject = stackit_service_account.building_block.email diff --git a/modules/stackit/project/backplane/variables.tf b/modules/stackit/project/backplane/variables.tf index 93bcaf42..969b21bf 100644 --- a/modules/stackit/project/backplane/variables.tf +++ b/modules/stackit/project/backplane/variables.tf @@ -25,3 +25,10 @@ variable "service_account_name" { nullable = false description = "Name of the service account created in the STACKIT project. Override when deploying multiple backplane instances in the same project." } + +variable "organization_onboarding_enabled" { + type = bool + default = true + nullable = false + description = "Whether the service account is granted `iam.member-admin` so the building block pre-run script can add meshStack project users to the STACKIT organization." +} diff --git a/modules/stackit/project/buildingblock/APP_TEAM_README.md b/modules/stackit/project/buildingblock/APP_TEAM_README.md index f3199be7..c515d086 100644 --- a/modules/stackit/project/buildingblock/APP_TEAM_README.md +++ b/modules/stackit/project/buildingblock/APP_TEAM_README.md @@ -87,11 +87,11 @@ Unknown meshStack roles are ignored. If a user has multiple meshStack roles, all ### STACKIT Organization Membership -Before applying project-level role assignments, the building block runs a best-effort pre-run step that adds all assigned meshStack users to the STACKIT organization with the organization role configured by the platform team. The default organization role is `organization.viewer`. +Before applying project-level role assignments, the building block runs a best-effort pre-run step that adds all assigned meshStack users to the STACKIT organization with the organization role configured by the platform team. The default organization role is `organization.viewer`. The platform team can disable this step entirely; in that case, organization membership must be managed outside this building block. This onboarding step is apply-only and does not remove organization memberships during destroy because organization membership can be shared by multiple projects. If the onboarding request fails, the building block logs a warning and continues; project-level role assignment remains authoritative and may still fail if STACKIT rejects a user that is not an organization member. -The building block summary shows which organization add requests succeeded or failed and whether each assigned user currently has the required organization role. If access is missing or could not be checked, the summary includes remediation guidance for the platform team. +The building block summary lists each user with a status: a checkmark if the required organization role is assigned, or details about what went wrong otherwise (e.g. a failed add request or a role that is still missing). If any user's status is not a checkmark, the summary includes remediation guidance for the platform team. ### Environment-Based Parent Container Selection diff --git a/modules/stackit/project/buildingblock/README.md b/modules/stackit/project/buildingblock/README.md index 5dddbe87..095b324a 100644 --- a/modules/stackit/project/buildingblock/README.md +++ b/modules/stackit/project/buildingblock/README.md @@ -8,7 +8,7 @@ description: | # STACKIT Project Building Block -This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users and writes a summary that reports which add requests succeeded or failed and whether the required organization role is currently assigned. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users before project-level role assignments are applied. +This Terraform module provisions a STACKIT project with user access control. meshStack roles from the `users` input are mapped to STACKIT project roles via the configurable `role_mapping` input. When used through the meshStack integration, a pre-run script performs best-effort STACKIT organization onboarding for all assigned users before project-level role assignments are applied and writes a summary that marks users with ✅ when the required organization role is currently assigned. ## Requirements diff --git a/modules/stackit/project/buildingblock/prerun.py b/modules/stackit/project/buildingblock/prerun.py index c4b1f60d..51bf8aa1 100755 --- a/modules/stackit/project/buildingblock/prerun.py +++ b/modules/stackit/project/buildingblock/prerun.py @@ -97,6 +97,9 @@ def get_access_token() -> str: def add_member(organization_id: str, role: str, email: str, token: str) -> dict[str, str]: + # This PATCH is STACKIT's `AddMembers` operation (Authorization API v2): it grants the given + # (subject, role) pair without touching the subject's other roles or other members' grants. + # Removal uses a separate `RemoveMembers` endpoint; there is no full-list-replace endpoint. code, body = request( "PATCH", f"{AUTHORIZATION_ENDPOINT}/v2/{urllib.parse.quote(organization_id, safe='')}/members", @@ -182,47 +185,50 @@ def write_summary(organization_id: str, role: str, rows: list[dict[str, str]]) - f"- **Organization ID**: `{organization_id}`", f"- **Required organization role**: `{role}`", "", - "| User | Required role | Add request | Current assignment | Details |", - "|------|---------------|-------------|--------------------|---------|", + "| User | Status |", + "|------|--------|", ] for row in rows: - add_icon = {"succeeded": "✅", "failed": "⚠️", "skipped": "❓"}[row["add_status"]] - assignment_icon = {"assigned": "✅", "missing": "⚠️", "unknown": "❓"}[ - row["assignment_status"] - ] - details = " ".join([row["add_details"], row["assignment_details"]]).strip() - lines.append( - "| " - f"`{markdown_cell(row['email'])}` | " - f"`{markdown_cell(row['role'])}` | " - f"{add_icon} {markdown_cell(row['add_status'])} | " - f"{assignment_icon} {markdown_cell(row['assignment_status'])} | " - f"{markdown_cell(details)} |" - ) - - has_problem = any( - row["add_status"] == "failed" or row["assignment_status"] in {"missing", "unknown"} - for row in rows - ) + if row["assignment_status"] == "assigned": + status = "✅" + else: + details = " ".join([row["add_details"], row["assignment_details"]]).strip() + status = markdown_cell(details) + lines.append(f"| `{markdown_cell(row['email'])}` | {status} |") + + has_problem = any(row["assignment_status"] != "assigned" for row in rows) if has_problem: lines += [ "", "## How to fix missing access", "", - "- If **Add request** is **failed**, verify that the building block service account has the STACKIT organization role `iam.member-admin` and that Workload Identity Federation is configured correctly. Then re-run this building block.", - f"- If **Current assignment** is **missing**, ask a STACKIT organization administrator to add the user to organization `{organization_id}` with role `{role}`, then re-run this building block.", - "- If **Current assignment** is **unknown**, verify API access for querying organization members and re-run this building block.", + "- If the add request failed, verify that the building block service account has the STACKIT organization role `iam.member-admin` and that Workload Identity Federation is configured correctly. Then re-run this building block.", + f"- If the required role is missing, ask a STACKIT organization administrator to add the user to organization `{organization_id}` with role `{role}`, then re-run this building block.", + "- If membership could not be checked, verify API access for querying organization members and re-run this building block.", ] SUMMARY_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8") +def write_disabled_summary() -> None: + SUMMARY_FILE.write_text( + "# STACKIT Organization Membership\n\n" + "Organization membership onboarding is disabled for this building block.\n", + encoding="utf-8", + ) + + def main() -> None: if len(sys.argv) > 1 and sys.argv[1].upper() == "DESTROY": info("destroy run detected; skipping organization membership onboarding") return + if os.environ.get("STACKIT_ORGANIZATION_ONBOARDING_ENABLED", "1") != "1": + info("organization membership onboarding is disabled; skipping") + write_disabled_summary() + return + organization_id = os.environ["STACKIT_ORGANIZATION_ID"] role = os.environ.get("STACKIT_ORGANIZATION_MEMBER_ROLE", DEFAULT_ORGANIZATION_ROLE) emails = extract_emails(sys.stdin.read()) diff --git a/reference-architectures/stackit-sandbox-landingzone/buildingblock/README.md b/reference-architectures/stackit-sandbox-landingzone/buildingblock/README.md index ee4ef593..4145d4dd 100644 --- a/reference-architectures/stackit-sandbox-landingzone/buildingblock/README.md +++ b/reference-architectures/stackit-sandbox-landingzone/buildingblock/README.md @@ -50,6 +50,7 @@ The user-facing readme is maintained inline in the `readme` field of the | [platform\_identifier](#input\_platform\_identifier) | Identifier for the STACKIT sandbox platform created in meshStack (letters, digits and dashes only). | `string` | n/a | yes | | [role\_mapping](#input\_role\_mapping) | Default mapping from meshStack roles to STACKIT project roles for the nested STACKIT Project integration. Values can be built-in STACKIT roles or custom STACKIT role names. | `map(list(string))` |
{
"admin": [
"owner"
],
"reader": [
"reader"
],
"user": [
"editor"
]
}
| no | | [stackit\_org](#input\_stackit\_org) | STACKIT organization UUID under which the landing-zone folder, backplane project and tenant projects are created. | `string` | n/a | yes | +| [stackit\_organization\_onboarding\_enabled](#input\_stackit\_organization\_onboarding\_enabled) | Whether the nested STACKIT Project integration adds meshStack project users to the STACKIT organization before applying project-level role assignments. Disable if organization membership is managed outside this landing zone. | `bool` | `true` | no | | [stackit\_owner\_email](#input\_stackit\_owner\_email) | Owner email assigned to the STACKIT resourcemanager folder and backplane project. | `string` | n/a | yes | | [stackit\_service\_account\_key](#input\_stackit\_service\_account\_key) | STACKIT service account key JSON with `resource-manager.admin` on the organization. Used to create the landing-zone folder and backplane project. | `string` | n/a | yes | | [tags](#input\_tags) | Tags forwarded to the nested STACKIT Project integration. `landingzone` tags are applied to the default landing zone; `building_block` tags are applied to the nested building block definition. |
object({
landingzone = map(list(string))
building_block = map(list(string))
})
|
{
"building_block": {},
"landingzone": {}
}
| no | diff --git a/reference-architectures/stackit-sandbox-landingzone/buildingblock/main.tf b/reference-architectures/stackit-sandbox-landingzone/buildingblock/main.tf index 78a95fde..0d2fa207 100644 --- a/reference-architectures/stackit-sandbox-landingzone/buildingblock/main.tf +++ b/reference-architectures/stackit-sandbox-landingzone/buildingblock/main.tf @@ -29,11 +29,12 @@ resource "stackit_resourcemanager_project" "backplane" { module "stackit_integration" { source = "github.com/meshcloud/meshstack-hub//modules/stackit?ref=${var.git_ref}" - stackit_organization_id = var.stackit_org - stackit_parent_container_id = stackit_resourcemanager_folder.this.container_id - stackit_project_id = stackit_resourcemanager_project.backplane.project_id - stackit_service_account_name = substr(var.platform_identifier, 0, 20) - role_mapping = var.role_mapping + stackit_organization_id = var.stackit_org + stackit_parent_container_id = stackit_resourcemanager_folder.this.container_id + stackit_project_id = stackit_resourcemanager_project.backplane.project_id + stackit_service_account_name = substr(var.platform_identifier, 0, 20) + role_mapping = var.role_mapping + stackit_organization_onboarding_enabled = var.stackit_organization_onboarding_enabled hub = { git_ref = var.git_ref diff --git a/reference-architectures/stackit-sandbox-landingzone/buildingblock/variables.tf b/reference-architectures/stackit-sandbox-landingzone/buildingblock/variables.tf index e48d3b62..711057bc 100644 --- a/reference-architectures/stackit-sandbox-landingzone/buildingblock/variables.tf +++ b/reference-architectures/stackit-sandbox-landingzone/buildingblock/variables.tf @@ -61,6 +61,12 @@ variable "role_mapping" { } } +variable "stackit_organization_onboarding_enabled" { + type = bool + default = true + description = "Whether the nested STACKIT Project integration adds meshStack project users to the STACKIT organization before applying project-level role assignments. Disable if organization membership is managed outside this landing zone." +} + variable "git_ref" { type = string default = "main" diff --git a/reference-architectures/stackit-sandbox-landingzone/meshstack_integration.tf b/reference-architectures/stackit-sandbox-landingzone/meshstack_integration.tf index cb9f4b62..cd3ba642 100644 --- a/reference-architectures/stackit-sandbox-landingzone/meshstack_integration.tf +++ b/reference-architectures/stackit-sandbox-landingzone/meshstack_integration.tf @@ -183,6 +183,14 @@ resource "meshstack_building_block_definition" "this" { })) } + stackit_organization_onboarding_enabled = { + display_name = "STACKIT Organization Onboarding Enabled" + description = "If true, the nested STACKIT Project integration adds meshStack project users to the STACKIT organization before applying project-level role assignments." + type = "BOOLEAN" + assignment_type = "USER_INPUT" + default_value = jsonencode(true) + } + # ── meshStack context ── workspace = {