@caelo-cms/provisioning 0.10.50 → 0.10.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/adapter.d.ts +4 -6
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/admin-internal.d.ts +41 -0
  4. package/dist/admin-internal.d.ts.map +1 -0
  5. package/dist/admin-internal.js +169 -0
  6. package/dist/admin-internal.js.map +1 -0
  7. package/dist/agent-db.js +89 -20
  8. package/dist/aws-task-roles.d.ts +27 -0
  9. package/dist/aws-task-roles.d.ts.map +1 -0
  10. package/dist/aws-task-roles.js +35 -0
  11. package/dist/aws-task-roles.js.map +1 -0
  12. package/dist/cli.js +34 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +2 -2
  15. package/dist/compose.js.map +1 -1
  16. package/dist/deployed-release.d.ts +3 -2
  17. package/dist/deployed-release.d.ts.map +1 -1
  18. package/dist/deployed-release.js +12 -5
  19. package/dist/deployed-release.js.map +1 -1
  20. package/dist/gateway-credentials.d.ts.map +1 -1
  21. package/dist/gateway-credentials.js +6 -3
  22. package/dist/gateway-credentials.js.map +1 -1
  23. package/dist/gcloud.d.ts +2 -2
  24. package/dist/gcloud.d.ts.map +1 -1
  25. package/dist/gcloud.js +3 -3
  26. package/dist/gcloud.js.map +1 -1
  27. package/dist/infra-contract.d.ts +1 -1
  28. package/dist/infra-contract.d.ts.map +1 -1
  29. package/dist/infra-contract.js +6 -1
  30. package/dist/infra-contract.js.map +1 -1
  31. package/dist/owner-reset.d.ts +37 -0
  32. package/dist/owner-reset.d.ts.map +1 -0
  33. package/dist/owner-reset.js +80 -0
  34. package/dist/owner-reset.js.map +1 -0
  35. package/dist/stack-contract.d.ts +9 -10
  36. package/dist/stack-contract.d.ts.map +1 -1
  37. package/dist/stack-contract.js +21 -10
  38. package/dist/stack-contract.js.map +1 -1
  39. package/dist/staging-admin.d.ts +12 -20
  40. package/dist/staging-admin.d.ts.map +1 -1
  41. package/dist/staging-admin.js +7 -159
  42. package/dist/staging-admin.js.map +1 -1
  43. package/dist/wizards/gcp-cost.js +1 -1
  44. package/package.json +2 -2
  45. package/src/adapter.ts +4 -6
  46. package/src/admin-internal.ts +212 -0
  47. package/src/aws-task-roles.ts +58 -0
  48. package/src/checkpoint-secrets.test.ts +0 -1
  49. package/src/cli.ts +41 -1
  50. package/src/compose.ts +2 -2
  51. package/src/deployed-release.test.ts +22 -4
  52. package/src/deployed-release.ts +14 -7
  53. package/src/gateway-credentials.ts +6 -3
  54. package/src/gateway-env-isolation.test.ts +212 -0
  55. package/src/gcloud.ts +5 -3
  56. package/src/index.test.ts +6 -6
  57. package/src/infra-contract.test.ts +4 -3
  58. package/src/infra-contract.ts +6 -1
  59. package/src/owner-reset.test.ts +165 -0
  60. package/src/owner-reset.ts +106 -0
  61. package/src/runtime-secrets.test.ts +25 -8
  62. package/src/stack-contract.test.ts +9 -18
  63. package/src/stack-contract.ts +25 -16
  64. package/src/stack-converge-secrets.test.ts +45 -10
  65. package/src/stack-converge.test.ts +19 -10
  66. package/src/staging-admin.ts +18 -181
  67. package/src/wizards/gcp-cost.ts +1 -1
  68. package/stacks/aws/README.md +1 -1
  69. package/stacks/aws/index.ts +55 -43
  70. package/stacks/azure/README.md +1 -1
  71. package/stacks/azure/index.ts +8 -14
  72. package/stacks/gcp/README.md +1 -1
  73. package/stacks/gcp/index.ts +2 -5
  74. package/stacks/gcp-firebase/index.ts +0 -3
