Skip to content
Open
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
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ terraform/platform/
variables.tf Root variables
outputs.tf Exported values
backend.tf S3 + DynamoDB state backend
networking/ VPC, subnets, NAT, SGs
networking/ VPC, subnets, NAT, SGs, javabin.internal Cloud Map namespace
ingress/ ALB, ACM, Route53
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, app-secret-rollout
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, app-peers-sync
identity/ Cognito user pools (internal + external), auth-claims trigger, auth memberships table. Identity Center is in terraform/org/
```

Expand Down Expand Up @@ -137,6 +137,7 @@ terraform/state/
| `terraform/modules/service-queue/` | SQS queue + DLQ with IAM policy output |
| `terraform/modules/service-alarm/` | CloudWatch alarms for ECS service |
| `terraform/modules/cognito-app-client/` | Requests a Cognito app client via SSM; the `app-auth-sync` Lambda creates it |
| `terraform/modules/app-network/` | Per-app task SG, Cloud Map name `{team}-{app}.javabin.internal`, app-to-app request via SSM; the `app-peers-sync` Lambda wires the edges |

### GitHub Actions Workflows
```
Expand Down Expand Up @@ -175,6 +176,7 @@ terraform/state/
| `app-auth-sync` | Creates/deletes Cognito app clients requested by app.yaml `auth:` and `routing.login` (SSM request → client + credentials + allowed groups) |
| `auth-claims` | Pre-token trigger: registry groups → `cognito:groups` (internal pool only), enforces per-client allowed groups; runs as `auth-claims-external` on the external pool (in `identity/`) |
| `plan-review` | LLM review of app Terraform plans with platform credentials; writes `risk.json` (invoked by app tf-plan) |
| `app-peers-sync` | App-to-app calls from app.yaml `calls:`/`provides:` (SSM `network` request → SG rules, Cognito M2M clients + scopes, caller's peer URL/credential params; daily ownership audits) |
| `app-secret-rollout` | Restarts `{team}-{app}` when one of its secrets in `/javabin/apps/` changes (ECS reads secrets only at task start) |

### Scripts
Expand Down Expand Up @@ -265,6 +267,9 @@ All parameters are in `eu-central-1`. Use `--profile javabin --region eu-central
| `/javabin/apps/{team}/{app}/cognito-client-{id,secret,identity-provider}` | String / SecureString / String | Written by app-auth-sync (only on change; a change restarts the app), read by ECS execution role |
| `/javabin/apps/{team}/{app}/auth-status` | String (JSON) | Written by app-auth-sync, read by the team and `scripts/auth-doctor.py` |
| `/javabin/platform/google-oauth/internal/{client-id,client-secret}` | String / SecureString | Internal pool's Google OIDC IdP (identity TF; IdP is created only when both exist) |
| `/javabin/apps/{team}/{app}/network` | String | app.yaml `calls:`/`provides:` request (written by app TF, read by app-peers-sync) |
| `/javabin/apps/{team}/{app}/peer-{t}-{a}-{url,scopes}`, `m2m-client-{id,secret}` | String / SecureString | Written by app-peers-sync, read by ECS execution role |
| `/javabin/apps/{team}/{app}/peers-status` | String | Written by app-peers-sync: state of the app's calls and consumers |

## GCP Connection

Expand Down Expand Up @@ -319,7 +324,7 @@ Team group OWNERs are admin-managed and never removed by the sync; [email protected]
- **ECS deploy stabilization**: platform-test-app task registers but service fails health check
- **Cognito**: pools deployed; internal pool's `GoogleSAML` IdP was created outside Terraform. It is replaced by the Terraform-managed Google OIDC IdP at cutover (docs/cognito-google-setup.md), then deleted
- **SSO sessions carry no `team` tag**: the Identity Center ABAC attribute (`${path:enterprise.team}`) is never set on users, so `${aws:PrincipalTag/team}` is empty for humans. Per-team permission sets (`javabin-team-{team}`) therefore use `javabin-team-{team}-guardrails` (the developer boundary rendered with the team name) instead. The ABAC-based `javabin-developer` permission set in `terraform/org` cannot reach team resources
- **App databases share one network path**: service-rds allows ingress from the shared ECS tasks SG, so every team's tasks can reach every team's DB port (password still required). Fix: per-app security groups
- **App databases share one network path** (fixed by `app-network`): service-rds now allows ingress only from the app's own `{team}-{app}-tasks` SG; each app moves off the shared ECS tasks SG on its next pipeline run
- **Team provisioner Lambda**: All sync functions working (Google/GitHub/Budget/Cognito/Identity Center). Password-set flow deployed.
- **`registered_app_repos` manually managed**: Being replaced with team-scoped IAM roles (repo→team resolved via GitHub API at runtime)
- **Cost allocation tags**: `team`, `service`, `environment`, `managed-by`, `repo` are active (Terraform). `created-by` and `commit` stay inactive on purpose (no cost signal)
Expand Down
116 changes: 116 additions & 0 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,118 @@ alarms:

Alarms are created only for services with `routing:`.

### calls / provides (app-to-app)

Apps can call each other privately inside the platform VPC, without going through the internet or
the public ALB. Both sides must agree: the **caller** lists the app it calls under `calls:`, and the
**callee** lists the caller under `provides.consumers:`. Either side removing its entry closes the
connection again.

```yaml
# Caller: cupcake-backend (team cupcake)
calls:
- app: moresleep # required
team: moresleep # optional, default: this app's team
scopes: [talks.read] # optional; omit for network-only
env: SP_BASE_URL # optional URL variable name, default MORESLEEP_URL
```

```yaml
# Callee: moresleep (team moresleep)
provides:
scopes: # optional; scope: description (a list of names also works)
talks.read: Read talks and speakers
talks.write: Create and update talks
consumers: # who may call this app
- app: cupcake-backend
team: cupcake # optional, default: this app's team
scopes: [talks.read] # optional; must be listed in provides.scopes
```

**What the platform does**

- Every app (with or without `calls`/`provides`) gets its own security group `{team}-{name}-tasks`
and a private DNS name `{team}-{name}.javabin.internal` (Cloud Map). The ALB can reach only the
app's `compute.port`; nothing else can, until a consumer is allowed.
- When both sides agree, the platform Lambda `javabin-app-peers-sync` opens the callee's port to the
caller's security group, usually within a minute of the second apply.
- **Scopes** (optional) are OAuth2 client-credentials scopes in the internal Cognito pool. The callee
gets a resource server `{team}-{name}`; each caller that requests scopes gets one machine-to-machine
client. Granted scopes are what the caller requested **and** the callee allows for that caller.
- Routing is not needed on either side. An app with `provides:` and no `routing:` is reachable only
from its consumers inside the VPC.

**Injected into the caller**

| Variable | Kind | Value |
|---|---|---|
| `<env>` (default `{APP}_URL`) | secret (SSM) | `http://{team}-{app}.javabin.internal:{port}` |
| `<env>` with `_URL` → `_SCOPES` (only with `scopes`) | secret (SSM) | Scopes actually **granted** for that callee, space-separated, e.g. `moresleep-moresleep/talks.read`; `none` until something is granted |
| `PEER_AUTH_TOKEN_URL` (any call with `scopes`) | env | Token endpoint of the internal pool |
| `PEER_AUTH_CLIENT_ID`, `PEER_AUTH_CLIENT_SECRET` (any call with `scopes`) | secrets | The caller's M2M client |

**Injected into the callee** (when `provides.scopes` is set): `PEER_AUTH_ISSUER`,
`PEER_AUTH_JWKS_URL`, `PEER_AUTH_SCOPE_PREFIX` (`{team}-{name}/`).

**Calling with scopes.** Request one token per callee, with that callee's scopes variable, and cache it
until it expires (60 minutes). Skip the request while the variable is `none`:

```bash
curl -s -u "$PEER_AUTH_CLIENT_ID:$PEER_AUTH_CLIENT_SECRET" \
-d grant_type=client_credentials --data-urlencode "scope=$SP_BASE_SCOPES" "$PEER_AUTH_TOKEN_URL"
# -> {"access_token": "...", "expires_in": 3600}; send it as Authorization: Bearer <token>
```

Cognito refuses the whole token request (`invalid_scope`) if any requested scope is not granted, so
always request exactly the scopes variable, never a hard-coded list: it holds what the client really has.
When the callee grants fewer scopes (or more), the variable changes and the platform restarts the caller
so it picks up the new value. The callee
verifies the token's signature against `PEER_AUTH_JWKS_URL`, its `iss` against `PEER_AUTH_ISSUER`, and
checks that `scope` contains `${PEER_AUTH_SCOPE_PREFIX}<scope>` (Spring Security
`oauth2ResourceServer().jwt()`, `jose` in Node). One token per callee means a token can never be
replayed against another app.

**Status.** Each side can see the state of its edges:

```bash
aws ssm get-parameter --name /javabin/apps/<team>/<app>/peers-status --query Parameter.Value --output text
# calls: active | pending-consent | callee-missing | callee-invalid, with granted/denied scopes
# consumers: active | pending | caller-invalid
```

If an app's request becomes invalid (its `peers-status` shows `{"error": ...}`), the app is frozen rather
than removed: its existing rules, scopes and parameters stay, so a bad apply does not cut off its peers.
The other side can still withdraw while it is frozen. Removing the app (destroying its Terraform) removes
its edges for real.

**Security notes**

- A network-only call (no `scopes`) is **not caller authentication**. It only means "tasks with the
caller's security group can reach the port". ECS attaches security groups and registers Cloud Map
instances through its own service role, so a team that deliberately runs a task with another app's
group ID would get that app's network identity. The daily audit reports this, but does not block it.
Endpoints that change data or return anything sensitive should require scopes.
- The platform denies teams changing the private `javabin.internal` DNS zone, and the daily audit
compares its records with Cloud Map. Cloud Map service names are not enforced at creation: a squatted
`{your-team}-{app}` name makes your apply fail and is reported by the daily audit; ask the platform
team to remove it.

**First deploy.** The caller's task definition references `peer-*-url`, `peer-*-scopes` and
`m2m-client-*`, which `javabin-app-peers-sync` writes shortly after the apply that adds the call. If the
Lambda has not written them (or failed), tasks fail to start with `ResourceInitializationError` and the
deployment rolls back. Check `peers-status` and the `#javabin-infra-alerts` channel, then re-run the
deploy.

**Rules and limits**

- Names: `app` as in `name:`, `team` as in `team:`. Scope names: lowercase letters, digits, `.`, `_`,
`-`, max 40 characters. At most 20 calls, 50 consumers, 25 scopes.
- The injected names must not also be set in `environment:`, a resource `env` or `auth.env`. Two calls
to apps with the same name in different teams need an explicit `env:`.
- Traffic is plain HTTP inside the VPC. Already-issued tokens stay valid for up to 60 minutes after a
scope is withdrawn, and open connections can outlive a removed rule until they close.
- At most about 25 apps can provide scopes (Cognito resource servers per pool).

## Not supported

These fields were documented earlier but are not implemented. Using them fails `expand` with an explanation.
Expand Down Expand Up @@ -507,3 +619,7 @@ All app resources are prefixed with the team name for ABAC enforcement.
| DNS record | `{routing.host}` |
| SSM (Cognito) | `/javabin/apps/{team}/{name}/cognito-client-{id,secret}` |
| Cognito client (`routing.login`) | `{team}-{name}.login-{N}`, credentials under `/javabin/apps/{team}/{name}/login-{N}/` |
| Task security group | `{team}-{name}-tasks` |
| Private DNS name | `{team}-{name}.javabin.internal` |
| SSM (app-to-app) | `/javabin/apps/{team}/{name}/network` (request), `peer-{t}-{a}-url`, `peer-{t}-{a}-scopes`, `m2m-client-{id,secret}`, `peers-status` |
| Cognito M2M | resource server `{team}-{name}`, client `m2m.{team}-{name}` (internal pool) |
19 changes: 18 additions & 1 deletion docs/lambda-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ Idempotent: skips resources that already have a `created-by` tag (preserves orig

**Manual run:** `aws lambda invoke --function-name javabin-app-auth-sync --payload '{"action":"reconcile"}' --cli-binary-format raw-in-base64-out /dev/stdout`

## app-peers-sync

**Trigger:** EventBridge "Parameter Store Change" for `/javabin/apps/*/*/network` (Create/Update/Delete), plus a daily `{"action": "reconcile"}` at 03:45 UTC
**Purpose:** App-to-app calls declared in `app.yaml` (`calls:` / `provides:`). The request's path identifies the app (only its team can write there); the security group in the request must be that app's own (`{team}-{app}-tasks`, tag `team={team}`), so a team cannot make the Lambda open another team's group.

**Flow:** Every run recomputes everything from all requests (event payloads are ignored) → an edge exists when the caller `calls` the callee and the callee lists the caller in `consumers` → ingress rule on the callee's SG from the caller's SG on the callee port, tagged `managed-by=javabin-app-peers-sync` (rules the team added itself are never touched) → Cognito resource server `{t}-{a}` per callee with scopes and client-credentials client `m2m.{t}-{a}` per caller requesting scopes (internal pool, 60 min tokens, granted = requested ∩ allowed; no grants = OAuth disabled) → writes `peer-{t}-{a}-url`, `peer-{t}-{a}-scopes` (the scopes the caller's client really holds for that callee, or `none`), `m2m-client-id`, `m2m-client-secret` (SecureString) and `peers-status` into the caller's path, only when a value changed. Parameters, clients and servers nobody needs are deleted. An app whose request is invalid is frozen, not removed: its parameters, managed rules, resource server and client scopes stay, as long as every valid side of an edge still declares it (the valid side can still withdraw); it gets `{"error": ...}` in `peers-status`, and its peers see `callee-invalid` / `caller-invalid`.

**Audits (reconcile only, Slack `#javabin-infra-alerts`, never fixed automatically):** Cloud Map services whose name does not match their `team` tag or that have instances registered by another ECS service; records in the namespace's Route53 private zone that are not A records Cloud Map wrote for a registered instance (teams are also denied changing the zone in the developer boundary); task ENIs carrying `{t}-{a}-tasks` that belong to a different ECS service.

**Concurrency:** reserved concurrency 1.

**Manual run:** `aws lambda invoke --function-name javabin-app-peers-sync --payload '{"action":"reconcile"}' --cli-binary-format raw-in-base64-out /dev/stdout`

| SSM Parameter | Use |
|---------------|-----|
| `/javabin/slack/platform-resource-alerts-webhook` | Audit findings and errors |

## auth-claims

**Trigger:** Cognito pre-token-generation (V2) on the internal pool (`javabin-internal`), every sign-in and token refresh
Expand Down Expand Up @@ -218,7 +235,7 @@ Team roles are denied writing `risk.json`, `review.json` and `override.json` in
## 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). Cognito client parameters (`cognito-client-id`, `-secret`, `-identity-provider`) also restart the app: app-auth-sync writes them only when the value changes (replaced client, IdP cutover), so running tasks never keep a deleted client ID and a no-op reconcile restarts nothing. Ignored: `auth`, `auth-status` (rewritten every sync), `db-master-password`. A missing service is logged and skipped.
**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). Cognito client parameters (`cognito-client-id`, `-secret`, `-identity-provider`) also restart the app: app-auth-sync writes them only when the value changes (replaced client, IdP cutover), so running tasks never keep a deleted client ID and a no-op reconcile restarts nothing. Ignored: `auth`, `auth-status` (rewritten every sync), `db-master-password`, `network`, `peers-status`. Peer URLs and scopes and `m2m-client-*` (app-peers-sync) do restart the caller. A missing service is logged and skipped.

## Shared Module: pricing

Expand Down
5 changes: 3 additions & 2 deletions docs/platform-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ VPC, subnets, NAT gateway, security groups.
| Public subnets | 3 AZs, tagged `tier=public` |
| Private subnets | 3 AZs, tagged `tier=private` |
| NAT Gateway | Single NAT in first public subnet |
| SGs | `javabin-alb-sg` (80/443 inbound), `javabin-ecs-tasks-sg` (ALB-only inbound) |
| SGs | `javabin-alb-sg` (80/443 inbound), `javabin-ecs-tasks-sg` (ALB-only inbound; legacy, apps now get their own `{team}-{app}-tasks` group from `app-network`) |
| Cloud Map namespace | `javabin.internal` (private DNS, tagged `team=shared`) — app-to-app names `{team}-{app}.javabin.internal` |

**Outputs:** `vpc_id`, `public_subnet_ids`, `private_subnet_ids`, `alb_sg_id`, `ecs_tasks_sg_id`
**Outputs:** `vpc_id`, `vpc_arn`, `public_subnet_ids`, `private_subnet_ids`, `alb_sg_id`, `ecs_tasks_sg_id`, `service_discovery_namespace_{id,arn,name}`

## ingress

Expand Down
Loading
Loading