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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>`, `openssl rand -base64 48 | tr -d '\n' | gcloud secrets versions add chat-reader-secret --data-file=- --project <project>`, and `gcloud secrets add-iam-policy-binding chat-reader-secret --member serviceAccount:chat-run@<project>.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project <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@<project>.iam.gserviceaccount.com --format='value(uniqueId)' --project <project>` prints, and `email`, `chat-run@<project>.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 <project>`, `gcloud secrets create chat-anthropic-key --replication-policy automatic --project <project>`, `printf '%s' "$KEY" | gcloud secrets versions add chat-anthropic-key --data-file=- --project <project>`, and `gcloud secrets add-iam-policy-binding chat-anthropic-key --member serviceAccount:chat-run@<project>.iam.gserviceaccount.com --role roles/secretmanager.secretAccessor --project <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 <login> --justification <why> --project <project> --billing-project <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@<project>.iam.gserviceaccount.com --member user:<login> --role roles/iam.serviceAccountTokenCreator --project <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@<project>.iam.gserviceaccount.com --bucket chat-questions --location <region> --view questions --project <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-<project>/reports/<week>.md --impersonate-service-account chat-analyst@<project>.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@<project>.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:
Expand Down
1 change: 0 additions & 1 deletion deploy/google/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}

Expand Down
14 changes: 11 additions & 3 deletions deploy/google/terraform/run.tf
Original file line number Diff line number Diff line change
Expand Up @@ -143,21 +143,29 @@ 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 {
name = env.key
value = env.value
}
}
# The reader secret keys a signed-in reader's month: chat-readers/<key> 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"
}
}
Expand All @@ -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" {
Expand Down
35 changes: 0 additions & 35 deletions deploy/google/terraform/signin.tf

This file was deleted.

17 changes: 11 additions & 6 deletions deploy/google/terraform/tests/signin.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."
}
}

Expand Down
2 changes: 1 addition & 1 deletion deploy/google/terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading