diff --git a/README.md b/README.md index fff11bf..d3adc66 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ A deployment on Google can check its answers: `chat.json` names `verdict: true`, A deployment on the Anthropic API can name where the API runs each request: `chat.json` names `inference_geo`, `global` for any geography Anthropic chooses or `us` for the United States only, which `infra/chat/main.tf` passes as `inference_geo = lookup(local.c, "inference_geo", null)` on either cloud, and the service sends it as `inference_geo` with every request. Left out, a request names none and the Console workspace's `default_inference_geo` decides, so a deployment that re-pins changes nothing it did not ask for. It is the Anthropic API's own setting: Vertex takes its region from the endpoint, so the deployment test and the service both refuse it with `provider` `vertex`. US-only processing is billed at 1.1 times the standard rate. A privacy page that names the country a visitor's message goes to needs it set, because `global` names none; the workspace's `allowed_inference_geos` in the Console is what makes the API refuse any other region, whatever the code sends. +A deployment on Google can sign readers in: `chat.json` names `sign_in`, an object of `issuer`, `project_id`, `org_id`, `client_id` and, where the roles carry one, `role_prefix`, and `user_month_tokens`, which `infra/chat/main.tf` passes as `sign_in = try(local.c.sign_in, null)` and `user_month_tokens = try(local.c.user_month_tokens, null)`. The module then sets the service's sign-in variables and mounts the project's secret `chat-reader-secret`, latest version, as `CHAT_READER_SECRET`. The secret is the owner's, as the Anthropic key's is, since the deploy identity holds no role in Secret Manager: before a host's first deploy with `sign_in`, the owner runs, once per project, with the value generated on the machine and never shown, `gcloud secrets create chat-reader-secret --replication-policy automatic --project `, `openssl rand -base64 48 | tr -d '\n' | gcloud secrets versions add chat-reader-secret --data-file=- --project `, and `gcloud secrets add-iam-policy-binding chat-reader-secret --member serviceAccount:chat-run@.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project `. A deploy before them fails at Cloud Run's own check, by name. Rotating it, a new version added by the second command, gives every reader a fresh month, since each reader's document is named by a key under the secret. + Five steps are the owner's, because Terraform cannot do them. Claude's terms are accepted and Sonnet 5 enabled in Vertex AI's Model Garden, once per project: no Terraform resource does it, and until it is done the first message fails as `internal` rather than as a refusal with a code, since the fence has nothing to refuse. A deployment on the Anthropic API instead makes a workspace in the Anthropic Console with a monthly spend limit, which is the hard stop Google does not give, and connects the service to it once under Settings, Workload identity, Connect workload, Google Cloud: the issuer `https://accounts.google.com`, made once for the organization; an Anthropic service account for this deployment, a member of that workspace; and a rule with audience `https://api.anthropic.com` matching the claims `sub`, the number `gcloud iam service-accounts describe chat-run@.iam.gserviceaccount.com --format='value(uniqueId)' --project ` prints, and `email`, `chat-run@.iam.gserviceaccount.com`, scope `workspace:developer`, lifetime six hundred seconds; the rule's, the organization's and the service account's ids then go into `chat.json`. Never match Google's `sub` by prefix: it is a bare number, and a prefix admits service accounts of any project. A deployment that cannot federate keeps a key instead, in the project's secret, made once with the key on stdin and never in a file: `gcloud services enable secretmanager.googleapis.com --project `, `gcloud secrets create chat-anthropic-key --replication-policy automatic --project `, `printf '%s' "$KEY" | gcloud secrets versions add chat-anthropic-key --data-file=- --project `, and `gcloud secrets add-iam-policy-binding chat-anthropic-key --member serviceAccount:chat-run@.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project `. Model Garden and the quota then do not apply. The model's quota is lowered on the project's Quotas page to a rate a chat needs and an attack does not, on the order of sixty requests and 300,000 input tokens a minute, which is the one layer that holds before this package runs a line. The module enables the Cloud Quotas API, so the same request can be filed from a terminal, `gcloud beta quotas preferences create --service aiplatform.googleapis.com --quota-id EuOnlinePredictionRequestsPerMinPerProjectPerBaseModel --dimensions base_model=anthropic-claude-sonnet --preferred-value 60 --email --justification --project --billing-project `, where the billing project keeps the request off whichever project `gcloud` happens to be set to, and a new project's first request may be refused until its billing account has history. The domain's records are set at the DNS provider from the `dns_records` output of `infra/chat/`, which the apply prints. The owner's own reading of the questions is granted once per project, with the login's address that belongs in no repository: `gcloud iam service-accounts add-iam-policy-binding chat-analyst@.iam.gserviceaccount.com --member user: --role roles/iam.serviceAccountTokenCreator --project `; a read then runs as the analyst and as nothing more, under the owner's own `gcloud auth login`, with `gcloud logging read '' --freshness=90d --impersonate-service-account chat-analyst@.iam.gserviceaccount.com --bucket chat-questions --location --view questions --project --format json`, since the flag names the account every command acts as and the freshness reaches back over the whole bucket where the default is a day, and a report with `gcloud storage cat gs://chat-reports-/reports/.md --impersonate-service-account chat-analyst@.iam.gserviceaccount.com`. Running `report` on the machine instead of in the workflow needs application-default credentials that impersonate the analyst, `gcloud auth application-default login --impersonate-service-account chat-analyst@.iam.gserviceaccount.com`, which the auth library reads and `gcloud` does not. And the chat is stopped by hand where the meter keeps it: the project's `(default)` Firestore database, document `chat/meter`, field `closed` set to `true`, which refuses the next message with `closed` and spends nothing, and back to `false` to open it again — the day and the month that document counts are UTC, so a share turns over at midnight UTC and not at midnight in Zürich. One message is sent by hand after the second merge, because the deploy's live check is a `GET /chat` and a GET proves the route, the origins, the ceiling and the MCP host and never the model, the region or the runtime's role binding: diff --git a/deploy/google/terraform/main.tf b/deploy/google/terraform/main.tf index 83c62d4..c2fdbeb 100644 --- a/deploy/google/terraform/main.tf +++ b/deploy/google/terraform/main.tf @@ -5,7 +5,6 @@ terraform { required_providers { google = { source = "hashicorp/google", version = "~> 8.0" } google-beta = { source = "hashicorp/google-beta", version = "~> 8.0" } - random = { source = "hashicorp/random", version = "~> 3.6" } } } diff --git a/deploy/google/terraform/run.tf b/deploy/google/terraform/run.tf index 1883ddf..a3cab24 100644 --- a/deploy/google/terraform/run.tf +++ b/deploy/google/terraform/run.tf @@ -143,7 +143,7 @@ resource "google_cloud_run_v2_service" "chat" { } } # Sign-in: the five values the service takes together or not at all, and the optional role - # prefix. The reader secret is the module's own (signin.tf), latest version. + # prefix. dynamic "env" { for_each = local.sign_in_env content { @@ -151,13 +151,21 @@ resource "google_cloud_run_v2_service" "chat" { value = env.value } } + # The reader secret keys a signed-in reader's month: chat-readers/ is named by the + # HMAC-SHA256 of the token's sub under it, so the meter holds nothing the identity service + # would recognize, and rotating it gives every reader a fresh month. With sign_in it rides + # in from the project's secret, latest version. The secret is the owner's, as the Anthropic + # key's is: the owner makes it, adds a random first version and grants the runtime read + # access before the deploy, as the owner's steps say. The module neither makes it nor grants + # access to it, so a deploy before the owner's three commands fails at Cloud Run's own + # check, by name. dynamic "env" { for_each = var.sign_in == null ? [] : [1] content { name = "CHAT_READER_SECRET" value_source { secret_key_ref { - secret = google_secret_manager_secret.reader_secret[0].secret_id + secret = "chat-reader-secret" version = "latest" } } @@ -172,7 +180,7 @@ resource "google_cloud_run_v2_service" "chat" { } } } - depends_on = [google_project_service.chat, google_firestore_database.meter, google_project_iam_member.chat, google_secret_manager_secret_version.reader_secret, google_secret_manager_secret_iam_member.reader_secret] + depends_on = [google_project_service.chat, google_firestore_database.meter, google_project_iam_member.chat] } resource "google_cloud_run_v2_service_iam_member" "public" { diff --git a/deploy/google/terraform/signin.tf b/deploy/google/terraform/signin.tf deleted file mode 100644 index 3e3f09d..0000000 --- a/deploy/google/terraform/signin.tf +++ /dev/null @@ -1,35 +0,0 @@ -# A signed-in reader's month: the service verifies a ZITADEL token and keeps the reader's month in -# a document of its own, chat-readers/, whose key is the HMAC-SHA256 of the token's sub under -# a secret only the service and this state hold, so the meter holds nothing the identity service -# would recognize. The secret is made here, not by the owner, because nothing outside the -# deployment ever needs its value; it exists only when the deployment signs readers in. Rotating -# it, a taint of the password and an apply, gives every reader a new key and so a fresh month. -resource "random_password" "reader_secret" { - count = var.sign_in == null ? 0 : 1 - length = 48 - special = false -} - -resource "google_secret_manager_secret" "reader_secret" { - count = var.sign_in == null ? 0 : 1 - secret_id = "chat-reader-secret" - replication { - auto {} - } - depends_on = [google_project_service.chat] -} - -resource "google_secret_manager_secret_version" "reader_secret" { - count = var.sign_in == null ? 0 : 1 - secret = google_secret_manager_secret.reader_secret[0].id - secret_data = random_password.reader_secret[0].result -} - -# Access to this one secret, not to the project's: the runtime reads nothing else in Secret Manager -# through this module. -resource "google_secret_manager_secret_iam_member" "reader_secret" { - count = var.sign_in == null ? 0 : 1 - secret_id = google_secret_manager_secret.reader_secret[0].id - role = "roles/secretmanager.secretAccessor" - member = "serviceAccount:${google_service_account.chat.email}" -} diff --git a/deploy/google/terraform/tests/signin.tftest.hcl b/deploy/google/terraform/tests/signin.tftest.hcl index 46d56b9..f42e431 100644 --- a/deploy/google/terraform/tests/signin.tftest.hcl +++ b/deploy/google/terraform/tests/signin.tftest.hcl @@ -2,7 +2,6 @@ # variables' own validation, which `terraform validate` never does. mock_provider "google" {} mock_provider "google-beta" {} -mock_provider "random" {} variables { project = "example-project" @@ -32,18 +31,24 @@ run "sign_in_with_user_month_plans" { sign_in = { issuer = "https://id.example.test", project_id = "1", org_id = "2" } user_month_tokens = 2000000 } + # The secret is the owner's: the service mounts it by name, latest version, and the module makes + # none, so the plan holds no resource of Secret Manager to assert on. assert { - condition = length(google_secret_manager_secret.reader_secret) == 1 - error_message = "sign_in makes the reader secret." + condition = [ + for e in google_cloud_run_v2_service.chat.template[0].containers[0].env : + [e.value_source[0].secret_key_ref[0].secret, e.value_source[0].secret_key_ref[0].version] + if e.name == "CHAT_READER_SECRET" + ] == [["chat-reader-secret", "latest"]] + error_message = "sign_in mounts CHAT_READER_SECRET from chat-reader-secret, latest version." } } -run "no_sign_in_makes_no_secret" { +run "no_sign_in_mounts_no_secret" { command = plan expect_failures = [check.run_host] assert { - condition = length(google_secret_manager_secret.reader_secret) == 0 - error_message = "without sign_in there is no reader secret." + condition = length([for e in google_cloud_run_v2_service.chat.template[0].containers[0].env : e if e.name == "CHAT_READER_SECRET"]) == 0 + error_message = "without sign_in there is no CHAT_READER_SECRET." } } diff --git a/deploy/google/terraform/variables.tf b/deploy/google/terraform/variables.tf index c7d310d..c13fcf6 100644 --- a/deploy/google/terraform/variables.tf +++ b/deploy/google/terraform/variables.tf @@ -90,7 +90,7 @@ variable "verdict_threshold" { # Readers who sign in at ZITADEL and choose the model. The service checks a token's issuer, the # project it was issued for and the organization the reader belongs to; role_prefix narrows which # roles count. chat.json's sign_in also holds the widget's client_id, which this object does not -# name and so drops: the service has no use for it. The reader secret is made by the module. +# name and so drops: the service has no use for it. The reader secret is the owner's (run.tf). variable "sign_in" { type = object({ issuer = string diff --git a/docs/superpowers/specs/2026-10-22-a-signed-in-reader-picks-the-model-design.md b/docs/superpowers/specs/2026-10-22-a-signed-in-reader-picks-the-model-design.md index e6a495e..706d617 100644 --- a/docs/superpowers/specs/2026-10-22-a-signed-in-reader-picks-the-model-design.md +++ b/docs/superpowers/specs/2026-10-22-a-signed-in-reader-picks-the-model-design.md @@ -96,4 +96,4 @@ ZITADEL becomes a system of each of the three mental models, and the decision to ## 11. Calls made in review -These were settled in the design's review and not with the owner, and are the owner's to overturn: `done` gains `by` rather than changing `model`; the reservation holds the effort's whole output at the model's price; the request timeout is 900 seconds where sign-in is on; ZITADEL's configuration is a second Terraform root after three one-time steps by hand; the site repositories follow their hosts in the build order; the picker is the panel's `/model` and `/effort` and sign-in its `/login` and `/logout`, since the panel is already a terminal with commands of its own; the configuration block is `sign_in`, beside the existing `CHAT_IDENTITY`; the HMAC key of the readers' documents is generated by Terraform into Secret Manager, as Läderach generates ZITADEL's masterkey; the privacy pages gain the paragraph of section 7; a long answer goes around Firebase Hosting's sixty seconds to the service's own address, if the measurement says it must. +These were settled in the design's review and not with the owner, and are the owner's to overturn: `done` gains `by` rather than changing `model`; the reservation holds the effort's whole output at the model's price; the request timeout is 900 seconds where sign-in is on; ZITADEL's configuration is a second Terraform root after three one-time steps by hand; the site repositories follow their hosts in the build order; the picker is the panel's `/model` and `/effort` and sign-in its `/login` and `/logout`, since the panel is already a terminal with commands of its own; the configuration block is `sign_in`, beside the existing `CHAT_IDENTITY`; the HMAC key of the readers' documents is the owner's, made by hand in Secret Manager with a random value generated on the machine, as the chat's other keys are, since the hosts' deploy identity holds no role in Secret Manager; the privacy pages gain the paragraph of section 7; a long answer goes around Firebase Hosting's sixty seconds to the service's own address, if the measurement says it must.