Skip to content

Cloud Run server/API-key edition

Status: private-pilot candidate (WING-193). This is not production authorization to deploy.

The Windows desktop edition remains the default. With no WorkWingman__Edition=server, the API still binds to loopback, writes its per-launch Electron token, and keeps all local-first paths and behavior. The container explicitly selects the separate server edition.

Security and runtime contract

  • The same-origin browser redeems an audience code at /api/tenant/onboard. The server sets a host-only HttpOnly, Secure, SameSite=Strict cookie and returns a per-session CSRF token.
  • Every later /api request requires the server-side session; every unsafe method also requires X-WW-CSRF. A raw API key is not a browser-authentication substitute.
  • The two configured code hashes select yc-reviewer or team-test policy. Audience comes from the authenticated session, never from a process-wide client flag.
  • The runtime image runs as the .NET image's non-root $APP_UID.
  • The Angular SPA and API share one origin. Hashed production assets are immutable-cacheable; HTML and dynamic responses are not.
  • /healthz is liveness only. /readyz confirms the HTTP pipeline is ready; neither reads user data nor checks paid/external providers.
  • Firestore stores invitation-redemption audit records and sessions. Each reusable audience code mints a cryptographically random tenant ID, so teammates who share one code do not share data.
  • Only the exact method/path pairs POST /api/tenant/onboard, GET /api/tenant/session, POST /api/tenant/logout, POST /api/cloud/study-guide, POST /api/cloud/job-explorer/query, and GET|PUT /api/cloud/job-explorer/preferences are admitted in this pilot. All legacy app-data routes return 503 feature_not_available_in_private_pilot until their repositories are tenant-scoped end to end. An operator cannot unlock them with configuration.
  • The SPA presents the locked scope directly. It does not load the desktop shell and then let process-wide repositories fail or leak.

Private YC and team entry model (required before activation)

Two independent entry audiences are required: yc-reviewer and team-test. They are authentication entry gates only, never administrator grants.

  • Each audience has its own high-entropy access code whose SHA-256 hash is stored as a separate Secret Manager secret, with independent version, expiry, rotation, revocation, attempt budget, and audit label.
  • The application stores only the SHA-256 code hash plus non-secret metadata. This hash is an online lookup value, not a password database: the code itself must be high entropy, rate limits apply, and rotation disables the old hash. Plaintext codes never enter source, image layers, runtime logs, or deployment commands.
  • Successful entry creates a cryptographically random opaque session and workspace. Raw workspace or tenant IDs never cross the public API. The server resolves the workspace exclusively from an HttpOnly, Secure, SameSite=Strict host-only cookie.
  • Sessions expire after the configured SessionHours (maximum seven days). The current pilot stores one bounded job-filter preference document in the server-resolved tenant workspace. It stores no résumé, profile, job-history, or provider prompt/output documents. Revoking the session deletes that private tenant workspace.
  • Optional Google sign-in may bind the existing workspace after explicit confirmation. It does not silently create, merge, or select another user's workspace.
  • The seeded sample workspace is immutable and read-only. Entering either code forks a private workspace; no two users share mutable state.
  • Every repository call requires the server-resolved workspace context. Missing context fails closed. Database policy and tests must prove no unscoped query exists.
  • YC and team sessions carry separate audience partitions. The current provider result is returned directly and stores no prompt or generated content.
  • Entry attempts use a five-token client-IP bucket that replenishes one token every 12 seconds; responses use generic error messages. There is no progressive delay, audience-keyed limiter, or durable lockout in this candidate. Do not log IP alongside career PII.
  • Protected-route session lookups use both a 120-token process-global bucket refilling at 120 tokens per minute and a materially lower per-authenticated-session bucket before Firestore is read. The global bucket protects the shared dependency; the per-session bucket prevents one signed handle from monopolizing it. Authenticated partition state is explicitly capped, and new authenticated session partitions are denied without allocation with 503 and a 60-second retry hint when that cap is full. The hint is the capacity recheck cadence, not a promise that admission will succeed: a capacity sweep runs no more than once per 60 seconds and evicts only partitions that are both idle and fully refilled, so an attacker cannot reset a depleted session bucket by forcing eviction. Store-validation failures retain the already-spent permits and advertise the larger of one minute and the configured process-global store refill period, preventing the response itself from encouraging retries that hammer a failing dependency. These ceilings intentionally ignore X-Forwarded-For; the later entry and tenant policies use forwarded addresses only when ClientIpSource=google-cloud-run explicitly selects the reviewed load-balancer boundary.
  • Study-guide generation is limited to three requests per authenticated tenant per minute. A Firestore-backed per-tenant UTC-day reservation counter also refuses requests whose configured worst-case cost would cross MaximumDailySpendUsd; the prompt is not sent when refused.
  • State-changing browser requests require an anti-CSRF token bound to the session in addition to SameSite cookies. Rotate the session after entry and Google binding; cap idle and absolute age.

