Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
```

Expand Down Expand Up @@ -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 |
Expand Down
9 changes: 8 additions & 1 deletion docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 # prompts without echo; --stdin to pipe it in
```

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:
Expand Down
5 changes: 5 additions & 0 deletions docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 47 additions & 0 deletions terraform/lambda-src/app_secret_rollout/handler.py
Original file line number Diff line number Diff line change
@@ -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<team>[a-z]{{1,12}})/(?P<app>[a-z][a-z0-9-]{{0,19}})/(?P<name>[^/]+)$")
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}
66 changes: 66 additions & 0 deletions terraform/lambda-src/tests/test_app_secret_rollout.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions terraform/org/identity-center.tf
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,40 @@ 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}/*"
# 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), 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: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",
]
},
]
})
}
Expand Down
95 changes: 95 additions & 0 deletions terraform/platform/lambdas/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading