diff --git a/.github/workflows/tf-apply.yml b/.github/workflows/tf-apply.yml index cf0501a..b5ffc3e 100644 --- a/.github/workflows/tf-apply.yml +++ b/.github/workflows/tf-apply.yml @@ -49,7 +49,7 @@ jobs: - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: - role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-deploy-${{ github.event.repository.name }} + role-to-assume: arn:aws:iam::${{ inputs.aws_account_id }}:role/javabin-ci-app-${{ github.event.repository.name }} aws-region: ${{ inputs.aws_region }} - name: Check risk level diff --git a/CLAUDE.md b/CLAUDE.md index 9e1af6b..1058442 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,15 +218,32 @@ Registry merge ──► team-provisioner (STUB — not yet implemented) ## SSM Parameters -| Path | Used By | -|------|---------| -| `/javabin/slack/platform-resource-alerts-webhook` | slack-alert (security events), compliance-reporter, platform-ci (HIGH risk, drift) | -| `/javabin/slack/platform-cost-alerts-webhook` | slack-alert (cost), cost-report, daily-cost-check | -| `/javabin/slack/platform-override-alerts-webhook` | tf-apply (block notification), approve-override (approval confirmation) | -| `/javabin/platform/google-admin-sa` | team-provisioner (Google Admin SDK) | -| `/javabin/platform/github-app-key` | team-provisioner (GitHub App private key) | -| `/javabin/platform-overrides/{repo}/{sha}` | Risk gate override tokens (single-use) | -| `/javabin/platform-apps/{name}/*` | Per-app secrets (Cognito clients, etc.) | +All parameters are in `eu-central-1`. Use `--profile javabin --region eu-central-1` via CLI. + +| Path | Type | Used By | +|------|------|---------| +| `/javabin/slack/platform-resource-alerts-webhook` | SecureString | slack-alert, compliance-reporter, platform-ci | +| `/javabin/slack/platform-cost-alerts-webhook` | String | slack-alert (cost), cost-report, daily-cost-check | +| `/javabin/slack/platform-override-alerts-webhook` | SecureString | tf-apply (block notification), approve-override | +| `/javabin/platform/google-admin-sa` | SecureString | team-provisioner (GCP SA JSON key, domain-wide delegation) | +| `/javabin/platform/google-admin-email` | String | team-provisioner (admin email for Google Admin SDK impersonation) | +| `/javabin/platform/github-app-id` | SecureString | team-provisioner (GitHub App ID) | +| `/javabin/platform/github-app-key` | SecureString | team-provisioner (GitHub App private key) | +| `/javabin/platform/github-app-client-secret` | SecureString | team-provisioner (GitHub App client secret) | +| `/javabin/platform-overrides/{repo}/{sha}` | SecureString | Risk gate override tokens (single-use) | +| `/javabin/platform-apps/{name}/*` | varies | Per-app secrets (Cognito clients, etc. — future) | + +## GCP Connection + +**GCP Org:** java.no +**GCP Project:** `javabin-platform` +**Purpose:** Service account with domain-wide delegation for Google Admin SDK + +A GCP service account in the `javabin-platform` project has domain-wide delegation configured +in Google Workspace Admin. The team-provisioner Lambda uses it to manage Google Groups +(create groups, sync membership) by impersonating the admin email stored in SSM. +The SA JSON key is at `/javabin/platform/google-admin-sa`, the impersonation target at +`/javabin/platform/google-admin-email`. ## Work Packages @@ -237,7 +254,7 @@ Registry merge ──► team-provisioner (STUB — not yet implemented) | 0a | AWS Discovery | **Done** | | 0b | Bootstrap State Backend | **Done** — S3 backend live | | 0c | Organizations + Permission Boundary | **Done** — org enabled, boundary deployed, SCP deferred | -| 1 | Identity (Google + Identity Center + Cognito) | **Not started** — Google Admin access confirmed, `identity/` dir is empty, no Cognito pools deployed | +| 1 | Identity (Google + Identity Center + Cognito) | **Partially done** — GCP SA with domain-wide delegation configured, GitHub App credentials in SSM. Identity Center and Cognito pools not deployed (`identity/` dir empty) | | 2a | Networking | **Deployed** — VPC, subnets, NAT | | 2b | Ingress | **Deployed** — ALB + ACM cert | | 2c | IAM / OIDC | **Deployed** — 4 CI roles + per-app roles | @@ -256,7 +273,7 @@ Registry merge ──► team-provisioner (STUB — not yet implemented) ### Known Issues - **Registry → platform dispatch broken**: `provision-app.yml` fails because `GH_TOKEN` env var not set in workflow - **platform-test-app CI failing**: ECR repo `platform-test-app` doesn't exist (never provisioned), tf-plan gets 404 on platform repo checkout -- **Team provisioner is a stub**: Lambda exists and is deployed but does nothing (logs and returns 200) +- **Team provisioner is a stub**: Lambda deployed but only logs. SSM credentials are ready (Google SA + GitHub App) — needs actual sync implementation - **Identity module empty**: `terraform/platform/identity/` is an empty directory, not wired in `main.tf` - **Cognito pools not deployed**: `cognito-app-client` module exists but has no pools to connect to diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index ae2a147..2306849 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -203,32 +203,138 @@ alarms: memory_threshold: 80 # default: 80 (percent) ``` -### environments (planned — not yet supported) +### environments -Multi-environment configuration. If omitted, a single production environment is created. - -> **Note:** This field is planned for a future release. The `app-stack` module and `generate-terraform.sh` do not yet read the `environments` key. For now, all apps deploy as a single production environment. +Multi-environment configuration. If omitted, a single production environment is created (backwards compatible). When present, each key defines a separate environment with its own ECS service, ALB target group, DNS record, IAM role, and Terraform state. ```yaml environments: prod: - compute: - cpu: 512 - memory: 1024 routing: host: submit.javazone.no priority: 100 dev: + routing: + host: dev.submit.javazone.no + priority: 101 + auto_deploy: true +``` + +Each environment inherits all top-level defaults and can override: `compute`, `routing`, `domain`, `resources`, `environment`, `budget_alert_nok`, `alarms`. + +#### Environment defaults + +The `dev` environment gets smaller defaults automatically: + +| Field | dev default | other envs default | +|-------|-------------|-------------------| +| `compute.cpu` | 256 | 512 | +| `compute.memory` | 512 | 1024 | + +All other fields (port, desired_count, health_check, etc.) use the same defaults regardless of environment. + +#### Full example + +```yaml +name: submittheforce +team: pkom +compute: + cpu: 512 + memory: 1024 + port: 8080 +resources: + databases: + - name: submissions + hash_key: id + env: SUBMISSIONS_TABLE +environment: + LOG_LEVEL: info +environments: + prod: + routing: + host: submit.javazone.no + priority: 100 compute: - cpu: 256 - memory: 512 + desired_count: 2 + environment: + LOG_LEVEL: warn + dev: routing: host: dev.submit.javazone.no priority: 101 - budget_alert_nok: 200 + auto_deploy: true +``` + +This creates two environments: + +| | prod | dev | +|---|---|---| +| CPU / Memory | 512 / 1024 (from top-level) | 256 / 512 (dev defaults) | +| Desired count | 2 (override) | 1 (default) | +| Domain | submit.javazone.no | dev.submit.javazone.no | +| DynamoDB table | `javabin-submittheforce-prod-submissions` | `javabin-submittheforce-dev-submissions` | +| ECS service | `submittheforce-prod` | `submittheforce-dev` | +| LOG_LEVEL | warn (override) | info (from top-level) | +| State key | `apps/submittheforce-prod/terraform.tfstate` | `apps/submittheforce-dev/terraform.tfstate` | + +#### Override behavior + +- **compute, routing, alarms**: Per-field override. Unspecified fields fall back to top-level, then to defaults. +- **resources**: Per-type replacement. Each resource type (buckets, databases, secrets, queues) is independently replaced — if the environment defines `resources.databases`, it gets only those databases (not merged with top-level). Resource types not defined in the environment inherit from top-level. +- **environment** (env vars): Merged. Top-level env vars are applied first, then environment-specific ones override on conflict. + +#### auto_deploy + +```yaml +environments: + dev: + auto_deploy: true ``` -Each environment inherits top-level defaults and can override: `compute`, `routing`, `resources`, `environment`, `budget_alert_nok`, `alarms`. +When `true`, merges to main automatically deploy to this environment without manual approval. Default: `false`. This is read by CI workflows, not by Terraform. + +#### ECR repository + +All environments share a single ECR repository named after the base app name. The same container image is deployed to all environments. The prod environment (or the first environment if no prod exists) creates the ECR repository; other environments reference it via data source. + +**Important:** When first setting up multi-env, apply the environment that creates ECR (prod) before other environments. + +#### Naming + +Environment name must be lowercase alphanumeric, max 4 characters (e.g. `dev`, `prod`, `test`, `stag`). Multi-env apps should keep their name under ~19 characters to allow room for the environment suffix in ALB target group names (32-char AWS limit). Resources are named with an environment suffix: + +| Resource | Single-env | Multi-env (dev) | +|----------|-----------|-----------------| +| ECS service | `moresleep` | `moresleep-dev` | +| IAM task role | `javabin-moresleep` | `javabin-moresleep-dev` | +| ALB target group | `javabin-moresleep` | `javabin-moresleep-dev` | +| DynamoDB table | `javabin-moresleep-sessions` | `javabin-moresleep-dev-sessions` | +| S3 bucket | `moresleep-data-{account}` | `moresleep-dev-data-{account}` | +| Log group | `/ecs/javabin/moresleep` | `/ecs/javabin/moresleep-dev` | +| ECR repo | `moresleep` | `moresleep` (shared) | + +#### Generated directory structure + +Single-env: +``` +terraform/ + backend.tf + providers.tf + main.tf +``` + +Multi-env: +``` +terraform/ + prod/ + backend.tf # state key: apps/{name}-prod/terraform.tfstate + providers.tf + main.tf # environment_name = "prod", create_ecr = true + dev/ + backend.tf # state key: apps/{name}-dev/terraform.tfstate + providers.tf + main.tf # environment_name = "dev", create_ecr = false +``` ## How It Works @@ -253,3 +359,5 @@ Generated files have a `# GENERATED FROM app.yaml` marker. The script only overw | CloudWatch logs | `/ecs/javabin/{name}` | | DNS record | `{routing.host}` | | SSM (Cognito) | `/javabin/platform-apps/{name}/cognito-*` | + +> In multi-env mode, `{name}` becomes `{name}-{env}` in all resource names except ECR. See [environments naming](#naming) for details. diff --git a/docs/cognito-google-setup.md b/docs/cognito-google-setup.md new file mode 100644 index 0000000..e21c105 --- /dev/null +++ b/docs/cognito-google-setup.md @@ -0,0 +1,152 @@ +# Cognito + Google IdP Setup + +Manual steps required alongside Terraform-managed infrastructure for Cognito user pools with Google Sign-In. + +## Two Separate Google Integrations + +This platform uses Google in two distinct ways. They require separate credentials and serve different purposes. + +| Integration | Purpose | Credential Type | Stored In | +|------------|---------|----------------|-----------| +| Domain-wide delegation SA | Team provisioner manages Google Groups via Admin SDK | GCP Service Account JSON key | SSM `/javabin/platform/google-admin-sa` | +| OAuth 2.0 Client | User-facing Google Sign-In in Cognito pools | OAuth Client ID + Secret | Cognito IdP config (Terraform) | + +The domain-wide delegation SA already exists in the `javabin-platform` GCP project. The OAuth client below is a separate credential that must be created. + +## 1. Google Cloud Console: OAuth Client + +### Prerequisites +- Access to `javabin-platform` GCP project (under `java.no` org) +- Permissions: `roles/oauthconfig.editor` or project Owner + +### 1.1 Configure OAuth Consent Screen + +1. Go to **APIs & Services > OAuth consent screen** in the `javabin-platform` project +2. Select **External** user type (needed for the external Cognito pool) +3. Fill in: + - App name: `Javabin Platform` + - User support email: `platform@java.no` (or board contact) + - Developer contact: same +4. Scopes: add `openid`, `email`, `profile` +5. Test users: skip (not needed once published) +6. Publish the app (move from Testing to Production) — Google review may take a few days + +### 1.2 Create OAuth 2.0 Client ID + +1. Go to **APIs & Services > Credentials > Create Credentials > OAuth client ID** +2. Application type: **Web application** +3. Name: `javabin-cognito` +4. Authorized redirect URIs — add both Cognito pool callback URLs: + ``` + https://auth-internal.javazone.no/oauth2/idpresponse + https://auth-external.javazone.no/oauth2/idpresponse + ``` + These are the Cognito hosted UI domains. Adjust if using custom domains. +5. Click **Create** and note the **Client ID** and **Client Secret** + +These values are passed to Terraform as variables when creating the Cognito identity provider resources. They are not stored in SSM — Cognito holds them internally. + +## 2. Cognito User Pools (Terraform-managed) + +The `terraform/platform/identity/` module (not yet implemented) will create two Cognito user pools. Below is what Terraform will manage and what to verify manually. + +### 2.1 Internal Pool (`javabin-internal`) + +For Javabin heroes and board members — Google Workspace accounts only. + +**Terraform creates:** +- User pool with email as primary attribute +- Google IdP with `hd=java.no` restriction (hosted domain claim) +- Hosted UI domain: `auth-internal.javazone.no` +- Default app client for platform admin UI + +**Google IdP config (in Terraform):** +```hcl +resource "aws_cognito_identity_provider" "google_internal" { + user_pool_id = aws_cognito_user_pool.internal.id + provider_name = "Google" + provider_type = "Google" + + provider_details = { + client_id = var.google_oauth_client_id + client_secret = var.google_oauth_client_secret + authorize_scopes = "openid email profile" + attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" + attributes_url_add_attributes = true + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" + oidc_issuer = "https://accounts.google.com" + token_url = "https://oauth2.googleapis.com/token" + token_request_method = "POST" + } + + attribute_mapping = { + email = "email" + name = "name" + username = "sub" + } +} +``` + +**Manual verification after `terraform apply`:** +1. Visit `https://auth-internal.javazone.no/login?client_id=&response_type=code&scope=openid+email+profile&redirect_uri=` +2. Confirm only `@java.no` accounts can sign in +3. Verify the `hd` claim filtering works (non-java.no accounts get rejected) + +### 2.2 External Pool (`javabin-external`) + +For public users — JavaZone attendees, community members. + +**Terraform creates:** +- User pool with email as primary attribute +- Google IdP **without** `hd` restriction (any Google account) +- Self-registration enabled +- Hosted UI domain: `auth-external.javazone.no` + +**Key differences from internal:** +- No hosted domain restriction on Google IdP +- Self-registration allowed +- Optional: GitHub social IdP (if configured) +- MFA optional (not enforced) + +**Manual verification after `terraform apply`:** +1. Test sign-in with a non-java.no Google account +2. Test self-registration with email/password +3. Verify email verification flow works + +## 3. App Client Registration + +Apps declare their auth needs in `app.yaml`: + +```yaml +auth: internal # or: external, both, none +``` + +The `cognito-app-client` module (`terraform/modules/cognito-app-client/`) creates: +- Cognito user pool client(s) in the specified pool(s) +- SSM parameters with client credentials: + - `/{project}/platform-apps/{name}/cognito-internal-client-id` + - `/{project}/platform-apps/{name}/cognito-internal-client-secret` + - `/{project}/platform-apps/{name}/cognito-external-client-id` + - `/{project}/platform-apps/{name}/cognito-external-client-secret` + +Apps read these SSM parameters at runtime — no secrets in env vars or code. + +## 4. Checklist + +### Before Terraform deployment +- [ ] OAuth consent screen configured and published in GCP +- [ ] OAuth 2.0 Client ID created with correct redirect URIs +- [ ] Client ID and Secret available for Terraform variables +- [ ] Domain-wide delegation SA already in SSM (separate from OAuth client) + +### After Terraform deployment +- [ ] Internal pool: `@java.no` Google Sign-In works +- [ ] Internal pool: non-`@java.no` accounts are rejected +- [ ] External pool: any Google account can sign in +- [ ] External pool: self-registration works +- [ ] Hosted UI domains resolve correctly (Route53 + ACM) +- [ ] Test app client credentials in SSM are readable + +### DNS records needed (created by identity module) +- `auth-internal.javazone.no` — CNAME to Cognito internal pool domain +- `auth-external.javazone.no` — CNAME to Cognito external pool domain diff --git a/scripts/generate-terraform.sh b/scripts/generate-terraform.sh index 0ab924d..7e14a7b 100755 --- a/scripts/generate-terraform.sh +++ b/scripts/generate-terraform.sh @@ -5,6 +5,13 @@ # Only overwrites files that have the GENERATED marker (or don't exist yet). # Files without the marker are user-managed and left untouched. # +# Single-env (no environments: key): +# Generates files in $TF_ROOT/ (current behavior) +# +# Multi-env (environments: key present): +# Generates files in $TF_ROOT/{env}/ for each environment +# Prod environment creates the ECR repo; others reference it via data source +# # Required env vars: APP_SERVICE, AWS_ACCOUNT_ID, AWS_REGION, TF_ROOT set -e @@ -31,6 +38,15 @@ yaml_val() { grep -m1 "^${1}:" "$APP_YAML" | sed "s/^${1}:[[:space:]]*//" | sed 's/[[:space:]]*#.*//' | tr -d '"' | tr -d "'" } +# List environment names from the environments: block +list_environments() { + awk ' + /^environments:/ { in_envs=1; next } + in_envs && /^[^ ]/ { exit } + in_envs && /^ [a-z][a-z0-9]*:/ { sub(/:.*/, ""); sub(/^ /, ""); print } + ' "$APP_YAML" +} + APP_NAME=$(yaml_val name) APP_TEAM=$(yaml_val team) APP_AUTH=$(yaml_val auth) @@ -40,26 +56,41 @@ if [ -z "$APP_NAME" ]; then exit 1 fi -# --- backend.tf --- -if should_write "$TF_ROOT/backend.tf"; then - cat > "$TF_ROOT/backend.tf" << EOF +# Detect multi-environment mode +ENVIRONMENTS=$(list_environments) + +# Generate Terraform files for a single environment directory +# Args: $1=output_dir $2=state_key_suffix $3=env_name $4=env_tag $5=create_ecr $6=config_path +generate_env() { + local out_dir="$1" + local state_suffix="$2" + local env_name="$3" + local env_tag="$4" + local create_ecr="$5" + local config_path="$6" + + mkdir -p "$out_dir" + + # --- backend.tf --- + if should_write "$out_dir/backend.tf"; then + cat > "$out_dir/backend.tf" << EOF ${MARKER} terraform { backend "s3" { bucket = "javabin-terraform-state-${AWS_ACCOUNT_ID}" - key = "apps/${APP_SERVICE}/terraform.tfstate" + key = "apps/${APP_SERVICE}${state_suffix}/terraform.tfstate" region = "${AWS_REGION}" dynamodb_table = "javabin-terraform-app-locks" encrypt = true } } EOF - echo " wrote backend.tf" -fi + echo " wrote ${out_dir}/backend.tf" + fi -# --- providers.tf --- -if should_write "$TF_ROOT/providers.tf"; then - cat > "$TF_ROOT/providers.tf" << EOF + # --- providers.tf --- + if should_write "$out_dir/providers.tf"; then + cat > "$out_dir/providers.tf" << EOF ${MARKER} terraform { required_version = ">= 1.5" @@ -81,30 +112,79 @@ provider "aws" { managed-by = "terraform" service = "${APP_SERVICE}" team = "${APP_TEAM:-unknown}" - environment = "production" + environment = "${env_tag}" } } } EOF - echo " wrote providers.tf" -fi + echo " wrote ${out_dir}/providers.tf" + fi + + # --- main.tf --- + if should_write "$out_dir/main.tf"; then + { + echo "${MARKER}" + echo 'module "app" {' + echo ' source = "git::https://github.com/javaBin/platform.git//terraform/modules/app-stack?ref=main"' + echo '' + echo " config_file = \"${config_path}\"" + echo " aws_account_id = \"${AWS_ACCOUNT_ID}\"" + if [ -n "$env_name" ]; then + echo " environment_name = \"${env_name}\"" + fi + if [ "$create_ecr" = "false" ]; then + echo " create_ecr = false" + fi + echo '}' + } > "$out_dir/main.tf" + echo " wrote ${out_dir}/main.tf" + fi +} -# --- main.tf --- -if should_write "$TF_ROOT/main.tf"; then - { - echo "${MARKER}" - echo 'module "app" {' - echo ' source = "git::https://github.com/javaBin/platform.git//terraform/modules/app-stack?ref=main"' - echo '' - echo ' config_file = "${path.root}/../app.yaml"' - echo " aws_account_id = \"${AWS_ACCOUNT_ID}\"" - echo '}' - - # Cognito app client module — deferred until Phase 1 (Identity) is implemented. - # When Cognito user pools exist, this will generate a cognito-app-client module - # block for apps with auth: internal/external/both in their app.yaml. - } > "$TF_ROOT/main.tf" - echo " wrote main.tf" +if [ -n "$ENVIRONMENTS" ]; then + # Multi-environment mode + echo " detected environments: $ENVIRONMENTS" + + # Determine which env creates ECR (prod if it exists, otherwise first) + ECR_ENV="" + for env in $ENVIRONMENTS; do + if [ "$env" = "prod" ]; then + ECR_ENV="prod" + break + fi + done + if [ -z "$ECR_ENV" ]; then + ECR_ENV=$(echo "$ENVIRONMENTS" | head -1) + fi + + for env in $ENVIRONMENTS; do + create_ecr="false" + if [ "$env" = "$ECR_ENV" ]; then + create_ecr="true" + fi + + env_tag="$env" + if [ "$env" = "prod" ]; then + env_tag="production" + fi + + generate_env \ + "$TF_ROOT/$env" \ + "-${env}" \ + "$env" \ + "$env_tag" \ + "$create_ecr" \ + '${path.root}/../../app.yaml' + done +else + # Single-environment mode (backwards compatible) + generate_env \ + "$TF_ROOT" \ + "" \ + "" \ + "production" \ + "true" \ + '${path.root}/../app.yaml' fi # Write a hash of app.yaml so terraform/ always has a git diff when YAML changes. @@ -117,4 +197,10 @@ else fi # Format generated files -terraform fmt "$TF_ROOT" > /dev/null || true +if [ -n "$ENVIRONMENTS" ]; then + for env in $ENVIRONMENTS; do + terraform fmt "$TF_ROOT/$env" > /dev/null || true + done +else + terraform fmt "$TF_ROOT" > /dev/null || true +fi diff --git a/terraform/lambda-src/team_provisioner/handler.py b/terraform/lambda-src/team_provisioner/handler.py index 8c05ec1..fcd7452 100644 --- a/terraform/lambda-src/team_provisioner/handler.py +++ b/terraform/lambda-src/team_provisioner/handler.py @@ -1,31 +1,705 @@ -"""Team provisioner — syncs teams across Google Workspace, GitHub, Cognito, IAM. +"""Team provisioner — syncs teams across Google Workspace, GitHub, and AWS Budgets. -This is a STUB. Full implementation is blocked on Google Admin access. -Currently logs the incoming event and returns success. +Trigger: repository_dispatch from javaBin/registry (via function URL or SNS). +Event payload contains team YAML definitions. -Trigger: GitHub repository dispatch from javaBin/registry +Integrations: +- Google Admin SDK: Create/sync Google Workspace groups (domain-wide delegation SA) +- GitHub API: Create/sync GitHub teams (GitHub App installation token) +- AWS Budgets: Create team-level budget scoped to `team` tag +- Cognito: TODO — pools not deployed yet +- IAM Identity Center: TODO — not configured yet """ +import base64 import json import logging +import os +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +import boto3 +from shared.constants import USD_TO_NOK +from shared.slack import get_webhook_url, post_to_slack logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) +ssm = boto3.client("ssm") +budgets_client = boto3.client("budgets") + +GOOGLE_SA_PARAM = os.environ.get( + "GOOGLE_SA_PARAM", "/javabin/platform/google-admin-sa" +) +GOOGLE_ADMIN_EMAIL_PARAM = os.environ.get( + "GOOGLE_ADMIN_EMAIL_PARAM", "/javabin/platform/google-admin-email" +) +GITHUB_APP_ID_PARAM = os.environ.get( + "GITHUB_APP_ID_PARAM", "/javabin/platform/github-app-id" +) +GITHUB_APP_KEY_PARAM = os.environ.get( + "GITHUB_APP_KEY_PARAM", "/javabin/platform/github-app-key" +) +INFRA_WEBHOOK_PARAM = os.environ.get( + "INFRA_WEBHOOK_PARAM", "/javabin/slack/platform-resource-alerts-webhook" +) +ACCOUNT_ID = os.environ.get("ACCOUNT_ID", "") +GITHUB_ORG = os.environ.get("GITHUB_ORG", "javaBin") +ALERTS_TOPIC_ARN = os.environ.get("ALERTS_TOPIC_ARN", "") + +# Cache credentials across invocations within the same Lambda container +_credential_cache = {} + + +def _get_ssm_param(param_name): + """Fetch and cache an SSM parameter value.""" + if param_name not in _credential_cache: + resp = ssm.get_parameter(Name=param_name, WithDecryption=True) + _credential_cache[param_name] = resp["Parameter"]["Value"] + return _credential_cache[param_name] + + +# --------------------------------------------------------------------------- +# JWT / RS256 helpers (stdlib only — uses openssl CLI for RSA signing) +# --------------------------------------------------------------------------- + +def _b64url(data): + """Base64url-encode without padding.""" + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sign_rs256(message_bytes, private_key_pem): + """Sign with RS256 using the openssl binary (available in Lambda runtime).""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".pem", delete=True + ) as key_file: + key_file.write(private_key_pem) + key_file.flush() + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", key_file.name], + input=message_bytes, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError(f"openssl signing failed: {result.stderr.decode()}") + return result.stdout + + +def _create_jwt(payload, private_key_pem): + """Create a signed RS256 JWT from a dict payload.""" + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"})) + body = _b64url(json.dumps(payload)) + signing_input = f"{header}.{body}".encode("ascii") + signature = _b64url(_sign_rs256(signing_input, private_key_pem)) + return f"{header}.{body}.{signature}" + + +# --------------------------------------------------------------------------- +# Google Admin SDK — domain-wide delegation +# --------------------------------------------------------------------------- + +GOOGLE_SCOPES = ( + "https://www.googleapis.com/auth/admin.directory.group " + "https://www.googleapis.com/auth/admin.directory.group.member" +) + + +def _get_google_access_token(): + """Exchange a domain-wide delegation JWT for a Google OAuth2 access token. + + Token is cached for ~58 minutes (Google tokens last 1 hour). + """ + cached = _credential_cache.get("_google_token") + if cached and cached["exp"] > time.time() + 120: + return cached["token"] + + sa_json = json.loads(_get_ssm_param(GOOGLE_SA_PARAM)) + admin_email = _get_ssm_param(GOOGLE_ADMIN_EMAIL_PARAM) + + now = int(time.time()) + signed_jwt = _create_jwt( + { + "iss": sa_json["client_email"], + "sub": admin_email, + "scope": GOOGLE_SCOPES, + "aud": "https://oauth2.googleapis.com/token", + "iat": now, + "exp": now + 3600, + }, + sa_json["private_key"], + ) + + data = urllib.parse.urlencode( + { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": signed_jwt, + } + ).encode("utf-8") + + req = urllib.request.Request( + "https://oauth2.googleapis.com/token", + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req) as resp: + token = json.loads(resp.read())["access_token"] + + _credential_cache["_google_token"] = { + "token": token, + "exp": time.time() + 3500, + } + return token + + +def _google_api(method, path, access_token, body=None): + """Call the Google Admin Directory API. Returns parsed JSON or None.""" + url = f"https://admin.googleapis.com/admin/directory/v1{path}" + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {access_token}") + req.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + if e.code == 409: + return {"already_exists": True} + if e.code == 404: + return None + body_text = e.read().decode("utf-8", errors="replace") + logger.error("Google API %s %s → %d: %s", method, path, e.code, body_text) + raise + + +def _normalize_member(member): + """Normalize a member entry to a dict with email and github fields. + + Supports two formats: + - Simple string: "alexander.amiri" + → email: alexander.amiri@java.no (Google Workspace username) + → github: not set (must use dict format for GitHub team sync) + - Dict: {google: "alexander.amiri", github: "alexanderamiri"} + → email: alexander.amiri@java.no (derived from google field) + → github: alexanderamiri + + The 'google' field is the username part only — @java.no is always appended. + Legacy 'email' field is also accepted for backwards compatibility. + Members without a 'github' field are synced to Google Groups only. + """ + if isinstance(member, str): + username = member.strip() + return {"email": f"{username}@java.no"} + if isinstance(member, dict): + result = dict(member) + # Derive email from 'google' field if 'email' is not set + if "email" not in result and "google" in result: + result["email"] = f"{result['google']}@java.no" + return result + logger.warning("Unexpected member format: %s", member) + return {} + + +def sync_google_group(team): + """Create or update a Google Workspace group and sync its membership. + + Group email is derived as team-{name}@java.no. All members are MEMBER role. + """ + team_name = team["name"] + group_email = f"team-{team_name}@java.no" + + access_token = _get_google_access_token() + group_key = urllib.parse.quote(group_email, safe="") + + group_body = { + "email": group_email, + "name": team_name, + "description": team["description"], + } + + existing = _google_api("GET", f"/groups/{group_key}", access_token) + if existing and not existing.get("already_exists"): + _google_api("PUT", f"/groups/{group_key}", access_token, group_body) + logger.info("Updated Google group %s", group_email) + else: + result = _google_api("POST", "/groups", access_token, group_body) + if result and result.get("already_exists"): + logger.info("Google group %s already exists", group_email) + else: + logger.info("Created Google group %s", group_email) + + # Current members (paginated — Google returns max 200 per page) + current_emails = set() + page_token = None + while True: + path = f"/groups/{group_key}/members?maxResults=200" + if page_token: + path += f"&pageToken={urllib.parse.quote(page_token, safe='')}" + resp = _google_api("GET", path, access_token) + if resp and "members" in resp: + for m in resp["members"]: + current_emails.add(m["email"].lower()) + if resp and resp.get("nextPageToken"): + page_token = resp["nextPageToken"] + else: + break + + # Desired members + desired_emails = set() + for member in team.get("members", []): + m = _normalize_member(member) + email = m.get("email", "").lower() + if not email: + continue + desired_emails.add(email) + + if email not in current_emails: + _google_api( + "POST", + f"/groups/{group_key}/members", + access_token, + {"email": email, "role": "MEMBER"}, + ) + logger.info("Added %s to %s", email, group_email) + + # Remove members no longer in the team definition + for email in current_emails: + if email not in desired_emails: + encoded_email = urllib.parse.quote(email, safe="") + _google_api( + "DELETE", + f"/groups/{group_key}/members/{encoded_email}", + access_token, + ) + logger.info("Removed %s from %s", email, group_email) + + return { + "synced": True, + "group": group_email, + "member_count": len(desired_emails), + } + + +# --------------------------------------------------------------------------- +# GitHub API — App authentication +# --------------------------------------------------------------------------- + +_GITHUB_HEADERS = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + + +def _get_github_installation_token(): + """Create a GitHub App installation token for the javaBin org. + + Token is cached for ~9 minutes (GitHub installation tokens last 10 min). + """ + cached = _credential_cache.get("_github_token") + if cached and cached["exp"] > time.time() + 60: + return cached["token"] + + app_id = _get_ssm_param(GITHUB_APP_ID_PARAM) + app_key = _get_ssm_param(GITHUB_APP_KEY_PARAM) + + now = int(time.time()) + app_jwt = _create_jwt( + {"iat": now - 60, "exp": now + 600, "iss": app_id}, app_key + ) + + # Resolve the org installation ID + req = urllib.request.Request( + f"https://api.github.com/orgs/{GITHUB_ORG}/installation", + headers={**_GITHUB_HEADERS, "Authorization": f"Bearer {app_jwt}"}, + ) + with urllib.request.urlopen(req) as resp: + installation_id = json.loads(resp.read())["id"] + + # Exchange for an installation access token + req = urllib.request.Request( + f"https://api.github.com/app/installations/{installation_id}/access_tokens", + data=b"{}", + method="POST", + headers={**_GITHUB_HEADERS, "Authorization": f"Bearer {app_jwt}"}, + ) + with urllib.request.urlopen(req) as resp: + token = json.loads(resp.read())["token"] + + _credential_cache["_github_token"] = { + "token": token, + "exp": time.time() + 540, + } + return token + + +def _github_api(method, path, token, body=None): + """Call the GitHub REST API. Returns parsed JSON, empty dict, or None (404).""" + url = f"https://api.github.com{path}" + data = json.dumps(body).encode("utf-8") if body else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {token}") + for k, v in _GITHUB_HEADERS.items(): + req.add_header(k, v) + if data: + req.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(req) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + if e.code == 422: + return {"already_exists": True} + if e.code == 404: + return None + body_text = e.read().decode("utf-8", errors="replace") + logger.error("GitHub API %s %s → %d: %s", method, path, e.code, body_text) + raise + + +def sync_github_team(team): + """Create or update a GitHub team and sync its membership. + + All members are added as maintainers (no role distinction). + """ + team_name = team["name"] + token = _get_github_installation_token() + team_slug = team_name.lower().replace(" ", "-") + + team_body = { + "name": team_name, + "description": team["description"], + "privacy": "closed", + } + + existing = _github_api( + "GET", f"/orgs/{GITHUB_ORG}/teams/{team_slug}", token + ) + if existing and not existing.get("already_exists"): + _github_api( + "PATCH", + f"/orgs/{GITHUB_ORG}/teams/{team_slug}", + token, + team_body, + ) + logger.info("Updated GitHub team %s", team_slug) + else: + result = _github_api( + "POST", f"/orgs/{GITHUB_ORG}/teams", token, team_body + ) + if result and result.get("already_exists"): + logger.info("GitHub team %s already exists", team_slug) + else: + logger.info("Created GitHub team %s", team_slug) + + # Current team members (paginated — GitHub returns max 100 per page) + current_members = set() + page = 1 + while True: + members_resp = _github_api( + "GET", + f"/orgs/{GITHUB_ORG}/teams/{team_slug}/members?per_page=100&page={page}", + token, + ) + if not isinstance(members_resp, list) or not members_resp: + break + current_members.update(m["login"].lower() for m in members_resp) + if len(members_resp) < 100: + break + page += 1 + + # Desired members + desired_users = set() + for member in team.get("members", []): + m = _normalize_member(member) + github_user = m.get("github", "").lower() + if not github_user: + logger.warning( + "No GitHub handle for member %s in team %s — skipping", + member, + team_name, + ) + continue + desired_users.add(github_user) + _github_api( + "PUT", + f"/orgs/{GITHUB_ORG}/teams/{team_slug}/memberships/{github_user}", + token, + {"role": "maintainer"}, + ) + logger.info("Set %s as maintainer in team %s", github_user, team_slug) + + # Remove members no longer in the team definition + for login in current_members - desired_users: + _github_api( + "DELETE", + f"/orgs/{GITHUB_ORG}/teams/{team_slug}/memberships/{login}", + token, + ) + logger.info("Removed %s from GitHub team %s", login, team_slug) + + return { + "synced": True, + "team": team_slug, + "member_count": len(desired_users), + } + + +# --------------------------------------------------------------------------- +# AWS Budgets — team-level budget scoped to the `team` cost allocation tag +# --------------------------------------------------------------------------- + +def sync_budget(team): + """Create or update an AWS Budget for this team.""" + budget_nok = team.get("budget_nok", 500) + + budget_usd = round(budget_nok / USD_TO_NOK, 2) + budget_name = f"javabin-team-{team['name']}" + + budget_spec = { + "BudgetName": budget_name, + "BudgetLimit": {"Amount": str(budget_usd), "Unit": "USD"}, + "BudgetType": "COST", + "TimeUnit": "MONTHLY", + "CostFilters": {"TagKeyValue": [f"user:team${team['name']}"]}, + } + + try: + budgets_client.describe_budget( + AccountId=ACCOUNT_ID, BudgetName=budget_name + ) + budgets_client.update_budget( + AccountId=ACCOUNT_ID, NewBudget=budget_spec + ) + logger.info("Updated budget %s: $%.2f/mo", budget_name, budget_usd) + except budgets_client.exceptions.NotFoundException: + notification = { + "Notification": { + "NotificationType": "ACTUAL", + "ComparisonOperator": "GREATER_THAN", + "Threshold": 80.0, + "ThresholdType": "PERCENTAGE", + }, + "Subscribers": [ + {"SubscriptionType": "SNS", "Address": ALERTS_TOPIC_ARN} + ], + } + budgets_client.create_budget( + AccountId=ACCOUNT_ID, + Budget=budget_spec, + NotificationsWithSubscribers=[notification], + ) + logger.info("Created budget %s: $%.2f/mo", budget_name, budget_usd) + + return {"synced": True, "budget": budget_name, "usd": budget_usd} + + +# --------------------------------------------------------------------------- +# Cognito + Identity Center — stubs with clear extension points +# --------------------------------------------------------------------------- + +def sync_cognito_group(team): + """Sync team to Cognito user pool groups. + + TODO: Implement when Cognito user pools are deployed. + Needs: internal_pool_id, external_pool_id from identity module outputs. + Steps: + 1. Create group in internal pool (for java.no members) + 2. Create group in external pool (if team has external members) + 3. Assign users to groups via cognito-idp:AdminAddUserToGroup + """ + logger.info( + "Cognito sync skipped — pools not deployed (team: %s)", team["name"] + ) + return {"skipped": True, "reason": "cognito_pools_not_deployed"} + + +def sync_identity_center_group(team): + """Sync team to IAM Identity Center groups. + + TODO: Implement when Identity Center is configured. + Needs: identity_store_id from identity module outputs. + Steps: + 1. Create group via identitystore:CreateGroup + 2. Resolve user IDs via identitystore:ListUsers + 3. Assign via identitystore:CreateGroupMembership + """ + logger.info( + "Identity Center sync skipped — not configured (team: %s)", + team["name"], + ) + return {"skipped": True, "reason": "identity_center_not_configured"} + + +# --------------------------------------------------------------------------- +# Event parsing +# --------------------------------------------------------------------------- + +def _extract_payload(event): + """Unwrap the event payload from SNS, Function URL, or direct invocation.""" + if "Records" in event: + for record in event["Records"]: + if "Sns" in record: + return json.loads(record["Sns"]["Message"]) + + if "body" in event: + body = event["body"] + return json.loads(body) if isinstance(body, str) else body + + return event + + +def parse_team_payload(event): + """Extract team definitions from the Lambda event. + + Supports three invocation patterns: + 1. Direct: {"teams": [...]} + 2. SNS-wrapped: {"Records": [{"Sns": {"Message": '{"teams": [...]}'}}]} + 3. Function URL: {"body": '{"teams": [...]}'} + """ + payload = _extract_payload(event) + if "teams" in payload: + return payload["teams"] + + logger.warning( + "No team data found in event: %s", + json.dumps(event, default=str)[:500], + ) + return [] + + +# --------------------------------------------------------------------------- +# Slack notification +# --------------------------------------------------------------------------- + +def _result_line(provider, outcome): + """Format a single provider sync result for Slack.""" + if not isinstance(outcome, dict): + return f":question: {provider}: {outcome}" + if outcome.get("skipped"): + reason = outcome.get("reason", "skipped") + return f":white_circle: {provider}: {reason}" + if outcome.get("error"): + return f":red_circle: {provider}: {outcome['error']}" + return f":white_check_mark: {provider}: synced" + + +def notify_slack(results): + """Post provisioning summary to Slack.""" + webhook_url = get_webhook_url(ssm, INFRA_WEBHOOK_PARAM) + + blocks = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":busts_in_silhouette: Team Provisioning Complete", + "emoji": True, + }, + } + ] + + for team_name, result in results.items(): + lines = [_result_line(p, o) for p, o in result.items()] + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{team_name}*\n" + "\n".join(lines), + }, + } + ) + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + blocks.extend( + [ + {"type": "divider"}, + { + "type": "context", + "elements": [ + {"type": "mrkdwn", "text": f"Team provisioner | {ts}"} + ], + }, + ] + ) + + post_to_slack( + webhook_url, + { + "blocks": blocks, + "text": f"Team provisioning: {', '.join(results.keys())}", + }, + ) + + +# --------------------------------------------------------------------------- +# Lambda entry point +# --------------------------------------------------------------------------- def handler(event, context): - logger.info("Team provisioner invoked (stub)") - logger.info("Event: %s", json.dumps(event, default=str)) + logger.info("Team provisioner invoked") + logger.info("Event: %s", json.dumps(event, default=str)[:1000]) + + teams = parse_team_payload(event) + if not teams: + logger.info("No team data in event — nothing to provision") + return {"statusCode": 200, "body": "No teams to provision"} + + results = {} + + for team in teams: + team_name = team.get("name") + if not team_name: + logger.warning( + "Team entry missing 'name' — skipping: %s", + json.dumps(team)[:200], + ) + continue + + if not team.get("description"): + logger.warning( + "Team %s missing required 'description' — skipping", + team_name, + ) + results[team_name] = {"error": "missing required 'description' field"} + continue + + logger.info("Provisioning team: %s", team_name) + tr = {} + + for label, fn in [ + ("google", sync_google_group), + ("github", sync_github_team), + ("budget", sync_budget), + ("cognito", sync_cognito_group), + ("identity_center", sync_identity_center_group), + ]: + try: + tr[label] = fn(team) + except Exception as e: + logger.error("%s sync failed for %s: %s", label, team_name, e) + tr[label] = {"error": str(e)[:200]} + + results[team_name] = tr - # TODO: Implement when Google Admin access is available - # 1. Parse team definition from registry event - # 2. Create/update Google Workspace group - # 3. Create/update GitHub team - # 4. Create/update Cognito group - # 5. Create/update IAM Identity Center group - # 6. Create/update AWS Budget for team + try: + notify_slack(results) + except Exception as e: + logger.error("Slack notification failed: %s", e) return { "statusCode": 200, - "body": "Team provisioner stub — not yet implemented (Google Admin blocked)" + "body": json.dumps( + {"provisioned": list(results.keys()), "results": results}, + default=str, + ), } diff --git a/terraform/modules/app-stack/main.tf b/terraform/modules/app-stack/main.tf index f7e2b8b..e6c2031 100644 --- a/terraform/modules/app-stack/main.tf +++ b/terraform/modules/app-stack/main.tf @@ -5,20 +5,44 @@ locals { raw = yamldecode(file(var.config_file)) - # Top-level (name validated: lowercase alphanumeric + hyphens, max 20 chars for ALB target group limits) - name = regex("^[a-z][a-z0-9-]{0,19}$", local.raw.name) - port = try(local.raw.compute.port, 8000) - health_check = try(local.raw.compute.health_check, "/health") - health_check_matcher = try(local.raw.compute.health_check_matcher, "200") - - # Compute - cpu = try(local.raw.compute.cpu, 512) - memory = try(local.raw.compute.memory, 1024) - desired_count = try(local.raw.compute.desired_count, 1) - - # Routing (required — domain is a convenience alias for routing.host) - _routing_host = try(local.raw.routing.host, try(local.raw.domain, null)) - _routing_priority = try(local.raw.routing.priority, null) + # Top-level (name validated: lowercase alphanumeric + hyphens, max 20 chars) + name = regex("^[a-z][a-z0-9-]{0,19}$", local.raw.name) + env_suffix = var.environment_name != "" ? "-${var.environment_name}" : "" + qualified_name = "${local.name}${local.env_suffix}" + + # Environment-specific overrides (empty when not using environments) + env_config = var.environment_name != "" ? try(local.raw.environments[var.environment_name], {}) : {} + + # Validate target group name length (AWS limit: 32 chars) + _validate_tg_name = ( + length("${var.project}-${local.qualified_name}") <= 32 + ) ? true : tobool("Target group name '${var.project}-${local.qualified_name}' exceeds 32 chars. Use a shorter app name or environment name.") + + # Default compute values vary by environment (dev gets smaller defaults) + _default_cpu = var.environment_name == "dev" ? 256 : 512 + _default_memory = var.environment_name == "dev" ? 512 : 1024 + + # Compute (env overrides -> top-level -> defaults) + port = try(local.env_config.compute.port, local.raw.compute.port, 8000) + health_check = try(local.env_config.compute.health_check, local.raw.compute.health_check, "/health") + health_check_matcher = try(local.env_config.compute.health_check_matcher, local.raw.compute.health_check_matcher, "200") + cpu = try(local.env_config.compute.cpu, local.raw.compute.cpu, local._default_cpu) + memory = try(local.env_config.compute.memory, local.raw.compute.memory, local._default_memory) + desired_count = try(local.env_config.compute.desired_count, local.raw.compute.desired_count, 1) + + # Routing (env overrides -> env domain alias -> top-level -> top-level domain alias) + _routing_host = try( + local.env_config.routing.host, + local.env_config.domain, + local.raw.routing.host, + local.raw.domain, + null + ) + _routing_priority = try( + local.env_config.routing.priority, + local.raw.routing.priority, + null + ) _validate_routing = ( local._routing_host != null && local._routing_priority != null @@ -26,25 +50,25 @@ locals { host = local._routing_host priority = local._routing_priority - # Resources (empty defaults) - buckets = try(local.raw.resources.buckets, []) - databases = try(local.raw.resources.databases, []) - secrets = try(local.raw.resources.secrets, []) - queues = try(local.raw.resources.queues, []) + # Resources (env overrides replace entire section; top-level is fallback) + buckets = try(local.env_config.resources.buckets, local.raw.resources.buckets, []) + databases = try(local.env_config.resources.databases, local.raw.resources.databases, []) + secrets = try(local.env_config.resources.secrets, local.raw.resources.secrets, []) + queues = try(local.env_config.resources.queues, local.raw.resources.queues, []) - # Alarms - alarms_enabled = try(local.raw.alarms.enabled, true) - cpu_threshold = try(local.raw.alarms.cpu_threshold, 80) - memory_threshold = try(local.raw.alarms.memory_threshold, 80) - error_5xx_threshold = try(local.raw.alarms.error_5xx_threshold, 10) + # Alarms (env overrides -> top-level -> defaults) + alarms_enabled = try(local.env_config.alarms.enabled, local.raw.alarms.enabled, true) + cpu_threshold = try(local.env_config.alarms.cpu_threshold, local.raw.alarms.cpu_threshold, 80) + memory_threshold = try(local.env_config.alarms.memory_threshold, local.raw.alarms.memory_threshold, 80) + error_5xx_threshold = try(local.env_config.alarms.error_5xx_threshold, local.raw.alarms.error_5xx_threshold, 10) - # Convert lists → maps keyed by name (stable for_each keys) + # Convert lists -> maps keyed by name (stable for_each keys) bucket_map = { for b in local.buckets : b.name => b } database_map = { for d in local.databases : d.name => d } secret_map = { for s in local.secrets : s.name => s } queue_map = { for q in local.queues : q.name => q } - # Auto-wired secret env vars (YAML secrets → ECS secrets map) + # Auto-wired secret env vars (YAML secrets -> ECS secrets map) # Secrets with env field are injected into the ECS task definition yaml_secrets_map = { for s in local.secrets : s.env => module.secrets[s.name].secret_arn @@ -52,7 +76,7 @@ locals { } all_secrets = merge(local.yaml_secrets_map, var.additional_secrets) - # Auto-wired resource env vars (resources with env field → container env) + # Auto-wired resource env vars (resources with env field -> container env) bucket_env = { for b in local.buckets : b.env => module.buckets[b.name].bucket_name if try(b.env, "") != "" @@ -66,7 +90,11 @@ locals { if try(q.env, "") != "" } - yaml_environment = try(local.raw.environment, {}) + # Environment variables: merge top-level + env-specific (env wins on conflict) + yaml_environment = merge( + try(local.raw.environment, {}), + try(local.env_config.environment, {}), + ) all_environment = merge( local.yaml_environment, local.bucket_env, @@ -83,6 +111,11 @@ locals { { for name, _ in local.queue_map : "queue-${name}" => module.queues[name].access_policy_json }, var.additional_policy_jsons, ) + + # ECR URL (from created module or data source) + ecr_url = var.create_ecr ? module.ecr[0].repository_url : data.aws_ecr_repository.existing[0].repository_url + ecr_name = var.create_ecr ? module.ecr[0].repository_name : data.aws_ecr_repository.existing[0].name + ecr_arn = var.create_ecr ? module.ecr[0].repository_arn : data.aws_ecr_repository.existing[0].arn } ################################################################################ @@ -94,12 +127,18 @@ module "platform" { } ################################################################################ -# 2. ECR Repository +# 2. ECR Repository (shared across environments) ################################################################################ module "ecr" { source = "../ecr-repo" - name = local.name + count = var.create_ecr ? 1 : 0 + name = local.name # base name, shared across environments +} + +data "aws_ecr_repository" "existing" { + count = var.create_ecr ? 0 : 1 + name = local.name } ################################################################################ @@ -109,7 +148,7 @@ module "ecr" { module "routing" { source = "../service-routing" - name = local.name + name = local.qualified_name project = var.project vpc_id = module.platform.vpc_id port = local.port @@ -131,11 +170,10 @@ module "buckets" { source = "../service-bucket" for_each = local.bucket_map - # project=appname + name=resource → bucket: moresleep-data-{account_id} name = each.key - project = local.name + project = local.qualified_name aws_account_id = var.aws_account_id - service = local.name + service = local.qualified_name versioning = try(each.value.versioning, true) expire_days = try(each.value.expire_days, 0) } @@ -148,10 +186,9 @@ module "databases" { source = "../service-database" for_each = local.database_map - # project=appname + name=resource → table: moresleep-sessions name = each.key - project = local.name - service = local.name + project = local.qualified_name + service = local.qualified_name hash_key = try(each.value.hash_key, "id") hash_key_type = try(each.value.hash_key_type, "S") range_key = try(each.value.range_key, null) @@ -167,10 +204,9 @@ module "secrets" { source = "../service-secret" for_each = local.secret_map - # project=appname + name=resource → secret: moresleep/slack-bot-token name = each.key - project = local.name - service = local.name + project = local.qualified_name + service = local.qualified_name description = try(each.value.description, "") } @@ -182,10 +218,9 @@ module "queues" { source = "../service-queue" for_each = local.queue_map - # project=appname + name=resource → queue: moresleep-tasks name = each.key - project = local.name - service = local.name + project = local.qualified_name + service = local.qualified_name visibility_timeout_seconds = try(each.value.visibility_timeout, 30) retention_seconds = try(each.value.retention_seconds, 345600) max_receive_count = try(each.value.max_receive_count, 3) @@ -198,7 +233,7 @@ module "queues" { module "task_role" { source = "../service-role" - name = local.name + name = local.qualified_name project = var.project region = var.region aws_account_id = var.aws_account_id @@ -212,9 +247,9 @@ module "task_role" { module "service" { source = "../ecs-service" - name = local.name + name = local.qualified_name cluster_id = module.platform.ecs_cluster_id - image = "${module.ecr.repository_url}:${var.image_tag}" + image = "${local.ecr_url}:${var.image_tag}" cpu = local.cpu memory = local.memory port = local.port @@ -240,9 +275,9 @@ module "alarms" { source = "../service-alarm" count = local.alarms_enabled ? 1 : 0 - name = local.name + name = local.qualified_name project = var.project - service = local.name + service = local.qualified_name cluster_name = module.platform.ecs_cluster_name sns_topic_arns = [data.aws_sns_topic.alerts.arn] cpu_threshold = local.cpu_threshold diff --git a/terraform/modules/app-stack/outputs.tf b/terraform/modules/app-stack/outputs.tf index a55009f..04c2cd1 100644 --- a/terraform/modules/app-stack/outputs.tf +++ b/terraform/modules/app-stack/outputs.tf @@ -4,12 +4,12 @@ output "ecr_url" { description = "ECR repository URL" - value = module.ecr.repository_url + value = local.ecr_url } output "ecr_name" { description = "ECR repository name" - value = module.ecr.repository_name + value = local.ecr_name } ################################################################################ @@ -137,6 +137,16 @@ output "platform" { ################################################################################ output "app_name" { - description = "Application name from YAML" + description = "Application name from YAML (base name without environment suffix)" value = local.name } + +output "environment_name" { + description = "Environment name (empty string for single-environment apps)" + value = var.environment_name +} + +output "qualified_name" { + description = "Environment-qualified name used for resource naming (e.g. 'moresleep-dev')" + value = local.qualified_name +} diff --git a/terraform/modules/app-stack/variables.tf b/terraform/modules/app-stack/variables.tf index be2bcf7..7f2ef4b 100644 --- a/terraform/modules/app-stack/variables.tf +++ b/terraform/modules/app-stack/variables.tf @@ -4,6 +4,23 @@ variable "config_file" { default = "../app.yaml" } +variable "environment_name" { + description = "Environment name (e.g. 'dev', 'prod'). Empty string for single-environment apps." + type = string + default = "" + + validation { + condition = var.environment_name == "" || can(regex("^[a-z][a-z0-9]{0,3}$", var.environment_name)) + error_message = "environment_name must be lowercase alphanumeric, max 4 chars (e.g. dev, prod, test)." + } +} + +variable "create_ecr" { + description = "Whether to create the ECR repository. Set false for secondary environments sharing an ECR repo." + type = bool + default = true +} + variable "aws_account_id" { description = "AWS account ID for ARN construction and bucket naming" type = string diff --git a/terraform/org/identity-center.tf b/terraform/org/identity-center.tf new file mode 100644 index 0000000..297ef2a --- /dev/null +++ b/terraform/org/identity-center.tf @@ -0,0 +1,181 @@ +################################################################################ +# IAM Identity Center — Permission Sets + Group Assignments +# +# Security-critical: applied manually by admins, never by CI. +# The javabin-ci-infra role has explicit deny on sso:*. +# +# Prerequisite: Google Workspace SAML sync must be configured first so that +# Identity Center groups (infra@, heroes@, board@, pkom@) exist. +# +# ABAC: The "team" attribute flows from Google group membership through +# SAML assertion → Identity Center → STS session tag. This lets a single +# JavabinDeveloper permission set work for all teams. +################################################################################ + +data "aws_ssoadmin_instances" "this" {} + +locals { + instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0] +} + +################################################################################ +# Access Control Attributes (ABAC) — map "team" from SAML assertion +################################################################################ + +resource "aws_ssoadmin_instance_access_control_attributes" "this" { + instance_arn = local.instance_arn + + attribute { + key = "team" + value { + source = ["$${path:enterprise.team}"] + } + } +} + +################################################################################ +# Permission Set: JavabinAdmin +# Full admin for infra@java.no. No permission boundary (trusted). +################################################################################ + +resource "aws_ssoadmin_permission_set" "admin" { + name = "${var.project}-admin" + description = "Full administrator access for platform infrastructure team" + instance_arn = local.instance_arn + session_duration = var.session_duration +} + +resource "aws_ssoadmin_managed_policy_attachment" "admin" { + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.admin.arn + managed_policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + +################################################################################ +# Permission Set: JavabinDeveloper +# ABAC-scoped access for heroes. Uses permission boundary. +# Team tag from SAML assertion enables access to team-tagged resources. +################################################################################ + +resource "aws_ssoadmin_permission_set" "developer" { + name = "${var.project}-developer" + description = "Developer access scoped to team resources via ABAC" + instance_arn = local.instance_arn + session_duration = var.session_duration +} + +resource "aws_ssoadmin_permissions_boundary_attachment" "developer" { + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.developer.arn + + permissions_boundary { + customer_managed_policy_reference { + name = "${var.project}-developer-boundary" + } + } +} + +resource "aws_ssoadmin_permission_set_inline_policy" "developer" { + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.developer.arn + + inline_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "ReadOwnTeamResources" + Effect = "Allow" + Action = [ + "ecs:Describe*", + "ecs:List*", + "ecr:Describe*", + "ecr:List*", + "ecr:GetRepositoryPolicy", + "ecr:BatchGetImage", + "s3:GetObject", + "s3:ListBucket", + "s3:GetBucketLocation", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:DescribeTable", + "sqs:GetQueueAttributes", + "sqs:ListQueues", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecrets", + "logs:GetLogEvents", + "logs:FilterLogEvents", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "cloudwatch:GetMetricData", + "cloudwatch:DescribeAlarms", + "cloudwatch:ListMetrics", + ] + Resource = "*" + Condition = { + StringEqualsIfExists = { + "aws:ResourceTag/team" = "$${aws:PrincipalTag/team}" + } + } + }, + { + Sid = "ReadPlatformSharedResources" + Effect = "Allow" + Action = [ + "ecs:ListClusters", + "ecs:ListServices", + "ecr:GetAuthorizationToken", + "elasticloadbalancing:Describe*", + "route53:ListHostedZones", + "route53:GetHostedZone", + ] + Resource = "*" + }, + { + Sid = "CostExplorer" + Effect = "Allow" + Action = [ + "ce:GetCostAndUsage", + "ce:GetCostForecast", + ] + # ce:* requires Resource = "*" per AWS docs + Resource = "*" + }, + { + Sid = "SSMAppParameters" + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + ] + Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/platform-apps/*" + }, + ] + }) +} + +################################################################################ +# Permission Set: JavabinReadOnly +# Governance visibility for board and program committee. +################################################################################ + +resource "aws_ssoadmin_permission_set" "readonly" { + name = "${var.project}-readonly" + description = "Read-only governance access for board and program committee" + instance_arn = local.instance_arn + session_duration = var.session_duration +} + +resource "aws_ssoadmin_managed_policy_attachment" "readonly" { + instance_arn = local.instance_arn + permission_set_arn = aws_ssoadmin_permission_set.readonly.arn + managed_policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" +} + +################################################################################ +# Group → Permission Set Assignments +# +# TODO: Add group lookups and assignments after Google SAML sync is configured +# and Identity Center groups (infra@, heroes@, board@, pkom@) exist. +################################################################################ diff --git a/terraform/org/main.tf b/terraform/org/main.tf index 248736f..1daf886 100644 --- a/terraform/org/main.tf +++ b/terraform/org/main.tf @@ -21,6 +21,9 @@ resource "aws_organizations_organization" "main" { # Enable SCPs enabled_policy_types = ["SERVICE_CONTROL_POLICY"] + + # sso.amazonaws.com is required for Identity Center + aws_service_access_principals = ["sso.amazonaws.com"] } ################################################################################ diff --git a/terraform/org/variables.tf b/terraform/org/variables.tf index 849e266..351fb10 100644 --- a/terraform/org/variables.tf +++ b/terraform/org/variables.tf @@ -36,3 +36,9 @@ variable "exempt_roles" { "arn:aws:iam::553637109631:root" ] } + +variable "session_duration" { + description = "SSO session duration in ISO 8601 format" + type = string + default = "PT4H" +} diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index d0d82aa..ed8acc9 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -100,12 +100,12 @@ resource "aws_iam_role_policy" "ci_infra_deny" { Resource = "*" }, { - Sid = "DenyIdentityStore" + Sid = "DenyIdentityCenter" Effect = "Deny" Action = [ - "identitystore:CreateUser", - "sso:CreateInstance", - "sso:DeleteInstance", + "sso:*", + "sso-admin:*", + "identitystore:*", ] Resource = "*" }, @@ -164,12 +164,17 @@ resource "aws_iam_role" "ci_app" { } Action = "sts:AssumeRoleWithWebIdentity" Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } StringLike = { "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${each.key}:*" - } - StringEquals = { - "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" - "token.actions.githubusercontent.com:job_workflow_ref" = "${var.github_org}/platform/.github/workflows/tf-plan.yml@refs/heads/main" + # Terraform operations: plan (read), review (read), apply (write) + "token.actions.githubusercontent.com:job_workflow_ref" = [ + "${var.github_org}/platform/.github/workflows/tf-plan.yml@refs/heads/main", + "${var.github_org}/platform/.github/workflows/plan-review.yml@refs/heads/main", + "${var.github_org}/platform/.github/workflows/tf-apply.yml@refs/heads/main", + ] } } } @@ -204,11 +209,95 @@ resource "aws_iam_role_policy" "ci_app_allow" { }) } +resource "aws_iam_role_policy" "ci_app_deny" { + for_each = toset(var.registered_app_repos) + + name = "deny-platform-operations" + role = aws_iam_role.ci_app[each.key].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyNetworkInfra" + Effect = "Deny" + Action = [ + "ec2:CreateVpc", "ec2:DeleteVpc", "ec2:ModifyVpcAttribute", + "ec2:CreateSubnet", "ec2:DeleteSubnet", + "ec2:CreateNatGateway", "ec2:DeleteNatGateway", + "ec2:CreateInternetGateway", "ec2:DeleteInternetGateway", + "ec2:AttachInternetGateway", "ec2:DetachInternetGateway", + "ec2:CreateRouteTable", "ec2:DeleteRouteTable", + "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", + "ec2:AuthorizeSecurityGroupIngress", "ec2:RevokeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", "ec2:RevokeSecurityGroupEgress", + ] + Resource = "*" + }, + { + Sid = "DenyLoadBalancerAndCluster" + Effect = "Deny" + Action = [ + "elasticloadbalancingv2:CreateLoadBalancer", + "elasticloadbalancingv2:DeleteLoadBalancer", + "ecs:CreateCluster", "ecs:DeleteCluster", + ] + Resource = "*" + }, + { + Sid = "DenySecurityServices" + Effect = "Deny" + Action = [ + "guardduty:*", + "securityhub:*", + "config:*", + "cloudtrail:*", + ] + Resource = "*" + }, + { + Sid = "DenyOrgAndAccount" + Effect = "Deny" + Action = [ + "organizations:*", + "account:*", + ] + Resource = "*" + }, + { + Sid = "DenyDangerousIAM" + Effect = "Deny" + Action = [ + "iam:CreateUser", + "iam:CreateAccessKey", + "iam:CreateLoginProfile", + ] + Resource = "*" + }, + { + Sid = "DenyPlatformSNS" + Effect = "Deny" + Action = [ + "sns:CreateTopic", + "sns:DeleteTopic", + ] + Resource = "*" + }, + { + Sid = "DenyStateBucketDeletion" + Effect = "Deny" + Action = "s3:DeleteBucket" + Resource = "*" + }, + ] + }) +} + ################################################################################ # 3. javabin-ci-deploy-{repo} — Per-app deploy role (build + push + deploy) # -# Trust: GitHub OIDC pinned to the platform's tf-apply, docker-build, and -# ecs-deploy workflows on main. +# Trust: GitHub OIDC pinned to docker-build and ecs-deploy workflows on main. +# Terraform operations use the app role instead. # Permissions: Scoped to ECR push, ECS update, CloudWatch logs, SSM read. ################################################################################ @@ -233,9 +322,8 @@ resource "aws_iam_role" "ci_deploy" { } StringLike = { "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${each.key}:*" - # Trust tf-apply, docker-build, and ecs-deploy workflows + # Deployment operations only — Terraform uses the app role "token.actions.githubusercontent.com:job_workflow_ref" = [ - "${var.github_org}/platform/.github/workflows/tf-apply.yml@refs/heads/main", "${var.github_org}/platform/.github/workflows/docker-build.yml@refs/heads/main", "${var.github_org}/platform/.github/workflows/ecs-deploy.yml@refs/heads/main", ] diff --git a/terraform/platform/identity/main.tf b/terraform/platform/identity/main.tf new file mode 100644 index 0000000..6adf83f --- /dev/null +++ b/terraform/platform/identity/main.tf @@ -0,0 +1,199 @@ +################################################################################ +# Cognito User Pools — Internal (java.no) + External (public) +# +# Pools are always created. Google IdP is added when google_client_id is set. +# Custom domains require certificate_arn (must be in us-east-1 for CloudFront). +# +# Identity Center lives in terraform/org/ (human-applied, not CI). +################################################################################ + +################################################################################ +# Internal Pool — Javabin heroes (@java.no accounts only) +################################################################################ + +resource "aws_cognito_user_pool" "internal" { + name = "${var.project}-internal" + + auto_verified_attributes = ["email"] + username_attributes = ["email"] + + password_policy { + minimum_length = 12 + require_lowercase = true + require_numbers = true + require_symbols = false + require_uppercase = true + } + + schema { + name = "email" + attribute_data_type = "String" + required = true + mutable = true + + string_attribute_constraints { + min_length = 1 + max_length = 256 + } + } + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + tags = { + Name = "${var.project}-internal" + } +} + +resource "aws_cognito_identity_provider" "internal_google" { + count = var.google_client_id != "" ? 1 : 0 + + user_pool_id = aws_cognito_user_pool.internal.id + provider_name = "Google" + provider_type = "Google" + + provider_details = { + client_id = var.google_client_id + client_secret = var.google_client_secret + authorize_scopes = "openid email profile" + attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" + attributes_url_add_attributes = "true" + # TODO: hd= is a UI hint only, not server-side enforcement. Add a pre-sign-up + # Lambda trigger to validate @java.no domain, or use separate GCP OAuth clients. + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth?hd=${var.google_workspace_domain}" + oidc_issuer = "https://accounts.google.com" + token_url = "https://www.googleapis.com/oauth2/v4/token" + token_request_method = "POST" + } + + attribute_mapping = { + email = "email" + username = "sub" + name = "name" + } +} + +resource "aws_cognito_user_pool_domain" "internal" { + count = var.certificate_arn != "" ? 1 : 0 + + domain = "auth-internal.${var.domain}" + certificate_arn = var.certificate_arn + user_pool_id = aws_cognito_user_pool.internal.id +} + +resource "aws_route53_record" "internal_auth" { + count = var.route53_zone_id != "" && var.certificate_arn != "" ? 1 : 0 + + zone_id = var.route53_zone_id + name = "auth-internal.${var.domain}" + type = "A" + + alias { + name = aws_cognito_user_pool_domain.internal[0].cloudfront_distribution + zone_id = aws_cognito_user_pool_domain.internal[0].cloudfront_distribution_zone_id + evaluate_target_health = false + } +} + +# Groups mirroring Google Workspace +resource "aws_cognito_user_group" "internal_groups" { + for_each = toset(["heroes", "board", "pkom", "infra"]) + + name = each.key + user_pool_id = aws_cognito_user_pool.internal.id + description = "${each.key} group (synced from Google Workspace)" +} + +################################################################################ +# External Pool — Public users (JavaZone attendees, etc.) +################################################################################ + +resource "aws_cognito_user_pool" "external" { + name = "${var.project}-external" + + auto_verified_attributes = ["email"] + username_attributes = ["email"] + + password_policy { + minimum_length = 8 + require_lowercase = true + require_numbers = true + require_symbols = false + require_uppercase = false + } + + schema { + name = "email" + attribute_data_type = "String" + required = true + mutable = true + + string_attribute_constraints { + min_length = 1 + max_length = 256 + } + } + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + tags = { + Name = "${var.project}-external" + } +} + +resource "aws_cognito_identity_provider" "external_google" { + count = var.google_client_id != "" ? 1 : 0 + + user_pool_id = aws_cognito_user_pool.external.id + provider_name = "Google" + provider_type = "Google" + + provider_details = { + client_id = var.google_client_id + client_secret = var.google_client_secret + authorize_scopes = "openid email profile" + attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" + attributes_url_add_attributes = "true" + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" + oidc_issuer = "https://accounts.google.com" + token_url = "https://www.googleapis.com/oauth2/v4/token" + token_request_method = "POST" + } + + attribute_mapping = { + email = "email" + username = "sub" + name = "name" + } +} + +resource "aws_cognito_user_pool_domain" "external" { + count = var.certificate_arn != "" ? 1 : 0 + + domain = "auth-external.${var.domain}" + certificate_arn = var.certificate_arn + user_pool_id = aws_cognito_user_pool.external.id +} + +resource "aws_route53_record" "external_auth" { + count = var.route53_zone_id != "" && var.certificate_arn != "" ? 1 : 0 + + zone_id = var.route53_zone_id + name = "auth-external.${var.domain}" + type = "A" + + alias { + name = aws_cognito_user_pool_domain.external[0].cloudfront_distribution + zone_id = aws_cognito_user_pool_domain.external[0].cloudfront_distribution_zone_id + evaluate_target_health = false + } +} diff --git a/terraform/platform/identity/outputs.tf b/terraform/platform/identity/outputs.tf new file mode 100644 index 0000000..73237b4 --- /dev/null +++ b/terraform/platform/identity/outputs.tf @@ -0,0 +1,35 @@ +################################################################################ +# Cognito outputs +################################################################################ + +# Internal pool +output "internal_user_pool_id" { + description = "ID of the internal Cognito user pool" + value = aws_cognito_user_pool.internal.id +} + +output "internal_user_pool_arn" { + description = "ARN of the internal Cognito user pool" + value = aws_cognito_user_pool.internal.arn +} + +output "internal_user_pool_domain" { + description = "Custom domain of the internal Cognito user pool (empty if not configured)" + value = var.certificate_arn != "" ? "auth-internal.${var.domain}" : "" +} + +# External pool +output "external_user_pool_id" { + description = "ID of the external Cognito user pool" + value = aws_cognito_user_pool.external.id +} + +output "external_user_pool_arn" { + description = "ARN of the external Cognito user pool" + value = aws_cognito_user_pool.external.arn +} + +output "external_user_pool_domain" { + description = "Custom domain of the external Cognito user pool (empty if not configured)" + value = var.certificate_arn != "" ? "auth-external.${var.domain}" : "" +} diff --git a/terraform/platform/identity/variables.tf b/terraform/platform/identity/variables.tf new file mode 100644 index 0000000..62b11a0 --- /dev/null +++ b/terraform/platform/identity/variables.tf @@ -0,0 +1,40 @@ +variable "project" { + description = "Project name used for resource naming" + type = string +} + +variable "domain" { + description = "Base domain (e.g. javazone.no)" + type = string +} + +variable "google_workspace_domain" { + description = "Google Workspace domain for Cognito IdP hosted-domain restriction" + type = string + default = "java.no" +} + +variable "google_client_id" { + description = "Google OAuth 2.0 client ID for Cognito IdP (from GCP console)" + type = string + default = "" +} + +variable "google_client_secret" { + description = "Google OAuth 2.0 client secret for Cognito IdP (from GCP console)" + type = string + sensitive = true + default = "" +} + +variable "route53_zone_id" { + description = "Route53 hosted zone ID for auth domain DNS records" + type = string + default = "" +} + +variable "certificate_arn" { + description = "ACM certificate ARN in us-east-1 for custom Cognito domains (CloudFront requirement)" + type = string + default = "" +} diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 5481335..9fb68af 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -100,7 +100,23 @@ data "archive_file" "override_cleanup" { data "archive_file" "team_provisioner" { type = "zip" output_path = "${path.module}/builds/team_provisioner.zip" - source_dir = "${local.lambda_src_path}/team_provisioner" + + source { + content = file("${local.lambda_src_path}/team_provisioner/handler.py") + filename = "handler.py" + } + source { + content = file("${local.lambda_src_path}/shared/__init__.py") + filename = "shared/__init__.py" + } + source { + content = file("${local.lambda_src_path}/shared/slack.py") + filename = "shared/slack.py" + } + source { + content = file("${local.lambda_src_path}/shared/constants.py") + filename = "shared/constants.py" + } } ################################################################################ @@ -345,7 +361,7 @@ resource "aws_iam_role_policy_attachment" "override_cleanup_logs" { policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } -# --- team-provisioner role (stub — minimal permissions) --- +# --- team-provisioner role --- resource "aws_iam_role" "team_provisioner" { name = "${var.project}-team-provisioner" @@ -359,6 +375,36 @@ resource "aws_iam_role" "team_provisioner" { }) } +resource "aws_iam_role_policy" "team_provisioner" { + name = "${var.project}-team-provisioner" + role = aws_iam_role.team_provisioner.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "SSMRead" + Effect = "Allow" + Action = "ssm:GetParameter" + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/javabin/platform/*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/javabin/slack/*", + ] + }, + { + Sid = "BudgetsManage" + Effect = "Allow" + Action = [ + "budgets:CreateBudget", + "budgets:DescribeBudget", + "budgets:UpdateBudget", + ] + Resource = "arn:aws:budgets::${var.aws_account_id}:budget/javabin-team-*" + }, + ] + }) +} + resource "aws_iam_role_policy_attachment" "team_provisioner_logs" { role = aws_iam_role.team_provisioner.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" @@ -471,6 +517,19 @@ resource "aws_lambda_function" "team_provisioner" { memory_size = 256 filename = data.archive_file.team_provisioner.output_path source_code_hash = data.archive_file.team_provisioner.output_base64sha256 + + environment { + variables = { + INFRA_WEBHOOK_PARAM = "/javabin/slack/platform-resource-alerts-webhook" + GOOGLE_SA_PARAM = "/javabin/platform/google-admin-sa" + GOOGLE_ADMIN_EMAIL_PARAM = "/javabin/platform/google-admin-email" + GITHUB_APP_ID_PARAM = "/javabin/platform/github-app-id" + GITHUB_APP_KEY_PARAM = "/javabin/platform/github-app-key" + ACCOUNT_ID = var.aws_account_id + GITHUB_ORG = "javaBin" + ALERTS_TOPIC_ARN = var.alerts_topic_arn + } + } } ################################################################################ diff --git a/terraform/platform/main.tf b/terraform/platform/main.tf index 74254a2..069ae12 100644 --- a/terraform/platform/main.tf +++ b/terraform/platform/main.tf @@ -11,7 +11,7 @@ # compute ECS cluster, public ECR images # monitoring SNS topics, EventBridge rules, Config, GuardDuty, Security Hub # lambdas slack-alert, cost-report, daily-cost-check, auto-tagger -# identity IAM Identity Center, Cognito pools +# identity Cognito pools (Identity Center is in terraform/org/) ################################################################################ module "networking" { @@ -61,13 +61,12 @@ module "lambdas" { compliance_reporter_identities = var.auto_tagger_identities } -# NOTE: identity module is not yet implemented. -# Uncomment when ready. - -# module "identity" { -# source = "./identity" -# project = var.project -# region = var.region -# aws_account_id = var.aws_account_id -# domain = var.domain -# } +module "identity" { + source = "./identity" + project = var.project + domain = var.domain + route53_zone_id = module.ingress.route53_zone_id + google_client_id = var.google_client_id + google_client_secret = var.google_client_secret + certificate_arn = var.certificate_arn +} diff --git a/terraform/platform/variables.tf b/terraform/platform/variables.tf index bfcde7b..997d649 100644 --- a/terraform/platform/variables.tf +++ b/terraform/platform/variables.tf @@ -48,6 +48,25 @@ variable "github_org" { default = "javaBin" } +variable "google_client_id" { + description = "Google OAuth 2.0 client ID for Cognito IdP (from GCP console)" + type = string + default = "" +} + +variable "google_client_secret" { + description = "Google OAuth 2.0 client secret for Cognito IdP (from GCP console)" + type = string + sensitive = true + default = "" +} + +variable "certificate_arn" { + description = "ACM certificate ARN in us-east-1 for Cognito custom domains (CloudFront requirement)" + type = string + default = "" +} + variable "auto_tagger_identities" { description = "IAM identity substrings allowed to trigger auto-tagging" type = list(string)