The current codebase has a tenant-aware session boundary, isolated workspace identifiers, one stateless provider path, and a synthetic read-only job catalog with tenant-isolated saved filter preferences. Every sample job is marked IsDemo, carries no application link, and never reads a desktop repository. It does not claim the rest of the desktop application's repositories are multi-user safe; those routes remain mechanically locked.

Runtime capabilities and YC exclusions

GET /capabilities is a fixed, content-free pre-auth response. It reports server-locked and cannot reveal or select an audience. After authentication, /api/tenant/session returns the session-derived audience capabilities; the frontend never infers edition from a URL or build flag.

The YC audience returns excluded with reason Not included in this YC test for music generation, AI image generation, image enhancement/upscaling, and image-to-video. An excluded capability is a non-error informational state: the client must not request its provider key or dispatch its API. Desktop retains its normal capability rules. Team-test sessions use a distinct audience, but non-study provider features remain locked in this first private pilot.

Excluded features are omitted from YC cost estimates and finance-ledger spend. A content-free blocked-attempt counter is allowed; user content is not.

Secret and IAM preparation

Use a dedicated project and service account. The runtime service account needs only:

  • roles/secretmanager.secretAccessor on the two invitation-hash secrets, the session-handle authentication key, and provider-key secret;
  • the narrow Firestore runtime role required to read/write the configured database;
  • no project editor/owner role;
  • no Cloud Storage, database, browser automation, package installation, or deployment role.

An operator with separately approved deployment authority creates two high-entropy access codes offline, stores only each code's SHA-256 hash in Secret Manager, and distributes the plaintext code out of band. Generate a separate 32-byte random session-handle authentication key, Base64-encode it, and store it only in Secret Manager. Rotation intentionally invalidates all existing sessions. The invitation values are code hashes, not browser or server API keys. Add the selected paid provider key as a separate Secret Manager secret; never echo any of these values or place them in image layers, source, deployment command arguments, or shell history.

The private pilot uses the existing Anthropic key-backed harness. Set ProviderId=anthropic, inject WorkWingman__AnthropicApiKey from Secret Manager, and optionally pin WorkWingman__Llm__Anthropic__Model. Missing, mismatched, or unreadable provider secrets keep the feature locked and must fail the post-deploy functional probe. This one key covers the pilot's text generation path; it does not replace source authentication, market-data APIs, or browser sessions.

The Cloud Run deployment shape is:

