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-onlyHttpOnly,Secure,SameSite=Strictcookie and returns a per-session CSRF token. - Every later
/apirequest requires the server-side session; every unsafe method also requiresX-WW-CSRF. A raw API key is not a browser-authentication substitute. - The two configured code hashes select
yc-reviewerorteam-testpolicy. 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.
/healthzis liveness only./readyzconfirms 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, andGET|PUT /api/cloud/job-explorer/preferencesare admitted in this pilot. All legacy app-data routes return503 feature_not_available_in_private_pilotuntil 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=Stricthost-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
503and 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 ignoreX-Forwarded-For; the later entry and tenant policies use forwarded addresses only whenClientIpSource=google-cloud-runexplicitly 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.secretAccessoron 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:
- Serverless NEG concepts
- Regional external Application Load Balancer with Cloud Run
- Cloud Run ingress restrictions
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 /healthzandGET /readyz:200;GET /capabilities:server-locked, with no YC/team audience disclosure;POST /api/tenant/onboard: secure cookie + audience capability response;GET /api/jobswith a valid session:503 feature_not_available_in_private_pilot;POST /api/cloud/study-guidewithout CSRF:403; with session, CSRF, consent, and configured provider: application response; the fourth request by one tenant inside one minute returns429;POST /api/cloud/job-explorer/queryreturns onlyIsDemo=truesample rows and never persists the submitted filters;PUT /api/cloud/job-explorer/preferencespersists only the bounded query under the cookie-resolved tenant; a second tenant cannot read it;- a single client-supplied
X-Forwarded-Forvalue is ignored.ClientIpSource=google-cloud-runtrusts 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 historyor 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.