@@ -32,6 +32,12 @@ import * as aws from "@pulumi/aws";
32
32
  import * as awsx from "@pulumi/awsx";
33
33
  import * as pulumi from "@pulumi/pulumi";
34
34
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
35
+ import {
36
+ AWS_SERVICE_SECRETS,
37
+ type AwsSecret,
38
+ type AwsTaskService,
39
+ secretsReadPolicy,
40
+ } from "../../dist/aws-task-roles.js";
35
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
36
42
  import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB } from "../../dist/stack-contract.js";
37
43
 
@@ -89,9 +95,11 @@ function randomHex(bytes: number): string {
89
95
  }
90
96
 
91
97
  const postgresPassword = pulumi.secret(randomHex(32));
92
- // #613 — one password per database role, so the gateway (public_role +
93
- // gateway_role) never holds admin_role's. bootstrap.sh creates the roles
94
- // with these (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD).
98
+ // #613 — one password per database role, so the gateway (public_role only,
99
+ // #635) never holds admin_role's. bootstrap.sh creates the roles with these
100
+ // (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD). gateway_role is no longer
101
+ // any service's login; its password stays until the contract release that
102
+ // drops the role (#635).
95
103
  const publicRolePassword = pulumi.secret(randomHex(32));
96
104
  const gatewayRolePassword = pulumi.secret(randomHex(32));
97
105
  const csrfSecret = pulumi.secret(randomHex(32));
@@ -163,13 +171,6 @@ const publicDatabaseUrl = pulumi
163
171
  ([host, port, pw]) =>
164
172
  `postgresql://public_role:${pw}@${host}:${port}/cms_public?sslmode=require`,
165
173
  );
166
- // The gateway's cms_admin login (#613): grants from migration 0248 only.
167
- const gatewayDatabaseUrl = pulumi
168
- .all([db.address, db.port, gatewayRolePassword])
169
- .apply(
170
- ([host, port, pw]) =>
171
- `postgresql://gateway_role:${pw}@${host}:${port}/cms_admin?sslmode=require`,
172
- );
173
174
 
174
175
  // === 4. S3 buckets ===
175
176
  const mediaBucket = new aws.s3.BucketV2(`${namePrefix}-media`, {
@@ -349,38 +350,48 @@ const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
349
350
  settings: [{ name: "containerInsights", value: "enabled" }],
350
351
  });
351
352
 
352
- const taskRole = new aws.iam.Role(`${namePrefix}-task-role`, {
353
- assumeRolePolicy: JSON.stringify({
354
- Version: "2012-10-17",
355
- Statement: [
356
- {
357
- Effect: "Allow",
358
- Principal: { Service: "ecs-tasks.amazonaws.com" },
359
- Action: "sts:AssumeRole",
360
- },
361
- ],
362
- }),
363
- });
353
+ // One task role per service (#635): the gateway's may read only the
354
+ // public_role password, never a secret that unlocks a cms_admin login
355
+ // (src/aws-task-roles.ts AWS_SERVICE_SECRETS, asserted by
356
+ // gateway-env-isolation.test.ts). The admin keeps the logical names the
357
+ // former shared role had, so upgrading replaces nothing on its side.
358
+ const secretArns: Record<AwsSecret, pulumi.Output<string>> = {
359
+ "postgres-password": pgSecret.arn,
360
+ "public-role-password": publicRoleSecret.arn,
361
+ "gateway-role-password": gatewayRoleSecret.arn,
362
+ "csrf-secret": csrfSecretRes.arn,
363
+ "cookie-secret": cookieSecretRes.arn,
364
+ "anthropic-api-key": anthropicSecretRes.arn,
365
+ "resend-api-key": resendSecretRes.arn,
366
+ };
364
367
 