--service-account workwingman-runtime@PROJECT_ID.iam.gserviceaccount.com
--set-secrets WorkWingman__TenantSession__Invitations__0__CodeSha256=workwingman-yc-invitation-hash:latest,WorkWingman__TenantSession__Invitations__1__CodeSha256=workwingman-team-invitation-hash:latest,WorkWingman__TenantSession__SessionHandleAuthenticationKeyBase64=workwingman-session-hmac:latest,WorkWingman__AnthropicApiKey=workwingman-anthropic-api-key:latest,WorkWingman__AdzunaCredentials=workwingman-adzuna-credentials-demo:latest
--set-env-vars WorkWingman__Edition=server,WorkWingman__TenantStore__Provider=firestore,WorkWingman__TenantStore__ProjectId=PROJECT_ID,WorkWingman__TenantSession__ClientIpSource=google-cloud-run,WorkWingman__TenantSession__SessionHours=168,WorkWingman__TenantSession__Invitations__0__Audience=yc-reviewer,WorkWingman__TenantSession__Invitations__0__TenantId=ycrev,WorkWingman__TenantSession__Invitations__0__CreatePrivateWorkspacePerRedemption=true,WorkWingman__TenantSession__Invitations__0__MaximumRedemptions=25,WorkWingman__TenantSession__Invitations__1__Audience=team-test,WorkWingman__TenantSession__Invitations__1__TenantId=team,WorkWingman__TenantSession__Invitations__1__CreatePrivateWorkspacePerRedemption=true,WorkWingman__TenantSession__Invitations__1__MaximumRedemptions=25,WorkWingman__Llm__Provider=anthropic,WorkWingman__CloudProviders__StudyGuide__ProviderId=anthropic,WorkWingman__CloudProviders__StudyGuide__MaximumInputTokens=5000,WorkWingman__CloudProviders__StudyGuide__MaximumOutputTokens=4096,WorkWingman__CloudProviders__StudyGuide__InputUsdPerMillionTokens=OPERATOR_VERIFIED,WorkWingman__CloudProviders__StudyGuide__OutputUsdPerMillionTokens=OPERATOR_VERIFIED,WorkWingman__CloudProviders__StudyGuide__MaximumEstimatedCostUsd=0.10,WorkWingman__CloudProviders__StudyGuide__MaximumDailySpendUsd=1.00,WorkWingman__CloudProviders__StudyGuide__MaximumAttempts=2
--port 8080 --concurrency 1 --max-instances 1 --allow-unauthenticated
--ingress internal-and-cloud-load-balancing --no-default-url

The service must sit behind an External Application Load Balancer through one serverless NEG. The --allow-unauthenticated flag is required for this topology: an external Application Load Balancer and its serverless NEG do not authenticate end-user requests to Cloud Run. It does not make the application anonymously usable. internal-and-cloud-load-balancing ingress plus --no-default-url make the load balancer the only public transport path, while Work Wingman's invitation redemption, opaque authenticated session, CSRF, and tenant authorization remain the end-user access boundary. Do not remove the public invoker binding; that leaves the load balancer unable to invoke the service.

--max-instances 1 --concurrency 1 remains the default private-pilot boundary. The explicit EnableCloudLiveRun deploy mode keeps --max-instances 1 but uses --timeout 360s and --concurrency 8 so three synchronous GKE assignment requests and control polling are not killed by the ordinary 120-second/single-request shape. The receipt verifier enforces the selected mode. This exception does not authorize horizontal replicas: run and admission state remain process-local. Async provisioning with encrypted consume-once ticket delivery is the required scaled follow-up; until then, the mode is a bounded synchronous demo shape.

Google references for this boundary:

After every deployment, fail the rollout unless the live boundary passes:

pwsh -File ops/verify-yc-cloud-run-boundary.ps1 `
  -Service workwingman-yc -Project PROJECT_ID -Region REGION `
  -BackendService workwingman-yc-backend

This verifier reads the live Cloud Run, backend-service, and NEG resources. It requires exactly one maximum instance and the configured concurrency (one normally, eight for Cloud Live Run), internal-and-cloud-load-balancing, a disabled default run.app URL, exactly one roles/run.invoker binding for allUsers, an EXTERNAL_MANAGED backend, and a same-region serverless NEG targeting this exact service. The PowerShell wrapper is intentional deployment glue around gcloud; application and policy logic remain C#.

Create independent secret containers (these commands contain no secret values):

gcloud secrets create workwingman-yc-invitation-hash --replication-policy=automatic
gcloud secrets create workwingman-team-invitation-hash --replication-policy=automatic
gcloud secrets create workwingman-session-hmac --replication-policy=automatic
gcloud secrets create workwingman-adzuna-credentials-demo --replication-policy=automatic
gcloud secrets add-iam-policy-binding workwingman-yc-invitation-hash `
  --member="serviceAccount:workwingman-runtime@PROJECT_ID.iam.gserviceaccount.com" `
  --role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding workwingman-team-invitation-hash `
  --member="serviceAccount:workwingman-runtime@PROJECT_ID.iam.gserviceaccount.com" `
  --role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding workwingman-session-hmac `
  --member="serviceAccount:workwingman-runtime@PROJECT_ID.iam.gserviceaccount.com" `
  --role="roles/secretmanager.secretAccessor"
