From f55f4344bd3d0a8526118e39ff1e8ea68143fbef Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Fri, 23 Oct 2026 17:17:55 +0200 Subject: [PATCH 1/2] feat(org): let developers set their own team's app secrets app.yaml resources.secrets are created with a PLACEHOLDER value, but the developer permission set could only read /javabin/apps//*, so no team could set its own secrets. Allow ssm:PutParameter on the team's own path, deny writes to the platform-managed Cognito credentials and auth requests, and allow ecs:UpdateService on the team's own services so tasks can be restarted to pick up new values. Used by javabin secret set in javaBin/javabin-cli. terraform/org is human-applied. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/app-yaml-reference.md | 9 +++++++- terraform/org/identity-center.tf | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 65c3275..6a8b9f3 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -156,7 +156,14 @@ RDS instances generate a master password at creation time, stored in SSM Paramet #### secrets -SSM Parameter Store SecureString parameters. Value is set manually after creation. +SSM Parameter Store SecureString parameters. The platform creates each one with the value `PLACEHOLDER`; set the real value with the CLI from the app repo (needs the `javabin-developer` SSO role): + +```bash +javabin secret list # which secrets still hold PLACEHOLDER +javabin secret set stripe-key --restart # prompts without echo; --stdin to pipe it in +``` + +ECS reads secrets when a task starts, so `--restart` (or any redeploy) is needed for running tasks to see a new value. Terraform never overwrites the value once set. ```yaml resources: diff --git a/terraform/org/identity-center.tf b/terraform/org/identity-center.tf index d2d412a..44d5d4e 100644 --- a/terraform/org/identity-center.tf +++ b/terraform/org/identity-center.tf @@ -172,6 +172,43 @@ resource "aws_ssoadmin_permission_set_inline_policy" "developer" { ] Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/$${aws:PrincipalTag/team}/*" }, + # Secret values: app.yaml `resources.secrets` creates the parameter with + # a PLACEHOLDER value; the team sets the real value (`javabin secret + # set`). Only under the team's own path. + { + Sid = "SetOwnTeamSecrets" + Effect = "Allow" + Action = [ + "ssm:PutParameter", + "ssm:LabelParameterVersion", + "ssm:GetParameterHistory", + ] + Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/$${aws:PrincipalTag/team}/*" + }, + # Platform-managed parameters in the team path: Cognito credentials + # (app-auth-sync) and the auth request (app Terraform). + { + Sid = "DenyPlatformManagedParameters" + Effect = "Deny" + Action = ["ssm:PutParameter", "ssm:DeleteParameter", "ssm:DeleteParameters", "ssm:LabelParameterVersion"] + Resource = [ + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*/*/cognito-client-*", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*/*/auth", + ] + }, + # Restart own services so tasks pick up new secret values + # (ECS resolves `secrets` only at task start). + { + Sid = "RestartOwnTeamServices" + Effect = "Allow" + Action = ["ecs:UpdateService"] + Resource = "*" + Condition = { + StringEquals = { + "aws:ResourceTag/team" = "$${aws:PrincipalTag/team}" + } + } + }, ] }) } From fff0782a758f7aeb12a418b83b83785ab0e12dcb Mon Sep 17 00:00:00 2001 From: Alexanderamiri Date: Fri, 23 Oct 2026 23:55:47 +0200 Subject: [PATCH 2/2] fix(org): only tagged team secrets; platform restarts apps on change Review feedback on #139: - PutParameter requires the parameter's team tag to match, so developers can only set secrets the platform created; creating one by hand would break the app's next terraform apply (ParameterAlreadyExists). - db-master-password joins the platform-managed deny list. - Drop ecs:UpdateService from the developer permission set: it also allows changing networking and task definitions. Instead a new javabin-app-secret-rollout Lambda forces a new deployment of {team}-{app} when one of its secrets is updated (EventBridge). Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 3 +- docs/app-yaml-reference.md | 6 +- docs/lambda-functions.md | 5 + .../lambda-src/app_secret_rollout/handler.py | 47 +++++++++ .../tests/test_app_secret_rollout.py | 66 +++++++++++++ terraform/org/identity-center.tf | 27 +++--- terraform/platform/lambdas/main.tf | 95 +++++++++++++++++++ 7 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 terraform/lambda-src/app_secret_rollout/handler.py create mode 100644 terraform/lambda-src/tests/test_app_secret_rollout.py diff --git a/CLAUDE.md b/CLAUDE.md index 25e4982..0300280 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ terraform/platform/ iam/ GitHub OIDC, CI roles, permission boundary compute/ ECS cluster, ECR base config monitoring/ SNS, EventBridge, Config, GuardDuty, Security Hub - lambdas/ slack-alert, cost-report, daily-cost-check, compliance-reporter, resource-tagger, budget-enforcer, override-cleanup, team-provisioner, apply-gate, securityhub-summary, password-set, ci-broker, app-auth-sync, plan-review + lambdas/ slack-alert, cost-report, daily-cost-check, compliance-reporter, resource-tagger, budget-enforcer, override-cleanup, team-provisioner, apply-gate, securityhub-summary, password-set, ci-broker, app-auth-sync, plan-review, app-secret-rollout identity/ Cognito user pools (internal + external), auth-claims trigger, auth memberships table. Identity Center is in terraform/org/ ``` @@ -172,6 +172,7 @@ terraform/state/ | `app-auth-sync` | Creates/deletes Cognito app clients requested by app.yaml `auth:` (SSM request → client + credentials + allowed groups) | | `auth-claims` | Internal pool pre-token trigger: registry groups → `cognito:groups`, enforces app `auth.groups` (in `identity/`) | | `plan-review` | LLM review of app Terraform plans with platform credentials; writes `risk.json` (invoked by app tf-plan) | +| `app-secret-rollout` | Restarts `{team}-{app}` when one of its secrets in `/javabin/apps/` changes (ECS reads secrets only at task start) | ### Scripts | Script | What | diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 8684e51..c6d2794 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -159,11 +159,11 @@ RDS instances generate a master password at creation time, stored in SSM Paramet SSM Parameter Store SecureString parameters. The platform creates each one with the value `PLACEHOLDER`; set the real value with the CLI from the app repo (needs the `javabin-developer` SSO role): ```bash -javabin secret list # which secrets still hold PLACEHOLDER -javabin secret set stripe-key --restart # prompts without echo; --stdin to pipe it in +javabin secret list # which secrets still hold PLACEHOLDER +javabin secret set stripe-key # prompts without echo; --stdin to pipe it in ``` -ECS reads secrets when a task starts, so `--restart` (or any redeploy) is needed for running tasks to see a new value. Terraform never overwrites the value once set. +ECS reads secrets when a task starts, so the platform restarts the service (`javabin-app-secret-rollout`) within a minute of a secret changing. Only secrets the platform created can be set (deploy first), and Terraform never overwrites the value once set. ```yaml resources: diff --git a/docs/lambda-functions.md b/docs/lambda-functions.md index dd90db7..2d6d712 100644 --- a/docs/lambda-functions.md +++ b/docs/lambda-functions.md @@ -138,6 +138,11 @@ Team roles are denied writing `risk.json`, `review.json` and `override.json` in |---------------|-----| | `/javabin/slack/platform-override-alerts-webhook` | HIGH/FAILED alerts | +## app-secret-rollout + +**Trigger:** EventBridge "Parameter Store Change" (Update) under `/javabin/apps/` +**Purpose:** ECS resolves task secrets only at start, so after a team sets a secret value (`javabin secret set`) this forces a new deployment of `{team}-{app}` in `javabin-platform`. Teams have no `ecs:UpdateService` themselves (it would also allow changing networking and task definitions). Platform-managed parameters (`auth`, `cognito-client-*`, `db-master-password`) are ignored; a missing service is logged and skipped. + ## Shared Module: pricing `terraform/lambda-src/shared/pricing.py` — provides `estimate_cost()` for enriching alerts with cost impact. Uses AWS Pricing API (us-east-1 endpoint). Caches pricing data across Lambda invocations. diff --git a/terraform/lambda-src/app_secret_rollout/handler.py b/terraform/lambda-src/app_secret_rollout/handler.py new file mode 100644 index 0000000..42193d1 --- /dev/null +++ b/terraform/lambda-src/app_secret_rollout/handler.py @@ -0,0 +1,47 @@ +"""App secret rollout — restart an app's ECS service when one of its secrets changes. + +ECS resolves a task's `secrets` only when the task starts, so a value set +with `javabin secret set` reaches the app only after a new deployment. +Teams may write their own secret values but have no ecs:UpdateService (it +would also let them change networking or task definitions), so the +platform does the restart: + + SSM "Parameter Store Change" Update on /javabin/apps/{team}/{app}/{name} + -> force a new deployment of service {team}-{app} in the platform cluster + +Platform-managed parameters (auth request, Cognito credentials, RDS master +password) are ignored: their consumers redeploy through their own paths. +""" + +import logging +import os +import re + +import boto3 + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +PROJECT = os.environ.get("PROJECT", "javabin") +CLUSTER = os.environ.get("CLUSTER", f"{PROJECT}-platform") +SECRET_RE = re.compile(rf"^/{re.escape(PROJECT)}/apps/(?P[a-z]{{1,12}})/(?P[a-z][a-z0-9-]{{0,19}})/(?P[^/]+)$") +PLATFORM_MANAGED = re.compile(r"^(auth|cognito-client-.*|db-master-password)$") + +ecs = boto3.client("ecs") + + +def handler(event, context): + detail = event.get("detail") or {} + name, operation = detail.get("name", ""), detail.get("operation", "") + m = SECRET_RE.match(name) + if operation != "Update" or not m or PLATFORM_MANAGED.match(m["name"]): + return {"status": "ignored"} + + service = f"{m['team']}-{m['app']}" + try: + ecs.update_service(cluster=CLUSTER, service=service, forceNewDeployment=True) + except (ecs.exceptions.ServiceNotFoundException, ecs.exceptions.ServiceNotActiveException): + logger.info("%s changed but service %s is not running; nothing to restart", name, service) + return {"status": "no-service", "service": service} + logger.info("%s changed: restarting %s", name, service) + return {"status": "restarted", "service": service} diff --git a/terraform/lambda-src/tests/test_app_secret_rollout.py b/terraform/lambda-src/tests/test_app_secret_rollout.py new file mode 100644 index 0000000..19b69b9 --- /dev/null +++ b/terraform/lambda-src/tests/test_app_secret_rollout.py @@ -0,0 +1,66 @@ +"""Tests for app_secret_rollout — run: python3 -m unittest discover terraform/lambda-src/tests""" + +import importlib.util +import os +import sys +import types +import unittest + + +class NotFound(Exception): + pass + + +class NotActive(Exception): + pass + + +class FakeECS: + def __init__(self, missing=()): + self.calls, self.missing = [], set(missing) + self.exceptions = types.SimpleNamespace(ServiceNotFoundException=NotFound, ServiceNotActiveException=NotActive) + + def update_service(self, cluster, service, forceNewDeployment): + if service in self.missing: + raise NotFound(service) + self.calls.append((cluster, service, forceNewDeployment)) + + +def load(ecs): + boto3 = types.ModuleType("boto3") + boto3.client = lambda svc, **kw: ecs + sys.modules["boto3"] = boto3 + path = os.path.join(os.path.dirname(__file__), "..", "app_secret_rollout", "handler.py") + spec = importlib.util.spec_from_file_location("app_secret_rollout_handler", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def event(name, operation="Update"): + return {"detail-type": "Parameter Store Change", "detail": {"name": name, "operation": operation}} + + +class AppSecretRolloutTest(unittest.TestCase): + def test_secret_update_restarts_the_apps_service(self): + ecs = FakeECS() + result = load(ecs).handler(event("/javabin/apps/cupcake/cupcake-backend/sp_user"), None) + self.assertEqual(result["status"], "restarted") + self.assertEqual(ecs.calls, [("javabin-platform", "cupcake-cupcake-backend", True)]) + + def test_platform_managed_and_non_update_events_are_ignored(self): + ecs = FakeECS() + h = load(ecs) + for name in ("/javabin/apps/cupcake/frosting/auth", "/javabin/apps/cupcake/frosting/cognito-client-id", + "/javabin/apps/core/api/db-master-password", "/javabin/platform/github-app-key"): + self.assertEqual(h.handler(event(name), None)["status"], "ignored") + self.assertEqual(h.handler(event("/javabin/apps/cupcake/frosting/x", "Create"), None)["status"], "ignored") + self.assertEqual(ecs.calls, []) + + def test_missing_service_is_not_an_error(self): + ecs = FakeECS(missing={"cupcake-frosting"}) + self.assertEqual(load(ecs).handler(event("/javabin/apps/cupcake/frosting/key"), None)["status"], "no-service") + + +if __name__ == "__main__": + unittest.main() diff --git a/terraform/org/identity-center.tf b/terraform/org/identity-center.tf index 44d5d4e..3da81a6 100644 --- a/terraform/org/identity-center.tf +++ b/terraform/org/identity-center.tf @@ -184,31 +184,28 @@ resource "aws_ssoadmin_permission_set_inline_policy" "developer" { "ssm:GetParameterHistory", ] Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/$${aws:PrincipalTag/team}/*" + # Only parameters the platform created and tagged for this team: + # creating one by hand would make the app's next terraform apply + # fail with ParameterAlreadyExists. + Condition = { + StringEquals = { + "aws:ResourceTag/team" = "$${aws:PrincipalTag/team}" + } + } }, # Platform-managed parameters in the team path: Cognito credentials - # (app-auth-sync) and the auth request (app Terraform). + # (app-auth-sync), the auth request (app Terraform) and RDS master + # passwords (the database keeps the original; changing it breaks the app). { Sid = "DenyPlatformManagedParameters" Effect = "Deny" - Action = ["ssm:PutParameter", "ssm:DeleteParameter", "ssm:DeleteParameters", "ssm:LabelParameterVersion"] + Action = ["ssm:PutParameter", "ssm:LabelParameterVersion"] Resource = [ "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*/*/cognito-client-*", "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*/*/auth", + "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*/*/db-master-password", ] }, - # Restart own services so tasks pick up new secret values - # (ECS resolves `secrets` only at task start). - { - Sid = "RestartOwnTeamServices" - Effect = "Allow" - Action = ["ecs:UpdateService"] - Resource = "*" - Condition = { - StringEquals = { - "aws:ResourceTag/team" = "$${aws:PrincipalTag/team}" - } - } - }, ] }) } diff --git a/terraform/platform/lambdas/main.tf b/terraform/platform/lambdas/main.tf index 7be0118..ea72d73 100644 --- a/terraform/platform/lambdas/main.tf +++ b/terraform/platform/lambdas/main.tf @@ -1871,3 +1871,98 @@ resource "aws_lambda_function" "plan_review" { } } } + +################################################################################ +# App Secret Rollout — restart an app when one of its secrets changes +# +# ECS reads task secrets only at task start. Teams can set their own secret +# values (javabin secret set) but have no ecs:UpdateService, so this Lambda +# forces a new deployment of {team}-{app} on every secret update. +################################################################################ + +data "archive_file" "app_secret_rollout" { + type = "zip" + output_path = "${path.module}/builds/app_secret_rollout.zip" + output_file_mode = "0644" + source_dir = "${local.lambda_src_path}/app_secret_rollout" +} + +resource "aws_iam_role" "app_secret_rollout" { + name = "${var.project}-app-secret-rollout" + # The org boundary denies creating any role without one + permissions_boundary = var.org_boundary_arn + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy" "app_secret_rollout" { + name = "${var.project}-app-secret-rollout" + role = aws_iam_role.app_secret_rollout.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "RestartAppServices" + Effect = "Allow" + Action = "ecs:UpdateService" + Resource = "arn:aws:ecs:${var.region}:${var.aws_account_id}:service/${var.project}-platform/*" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "app_secret_rollout_logs" { + role = aws_iam_role.app_secret_rollout.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_lambda_function" "app_secret_rollout" { + function_name = "${var.project}-app-secret-rollout" + role = aws_iam_role.app_secret_rollout.arn + handler = "handler.handler" + runtime = "python3.12" + timeout = 30 + memory_size = 128 + filename = data.archive_file.app_secret_rollout.output_path + source_code_hash = data.archive_file.app_secret_rollout.output_base64sha256 + + environment { + variables = { + PROJECT = var.project + } + } +} + +resource "aws_cloudwatch_event_rule" "app_secret_rollout_trigger" { + name = "${var.project}-app-secret-rollout-trigger" + description = "An app secret value was changed" + + event_pattern = jsonencode({ + source = ["aws.ssm"] + detail-type = ["Parameter Store Change"] + detail = { + name = [{ prefix = "/${var.project}/apps/" }] + operation = ["Update"] + } + }) +} + +resource "aws_cloudwatch_event_target" "app_secret_rollout" { + rule = aws_cloudwatch_event_rule.app_secret_rollout_trigger.name + target_id = "invoke-app-secret-rollout" + arn = aws_lambda_function.app_secret_rollout.arn +} + +resource "aws_lambda_permission" "app_secret_rollout_eventbridge" { + statement_id = "AllowEventBridge" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.app_secret_rollout.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.app_secret_rollout_trigger.arn +}