365
- new aws.iam.RolePolicy(`${namePrefix}-task-secrets`, {
366
- role: taskRole.id,
367
- policy: pulumi
368
- .all([
369
- pgSecret.arn,
370
- publicRoleSecret.arn,
371
- gatewayRoleSecret.arn,
372
- csrfSecretRes.arn,
373
- cookieSecretRes.arn,
374
- anthropicSecretRes.arn,
375
- resendSecretRes.arn,
376
- ])
377
- .apply((arns) =>
378
- JSON.stringify({
379
- Version: "2012-10-17",
380
- Statement: [{ Effect: "Allow", Action: "secretsmanager:GetSecretValue", Resource: arns }],
381
- }),
382
- ),
383
- });
368
+ function taskRole(service: AwsTaskService): aws.iam.Role {
369
+ const prefix = service === "admin" ? namePrefix : `${namePrefix}-${service}`;
370
+ const role = new aws.iam.Role(`${prefix}-task-role`, {
371
+ assumeRolePolicy: JSON.stringify({
372
+ Version: "2012-10-17",
373
+ Statement: [
374
+ {
375
+ Effect: "Allow",
376
+ Principal: { Service: "ecs-tasks.amazonaws.com" },
377
+ Action: "sts:AssumeRole",
378
+ },
379
+ ],
380
+ }),
381
+ });
382
+ new aws.iam.RolePolicy(`${prefix}-task-secrets`, {
383
+ role: role.id,
384
+ policy: pulumi
385
+ .all(AWS_SERVICE_SECRETS[service].map((name) => secretArns[name]))
386
+ .apply(secretsReadPolicy),
387
+ });
388
+ return role;
389
+ }
390
+
391
+ const taskRoles: Record<AwsTaskService, aws.iam.Role> = {
392
+ admin: taskRole("admin"),
393
+ gateway: taskRole("gateway"),
394
+ };
384
395
 