gcloud secrets add-iam-policy-binding workwingman-adzuna-credentials-demo `
  --member="serviceAccount:workwingman-runtime@PROJECT_ID.iam.gserviceaccount.com" `
  --role="roles/secretmanager.secretAccessor"

--set-secrets REPLACES the service's entire secret map on every deploy — it never merges. Always pass the full closed set above; a partial list silently unmounts the invitation hashes and locks every pilot user out.

Add or rotate a version using an approved hidden-input/Secret Manager console workflow:

gcloud secrets versions add workwingman-yc-invitation-hash --data-file=-
gcloud secrets versions add workwingman-team-invitation-hash --data-file=-

Never place the value directly after --data-file, in --set-env-vars, or on the command line. Generate each plaintext code with a cryptographically secure password generator, compute its lowercase SHA-256 hash locally without printing the plaintext, place only that hash in Secret Manager, and deliver the plaintext through the approved private channel. After rollout verification, disable the superseded hash independently:

gcloud secrets versions disable OLD_VERSION --secret=workwingman-yc-invitation-hash
gcloud secrets versions disable OLD_VERSION --secret=workwingman-team-invitation-hash

Project ID, region, service-account email, image digest, and secret resource name are deliberate operator inputs. This repository does not embed or guess them.

Local validation

docker build --tag workwingman-api:wing-193 .
docker run --rm --read-only --tmpfs /tmp --tmpfs /data `
  --env WorkWingman__Edition=server `
  --env WorkWingman__TenantStore__Provider=firestore `
  --env WorkWingman__TenantStore__ProjectId="<test-project>" `
  --env WorkWingman__TenantSession__ClientIpSource=google-cloud-run `
  --env WorkWingman__TenantSession__Invitations__0__Audience=yc-reviewer `
  --env WorkWingman__TenantSession__Invitations__0__TenantId=yc `
  --env WorkWingman__TenantSession__Invitations__0__CreatePrivateWorkspacePerRedemption=true `
  --env WorkWingman__TenantSession__Invitations__0__MaximumRedemptions=25 `
  --env WorkWingman__TenantSession__Invitations__0__CodeSha256="<inject-hash-outside-shell-history>" `
  --env WorkWingman__TenantSession__SessionHandleAuthenticationKeyBase64="<inject-32-byte-Base64-key-outside-shell-history>" `
  --publish 127.0.0.1:8080:8080 workwingman-api:wing-193

Expected checks:

  • GET /healthz and GET /readyz: 200;
  • GET /capabilities: server-locked, with no YC/team audience disclosure;
  • POST /api/tenant/onboard: secure cookie + audience capability response;
  • GET /api/jobs with a valid session: 503 feature_not_available_in_private_pilot;
  • POST /api/cloud/study-guide without CSRF: 403; with session, CSRF, consent, and configured provider: application response; the fourth request by one tenant inside one minute returns 429;
  • POST /api/cloud/job-explorer/query returns only IsDemo=true sample rows and never persists the submitted filters; PUT /api/cloud/job-explorer/preferences persists only the bounded query under the cookie-resolved tenant; a second tenant cannot read it;
  • a single client-supplied X-Forwarded-For value is ignored. ClientIpSource=google-cloud-run trusts only the second-to-last value of a two-address-or-longer chain produced at that explicitly configured ingress boundary; otherwise the transport peer address is used;
  • the process UID is non-zero;
  • no secret appears in docker history or image configuration.

Release blockers

Before any deployment: full security council review; resolve the repository's historical gitleaks gate finding through evidence/allowlisting rather than deletion of history; obtain a Semgrep verdict; complete a Firestore integration test against an authorized disposable project; validate the image under read-only/non-root settings; name the GCP project/region/service account/secrets; enable billing and required APIs; and receive Andrew's explicit authorization for that exact target and time window.