385
396
  const execRole = new aws.iam.Role(`${namePrefix}-exec-role`, {
386
397
  assumeRolePolicy: JSON.stringify({
@@ -421,7 +432,6 @@ const dnsRecordsRequired: DnsRecord[] = [
421
432
  const out: CloudAdapterOutputs = {
422
433
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
423
434
  publicDatabaseUrl: publicDatabaseUrl as unknown as string,
424
- gatewayDatabaseUrl: gatewayDatabaseUrl as unknown as string,
425
435
  mediaStorageUrl: pulumi.interpolate`s3://${mediaBucket.bucket}` as unknown as string,
426
436
  mediaCdnBaseUrl:
427
437
  pulumi.interpolate`https://${distribution.domainName}/media` as unknown as string,
@@ -436,7 +446,6 @@ const out: CloudAdapterOutputs = {
436
446
 
437
447
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
438
448
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
439
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
440
449
  export const mediaStorageUrlOut = out.mediaStorageUrl;
441
450
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
442
451
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -448,6 +457,9 @@ export const cloudfrontDomainOut = distribution.domainName;
448
457
  export const redirectKvsArnOut = redirectKvs.arn;
449
458
  export const ecsClusterArnOut = cluster.arn;
450
459
  export const rdsAddressOut = db.address;
460
+ // The task roles the admin-side and gateway task definitions run as.
461
+ export const adminTaskRoleArnOut = taskRoles.admin.arn;
462
+ export const gatewayTaskRoleArnOut = taskRoles.gateway.arn;
451
463
 
452
464
  // Note: the bootstrap token (and other secrets like postgres-password,
453
465
  // CSRF/cookie secrets, Anthropic key) are stored in Pulumi's encrypted
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on Azure managed services. Implements the shared
10
10
  | Blob storage | Storage Account with a private `media` container and static-website hosting (`$web`) |
11
11
  | CDN / Edge | Azure Front Door Standard: the primary domain serves the static website as files, with a Front Door managed certificate; no edge compute on the request path |
12
12
  | Container runtime | Four Container Apps (admin / gateway / orchestrator / runner) in one managed environment |
13
- | Secret store | Key Vault (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway container gets `GATEWAY_DATABASE_URL` + `PUBLIC_DATABASE_URL`, never an admin credential (#613). |
13
+ | Secret store | Key Vault (postgres-password, public-role-password, gateway-role-password, csrf-secret, cookie-secret, anthropic-api-key, resend-api-key). The gateway container gets `PUBLIC_DATABASE_URL` only, never a cms_admin credential of any role (#613, #635). |
14
14
  | Edge-log sink | Log Analytics workspace receives Container Apps logs (queryable by P12A analytics plugin via Azure Monitor) |
15
15
 
16
16
  The Caelo runtime never knows it's on Azure — every Container App consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
@@ -81,9 +81,11 @@ function randomHex(bytes: number): string {
81
81
  }
82
82
 
83
83
  const postgresPassword = pulumi.secret(randomHex(32));
84
- // #613 — one password per database role, so the gateway (public_role +
85
- // gateway_role) never holds admin_role's. bootstrap.sh creates the roles
86
- // with these (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD).
84
+ // #613 — one password per database role, so the gateway (public_role only,
85
+ // #635) never holds admin_role's. bootstrap.sh creates the roles with these
86
+ // (PUBLIC_ROLE_PASSWORD / GATEWAY_ROLE_PASSWORD). gateway_role is no longer
87
+ // any service's login; its password stays until the contract release that
88
+ // drops the role (#635).
87
89
  const publicRolePassword = pulumi.secret(randomHex(32));
88
90
  const gatewayRolePassword = pulumi.secret(randomHex(32));
89
91
  const csrfSecret = pulumi.secret(randomHex(32));
@@ -162,14 +164,11 @@ const adminDatabaseUrl = pulumi
162
164
  const publicAdminDatabaseUrl = pulumi
163
165
  .all([pgServer.fullyQualifiedDomainName, postgresPassword])
164
166
  .apply(([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_public?sslmode=require`);
165
- // The gateway's two logins (#613): public_role on cms_public and
166
- // gateway_role on cms_admin, each with its own password.
167
+ // The gateway's only login (#613, #635): public_role on cms_public. It has
168
+ // no cms_admin login at all (CLAUDE.md §2).
167
169
  const publicDatabaseUrl = pulumi
168
170
  .all([pgServer.fullyQualifiedDomainName, publicRolePassword])
169
171
  .apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?sslmode=require`);
170
- const gatewayDatabaseUrl = pulumi
171
- .all([pgServer.fullyQualifiedDomainName, gatewayRolePassword])
172
- .apply(([host, pw]) => `postgresql://gateway_role:${pw}@${host}:5432/cms_admin?sslmode=require`);
173
172
 
174
173
  // === 4. Storage account + containers ===
175
174
  const storage = new azure.storage.StorageAccount(`${namePrefix}-st`, {
@@ -419,10 +418,7 @@ const adminApp = containerApp({
419
418
  });
420
419
  const _gatewayApp = containerApp({
421
420
  serviceName: "gateway",
422
- databaseEnv: [
423
- { name: "GATEWAY_DATABASE_URL", value: gatewayDatabaseUrl },
424
- { name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl },
425
- ],
421
+ databaseEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
426
422
  });
427
423
  const _orchestratorApp = containerApp({
428
424
  serviceName: "orchestrator",
@@ -446,7 +442,6 @@ const dnsRecordsRequired = pulumi
446
442
  const out: CloudAdapterOutputs = {
447
443
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
448
444
  publicDatabaseUrl: publicDatabaseUrl as unknown as string,
449
- gatewayDatabaseUrl: gatewayDatabaseUrl as unknown as string,
450
445
  mediaStorageUrl:
451
446
  pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media` as unknown as string,
452
447
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
@@ -460,7 +455,6 @@ const out: CloudAdapterOutputs = {
460
455
 
461
456
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
462
457
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
463
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
464
458
  export const mediaStorageUrlOut = out.mediaStorageUrl;
465
459
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
466
460
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -10,7 +10,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
10
10
  | Blob storage | Two GCS buckets (`<project>-caelo-<env>-media`, `<project>-caelo-<env>-static`) with uniform bucket-level access |
11
11
  | CDN | Global HTTPS load balancer + Cloud CDN backend bucket: `<domain>` serves the static bucket as files, `/api/*` goes to the gateway, `admin.<domain>` to the IAP-gated admin |
12
12
  | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
13
- | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret, public-role-password and gateway-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only the two gateway role passwords (#613); `gateway_role` itself is created by migration 0248 and gets its password from the CLI after migrations. |
13
+ | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret, public-role-password and gateway-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only `public-role-password` (#613, #635: the gateway has no cms_admin login); `gateway_role`, created by migration 0248, is no service's login any more. |
14
14
  | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
15
15
 
16
16
  The Caelo runtime never knows it's on GCP — every Cloud Run service consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
@@ -662,7 +662,6 @@ const envContractInputs = {
662
662
  admin: dbUrls.admin,
663
663
  publicAdmin: dbUrls.publicAdmin,
664
664
  public: dbUrls.public,
665
- gateway: dbUrls.gateway,
666
665
  },
667
666
  } as const;
668
667
 
@@ -753,8 +752,8 @@ const gatewaySvc = cloudRunService({
753
752
  minInstances: gatewayMinInstances,
754
753
  maxInstances: 100,
755
754
  memory: "512Mi",
756
- // public_role on cms_public + gateway_role on cms_admin, each with its
757
- // own password; never admin_role (#613, CLAUDE.md §2).
755
+ // public_role on cms_public only: no cms_admin login of any role
756
+ // (#613, #635, CLAUDE.md §2).
758
757
  contractEnv: gatewayEnvContract(envContractInputs),
759
758
  });
760
759
 
@@ -1158,7 +1157,6 @@ const out: CloudAdapterOutputs = {
1158
1157
  // Password-less — the password lives only in Secret Manager.
1159
1158
  adminDatabaseUrl: dbUrls.admin as unknown as string,
1160
1159
  publicDatabaseUrl: dbUrls.public as unknown as string,
1161
- gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
1162
1160
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
1163
1161
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
1164
1162
  bootstrapUrl:
@@ -1172,7 +1170,6 @@ const out: CloudAdapterOutputs = {
1172
1170
 
1173
1171
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
1174
1172
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
1175
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
1176
1173
  export const mediaStorageUrlOut = out.mediaStorageUrl;
1177
1174
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
1178
1175
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -606,7 +606,6 @@ const envContractInputs = {
606
606
  admin: dbUrls.admin,
607
607
  publicAdmin: dbUrls.publicAdmin,
608
608
  public: dbUrls.public,
609
- gateway: dbUrls.gateway,
610
609
  },
611
610
  } as const;
612
611
 
@@ -988,7 +987,6 @@ const out: CloudAdapterOutputs = {
988
987
  // Password-less — the password lives only in Secret Manager.
989
988
  adminDatabaseUrl: dbUrls.admin as unknown as string,
990
989
  publicDatabaseUrl: dbUrls.public as unknown as string,
991
- gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
992
990
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
993
991
  // Firebase Hosting serves media via its own CDN; we proxy through
994
992
  // the Firebase site's apex domain for canonical URLs.
@@ -1005,7 +1003,6 @@ const out: CloudAdapterOutputs = {
1005
1003
 
1006
1004
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
1007
1005
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
1008
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
1009
1006
  export const mediaStorageUrlOut = out.mediaStorageUrl;
1010
1007
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
1011
1008
  export const bootstrapUrlOut = out.bootstrapUrl;