diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 052fd2d..fa98d3d 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -4,6 +4,16 @@ This is the repo-level change log for Zest module and release versions. Every co Entries can represent coordinated product releases or independently versioned modules. +## 1.0.4 + +Date: 2026-07-16 + +- Added backend abuse controls for the public Cloud Run proxy: a Firebase App Check (Play Integrity) token verifier (`backend/app_check.py`), enforced on `/analyze` and `/chat`, gated off by default (`APP_CHECK_ENABLED=false`) so it is a no-op until the app sends tokens. +- Capped Cloud Run `--max-instances` 100 -> 10 to bound the Vertex cost/quota blast radius. +- Added `documentation/15-backend-abuse-controls.md` with exposure, mitigations, and the ordered enforcement rollout. +- No Android module change; enforcement stays off until a token-sending app build has adoption. +- Bumped `backend-proxy` to 1.0.4 and `documentation` to 1.0.1; updated `module_versions.json` and `documentation/12-module-versioning.md` to match. + ## 1.0.3 Date: 2026-07-13 diff --git a/backend/Dockerfile b/backend/Dockerfile index eb2fdd6..47d7843 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY backend/main.py backend/prompt.py module_versions.json ./ +COPY backend/main.py backend/prompt.py backend/app_check.py module_versions.json ./ COPY backend/prompts ./prompts EXPOSE 8080 diff --git a/backend/README.md b/backend/README.md index e59d5ce..4f21dc0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -117,6 +117,9 @@ On model/auth/timeout failure the service returns HTTP 502 with | `GEMINI_CHAT_MAX_OUTPUT_TOKENS` | `2048` | Result-chat output cap (shared by thinking + answer) | | `ENABLE_OPENAPI_DOCS` | `false` | Enables `/docs`, `/redoc`, and `/openapi.json` only for local/dev use | | `CORS_ALLOWED_ORIGINS` | empty | Comma-separated browser origins allowed to call the API; empty disables CORS middleware | +| `APP_CHECK_ENABLED` | `false` | When `true`, `/analyze` and `/chat` require a valid Firebase App Check token (`X-Firebase-AppCheck` header). Off = no attestation check (current shipped-app posture). | +| `FIREBASE_PROJECT_NUMBER` | empty | Required when `APP_CHECK_ENABLED=true`; the project number (`894254677159`) used to validate the token audience/issuer. | +| `APP_CHECK_JWKS_URL` | Firebase JWKS | Override the App Check public-key endpoint (testing only). | | `PORT` | `8080` | Server port (set by Cloud Run) | ## Local development @@ -192,10 +195,16 @@ Deploy from the repository root so the root `Dockerfile` and root `module_versio ## Security notes -- **Known deferred risk:** `--allow-unauthenticated` leaves `/analyze` and `/chat` publicly callable. This is - accepted only as a temporary testing or limited-rollout posture. Before broad production launch, - add real abuse controls such as Firebase App Check / Play Integrity, Firebase Auth, API Gateway - + IAM, Cloud Armor, per-device quotas, or another approved mechanism. +- **Public access is still `--allow-unauthenticated`.** `/analyze` and `/chat` remain publicly callable at + the Cloud Run IAM layer (removing this now would break the shipped app, which sends no credential). + Two mitigations are in place, and the plan to close the gap is tracked in + `documentation/15-backend-abuse-controls.md`: + - **Blast radius capped:** Cloud Run `--max-instances=10` bounds how many paid Vertex calls a flood can + trigger. This limits cost/quota damage; it does not authenticate callers. + - **App Check verifier present but gated OFF** (`APP_CHECK_ENABLED=false`). `app_check.py` verifies a + Firebase App Check (Play Integrity) token when enabled. It stays a no-op until the Android app ships + tokens and the flag is flipped — see the rollout order in the doc above. This is the app-layer + attestation control; it works alongside `--allow-unauthenticated` at the IAM layer. - **No static mobile secret:** do not protect these endpoints with a secret compiled into the Android app; it can be extracted and replayed. - **Docs are disabled by default.** Set `ENABLE_OPENAPI_DOCS=true` only for local development or diff --git a/backend/app_check.py b/backend/app_check.py new file mode 100644 index 0000000..0c48b67 --- /dev/null +++ b/backend/app_check.py @@ -0,0 +1,96 @@ +"""Firebase App Check (Play Integrity) token verification for the Zest backend. + +This is the abuse control called for before broad rollout: verifying an App Check +token is what distinguishes a genuine, unmodified instance of the Android app from +a script hitting the public Cloud Run URL. The Android app obtains the token via the +Play Integrity provider and sends it in the `X-Firebase-AppCheck` header; this module +verifies it before `/analyze` or `/chat` reach the paid Vertex call. + +Gated OFF by default (`APP_CHECK_ENABLED`). It stays a no-op until BOTH the backend +flag is on AND the shipped app sends tokens, so it can be deployed safely ahead of +the client change without breaking the live app. See +`documentation/15-backend-abuse-controls.md` for the rollout order. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from fastapi import Header, HTTPException + +logger = logging.getLogger("ultraprocessed-ai-proxy.appcheck") + +APP_CHECK_ENABLED = os.getenv("APP_CHECK_ENABLED", "false").strip().lower() == "true" +# Firebase project number (== GCP project number when Firebase is added to this project). +FIREBASE_PROJECT_NUMBER = os.getenv("FIREBASE_PROJECT_NUMBER", "").strip() +# Stable Google endpoint serving the App Check public keys; override only for testing. +APP_CHECK_JWKS_URL = os.getenv( + "APP_CHECK_JWKS_URL", "https://firebaseappcheck.googleapis.com/v1/jwks" +) +_ISSUER_PREFIX = "https://firebaseappcheck.googleapis.com/" + +_jwks_client = None + + +def _get_jwks_client(): + """Cache the JWKS client; PyJWKClient caches the fetched keys internally.""" + global _jwks_client + if _jwks_client is None: + from jwt import PyJWKClient + + _jwks_client = PyJWKClient(APP_CHECK_JWKS_URL, cache_keys=True) + return _jwks_client + + +def verify_app_check_token(token: str) -> dict: + """Verify a Firebase App Check JWT and return its claims, or raise on any failure. + + Checks RS256 signature against Google's published keys, plus the audience and + issuer bound to this project. Monkeypatched in tests so the suite needs no + network or real tokens. + """ + import jwt + + signing_key = _get_jwks_client().get_signing_key_from_jwt(token).key + return jwt.decode( + token, + signing_key, + algorithms=["RS256"], + audience=f"projects/{FIREBASE_PROJECT_NUMBER}", + issuer=f"{_ISSUER_PREFIX}{FIREBASE_PROJECT_NUMBER}", + leeway=10, # small clock-skew tolerance + options={"require": ["exp", "iat"]}, + ) + + +def require_app_check(x_firebase_appcheck: Optional[str] = Header(default=None)) -> None: + """FastAPI dependency: no-op when disabled, enforces a valid token when enabled. + + Fails CLOSED — if enforcement is on but misconfigured, requests are denied, never + allowed through unchecked. + """ + if not APP_CHECK_ENABLED: + return + if not FIREBASE_PROJECT_NUMBER: + logger.error("APP_CHECK_ENABLED=true but FIREBASE_PROJECT_NUMBER is unset; denying request.") + raise HTTPException( + status_code=500, + detail={"error": "app_check_misconfigured", "message": "App attestation is misconfigured."}, + ) + if not x_firebase_appcheck: + raise HTTPException( + status_code=401, + detail={"error": "app_check_required", "message": "App attestation is required."}, + ) + try: + verify_app_check_token(x_firebase_appcheck) + except HTTPException: + raise + except Exception as exc: # bad signature, expired, wrong aud/iss, malformed token + logger.warning("App Check token rejected: %s", type(exc).__name__) + raise HTTPException( + status_code=401, + detail={"error": "app_check_invalid", "message": "App attestation failed."}, + ) diff --git a/backend/main.py b/backend/main.py index c5d966b..23f99c1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,10 +17,11 @@ from pathlib import Path from typing import Any, Optional -from fastapi import FastAPI, HTTPException +from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, ConfigDict, Field +from app_check import require_app_check from prompt import ( build_chat_prompt, build_full_analysis_prompt, @@ -530,7 +531,7 @@ def version() -> dict: return MODULE_VERSION_MANIFEST -@app.post("/analyze") +@app.post("/analyze", dependencies=[Depends(require_app_check)]) def analyze(req: AnalyzeRequest) -> dict: try: operation = (req.type or "").strip().lower() @@ -567,7 +568,7 @@ def analyze(req: AnalyzeRequest) -> dict: ) -@app.post("/chat") +@app.post("/chat", dependencies=[Depends(require_app_check)]) def chat(req: ChatRequest) -> dict: try: return analyze_chat(req) diff --git a/backend/requirements.txt b/backend/requirements.txt index b05fdd7..23bcddc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ pydantic>=2.9,<3.0 # Keep local and Cloud Run installs on a wheel-backed version; newer releases may require a # Rust/Cargo version unavailable in some build environments. cryptography==44.0.3 +# App Check (Play Integrity) token verification; uses the pinned cryptography above for RS256. +pyjwt[crypto]>=2.8,<3.0 diff --git a/backend/tests/test_app_check.py b/backend/tests/test_app_check.py new file mode 100644 index 0000000..5bb672a --- /dev/null +++ b/backend/tests/test_app_check.py @@ -0,0 +1,104 @@ +"""App Check enforcement tests. No network or real tokens: the verifier is monkeypatched. + +Enforcement is gated behind module-level flags on `app_check`, so tests toggle those +attributes (mirroring how test_app.py monkeypatches main.call_gemini) rather than env. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +import app_check +import main + +client = TestClient(main.app) + +ANALYZE_BODY = {"type": "analysis", "ingredient_text": "water, sugar, palm oil"} +FULL_RESPONSE = { + "nova": { + "containsConsumableFoodItem": True, + "novaGroup": 4, + "summary": "s", + "rejectionReason": "", + "confidence": 0.8, + "warnings": [], + }, + "ingredients": {"correctedIngredients": [], "ultraProcessedIngredients": [], "confidence": 0.0, "warnings": []}, + "allergens": {"allergens": [], "confidence": 0.0, "warnings": []}, +} + + +def enable(monkeypatch, project_number="894254677159"): + monkeypatch.setattr(app_check, "APP_CHECK_ENABLED", True) + monkeypatch.setattr(app_check, "FIREBASE_PROJECT_NUMBER", project_number) + + +def stub_model(monkeypatch): + calls = [] + monkeypatch.setattr(main, "call_full_analysis", lambda j: (calls.append(j), (FULL_RESPONSE, {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0}))[1]) + return calls + + +def test_disabled_by_default_no_header_needed(monkeypatch): + # Default posture: enforcement off, so the shipped app (which sends no token) still works. + assert app_check.APP_CHECK_ENABLED is False + stub_model(monkeypatch) + r = client.post("/analyze", json=ANALYZE_BODY) + assert r.status_code == 200 + + +def test_enabled_missing_header_rejected_before_model(monkeypatch): + enable(monkeypatch) + calls = stub_model(monkeypatch) + r = client.post("/analyze", json=ANALYZE_BODY) + assert r.status_code == 401 + assert r.json()["detail"]["error"] == "app_check_required" + assert calls == [] # never reached the paid Vertex call + + +def test_enabled_invalid_token_rejected_before_model(monkeypatch): + enable(monkeypatch) + calls = stub_model(monkeypatch) + + def boom(_token): + raise ValueError("bad signature") + + monkeypatch.setattr(app_check, "verify_app_check_token", boom) + r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "garbage"}) + assert r.status_code == 401 + assert r.json()["detail"]["error"] == "app_check_invalid" + assert calls == [] + + +def test_enabled_valid_token_passes(monkeypatch): + enable(monkeypatch) + stub_model(monkeypatch) + monkeypatch.setattr(app_check, "verify_app_check_token", lambda token: {"sub": "app-id"}) + r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "valid"}) + assert r.status_code == 200 + assert r.json()["type"] == "analysis" + + +def test_enabled_but_misconfigured_fails_closed(monkeypatch): + # Flag on but no project number -> deny, never fall open. + enable(monkeypatch, project_number="") + calls = stub_model(monkeypatch) + r = client.post("/analyze", json=ANALYZE_BODY, headers={"X-Firebase-AppCheck": "whatever"}) + assert r.status_code == 500 + assert r.json()["detail"]["error"] == "app_check_misconfigured" + assert calls == [] + + +def test_chat_is_also_protected(monkeypatch): + enable(monkeypatch) + called = [] + monkeypatch.setattr(main, "call_gemini", lambda p: called.append(p)) + r = client.post("/chat", json={"question": "hi", "result": {"productName": "x"}}) + assert r.status_code == 401 + assert called == [] + + +def test_healthz_stays_open_when_enabled(monkeypatch): + # Cloud Run health checks and uptime probes must not need a token. + enable(monkeypatch) + assert client.get("/healthz").status_code == 200 diff --git a/documentation/09-todo-roadmap.md b/documentation/09-todo-roadmap.md index 9c61ea7..00ded2f 100644 --- a/documentation/09-todo-roadmap.md +++ b/documentation/09-todo-roadmap.md @@ -25,7 +25,7 @@ Zest currently uses: - Create typed screen arguments for result, disclaimer, and analysis flows. - Move analysis launch side effects out of screen composition where practical. - Design privacy-safe operational diagnostics that never include OCR text, images, prompts, or model responses. -- Add production abuse controls for public backend `/analyze` and `/chat` endpoints before broad launch; App Check / Play Integrity is a candidate, but not part of the current implementation. +- Finish production abuse controls for public backend `/analyze` and `/chat` before broad launch. In progress (see [15-backend-abuse-controls.md](15-backend-abuse-controls.md)): max-instances capped, and a Firebase App Check / Play Integrity verifier is landed in the backend but gated off. Remaining: Firebase/Play setup, the Android token header, then flip `APP_CHECK_ENABLED`. - Add provider usage parsing when provider responses include reliable token/cost metadata. - Add migration tests only if a future approved feature restores local persistence. - Add UI snapshot or screenshot tests for scanner, settings, results, and error states. diff --git a/documentation/12-module-versioning.md b/documentation/12-module-versioning.md index 4e5c9b2..2ce7b7c 100644 --- a/documentation/12-module-versioning.md +++ b/documentation/12-module-versioning.md @@ -14,7 +14,7 @@ The root manifest is the production source of truth. The root version log record | Module ID | Kind | Path | Version | Runtime Surface | | --- | --- | --- | --- | --- | | `android-app` | android-gradle-module | `:app` | `1.0.2` | Android `BuildConfig.VERSION_NAME` | -| `backend-proxy` | cloud-run-service | `backend` | `1.0.2` | FastAPI `app.version` and `GET /version` | +| `backend-proxy` | cloud-run-service | `backend` | `1.0.4` | FastAPI `app.version` and `GET /version` | | `analysis` | android-package | `app/src/main/java/com/b2/ultraprocessed/analysis` | `1.0.1` | module version manifest | | `barcode` | android-package | `app/src/main/java/com/b2/ultraprocessed/barcode` | `1.0.0` | module version manifest | | `camera` | android-package | `app/src/main/java/com/b2/ultraprocessed/camera` | `1.0.0` | module version manifest | @@ -25,7 +25,7 @@ The root manifest is the production source of truth. The root version log record | `storage` | android-package | `app/src/main/java/com/b2/ultraprocessed/storage` | `1.0.0` | module version manifest | | `ui` | android-package | `app/src/main/java/com/b2/ultraprocessed/ui` | `1.0.1` | module version manifest | | `backend-prompts` | backend-contract-assets | `backend/prompts` | `1.0.3` | backend-owned prompt files | -| `documentation` | repo-documentation | `documentation` | `1.0.0` | repo documentation and handoff docs | +| `documentation` | repo-documentation | `documentation` | `1.0.1` | repo documentation and handoff docs | | `agent-context` | repo-agent-guidance | `AGENTS.md` | `1.0.0` | `AGENTS.md` and `.cursor/rules/zest-project.mdc` | ## Production Tracking Contract diff --git a/documentation/13-agent-brief.md b/documentation/13-agent-brief.md index 60f19f6..dea417a 100644 --- a/documentation/13-agent-brief.md +++ b/documentation/13-agent-brief.md @@ -26,7 +26,7 @@ Runtime flow: - Privacy: do not persist or log scan images, OCR text, ingredients, results, chat, usage, or failures. - Storage archive: former Room/history code stays under `documentation/code-archive/session_only_storage/` and must not be restored without a product privacy decision. - UI: allergens are separate from processing markers; ingredient bubbles are based on corrected ingredient names and ultra-processed marker matches. -- Production risk: public unauthenticated backend endpoints are a known deferred risk and require abuse controls before broad launch. +- Production risk: public unauthenticated backend endpoints are a known deferred risk. The backend App Check (Play Integrity) verifier is landed but gated off and `--max-instances` is capped; finish enforcement (Firebase/Play setup, Android token header, then `APP_CHECK_ENABLED=true`) before broad launch. See `documentation/15-backend-abuse-controls.md`. ## Where To Look diff --git a/documentation/14-decision-log.md b/documentation/14-decision-log.md index 817767c..7b1e18c 100644 --- a/documentation/14-decision-log.md +++ b/documentation/14-decision-log.md @@ -16,5 +16,5 @@ This file summarizes the decisions most likely to guide future agent work. Detai | Keep allergens separate from processing markers. | Active | Allergen detection is not the same signal as ultra-processing. | Do not color ingredients red because they are allergens; show allergens in their own block. | `documentation/04-classification-analysis.md`, `documentation/08-llm-api-contracts.md` | | Track module versions in the repo root. | Active | Agents and releases need an objective production version surface that is not hidden inside the backend service directory. | Update root `module_versions.json`, root `VERSION_LOG.md`, and `documentation/12-module-versioning.md` when module behavior changes. | `documentation/12-module-versioning.md`, `app/build.gradle.kts` | | Require every change to leave a summarized handoff trail. | Active | Future agents need the decision philosophy, not only the code diff. | Each change must update relevant docs, decision log, agent brief, and version log when applicable. | `AGENTS.md`, `.cursor/rules/zest-project.mdc` | -| Treat public unauthenticated backend access as deferred risk. | Open caution | Current Cloud Run deployment can be public during testing/limited rollout, but broad production needs abuse controls. | Add App Check / Play Integrity, auth/IAM gateway, Cloud Armor, quotas, rate limits, or equivalent before broad launch. | `backend/README.md`, `documentation/09-todo-roadmap.md` | +| Treat public unauthenticated backend access as deferred risk. | Open caution | Current Cloud Run deployment can be public during testing/limited rollout, but broad production needs abuse controls. | Backend half landed: App Check (Play Integrity) verifier in `backend/app_check.py`, gated off (`APP_CHECK_ENABLED=false`); `--max-instances` capped to bound cost. Remaining before broad launch: Firebase/Play setup, Android token header, then enable `APP_CHECK_ENABLED` (only after a token-sending app build has adoption, or installed apps break). | `backend/README.md`, `documentation/09-todo-roadmap.md`, `documentation/15-backend-abuse-controls.md` | | Prefer compact agent bootstrap docs over duplicated handbook content. | Active | Long-form docs already exist; agents need a fast route into current constraints. | Keep `documentation/13-agent-brief.md`, this log, `AGENTS.md`, and Cursor rules short and link outward. | `documentation/README.md` | diff --git a/documentation/15-backend-abuse-controls.md b/documentation/15-backend-abuse-controls.md new file mode 100644 index 0000000..c39774d --- /dev/null +++ b/documentation/15-backend-abuse-controls.md @@ -0,0 +1,99 @@ +# Backend Abuse Controls + +Owner: Eman Cickusic. This tracks closing the one production caution left after the GCP backend +migration: the public Cloud Run proxy (`/analyze`, `/chat`) still needs abuse controls before broad +rollout. It records the current exposure, what is already mitigated, the chosen control, and the exact +ordered steps to finish — so the cutover does not break the shipped app. + +## Current exposure (verified 2026-07-16) + +- Service `ultraprocessed-ai-proxy`, region `us-east1`, project `b2-ultra-processed` (number `894254677159`). +- Cloud Run IAM: `allUsers` → `roles/run.invoker`. Fully public. +- Ingress: `all`. No App Check, Firebase, API Gateway, or load balancer exists in the project. +- Every `/analyze` and `/chat` call triggers a **paid Vertex Gemini** call. No auth, no attestation, no rate limit. +- The proxy URL ships inside the APK (`PROXY_BASE_URL` in `app/build.gradle.kts`), so treat it as public knowledge. +- **Threat:** anyone can script the endpoints → unbounded Vertex cost and quota exhaustion (a DoS for real users). + +## Mitigations already in place + +1. **Blast radius capped.** Cloud Run `--max-instances` lowered `100 → 10` (revision `-00004`). Bounds how many + concurrent paid calls a flood can drive (~10× reduction). This limits *damage*, not *access*, and is tunable: + `gcloud run services update ultraprocessed-ai-proxy --region=us-east1 --project=b2-ultra-processed --max-instances=N`. +2. **App Check verifier landed, gated OFF.** `backend/app_check.py` + a `require_app_check` dependency now guard both + POST routes. With `APP_CHECK_ENABLED=false` (default) it is a no-op — the shipped app, which sends no token, keeps + working. When enabled it verifies a Firebase App Check token and **fails closed** if misconfigured. Covered by + `backend/tests/test_app_check.py`. + +## The chosen control: Firebase App Check with Play Integrity + +Attests that a request comes from a genuine, unmodified instance of *our* Android app — the right fit for an +anonymous, Android-only backend. Why not the alternatives: + +- **Firebase Auth** — no user accounts in this app; overkill. +- **API Gateway + IAM / a static key in the app** — any secret compiled into the APK can be extracted and replayed + (see the backend README rule). Rejected. +- **Cloud Armor** — needs an external HTTPS load balancer in front of Cloud Run (none exists), and IP rate limits are + weak behind mobile-carrier NAT. Heavier; keep only as a later option if IP/geo/WAF controls are ever needed. +- **In-process rate limiting** — Cloud Run autoscales, so per-instance memory counters don't hold; would need + Memorystore/Redis. Defer unless abuse persists *after* App Check. + +Verification implemented (per Google's "verify App Check tokens from a custom backend"): RS256 JWT sent in the +`X-Firebase-AppCheck` header, signature checked against Google's JWKS, audience `projects/894254677159`, issuer +`https://firebaseappcheck.googleapis.com/894254677159`. + +## Remaining work to turn it on — ordered, do not reorder + +Reordering breaks the live app: if the backend enforces before the shipped app sends tokens, every real user gets 401. + +1. **Firebase + Play setup** (Firebase / Play Console access required): + - Add Firebase to the existing GCP project `b2-ultra-processed` (same project number). + - Enable APIs `firebase.googleapis.com` and `firebaseappcheck.googleapis.com`. + - Register the Android app (package `com.b2.ultraprocessed`); download `google-services.json`. + - Link the app in Play Console, enable the Play Integrity API, and register the App Check Play Integrity provider. +2. **Android change** (not done here — needs `google-services.json` and the Firebase project): + - Add the Google Services Gradle plugin, Firebase BoM, `firebase-appcheck`, and `firebase-appcheck-playintegrity`. + - Initialize App Check with the Play Integrity provider in `Application.onCreate`. + - Attach the token to each proxy call. Both `ProxyFoodLabelLlmWorkflow` and `ProxyResultChatWorkflow` currently + set only `Content-Type`, so this is one header per request (coroutines-play-services is already a dependency): + ```kotlin + val token = FirebaseAppCheck.getInstance().getAppCheckToken(false).await().token + // ...Request.Builder().header("X-Firebase-AppCheck", token) + ``` + - On token-fetch failure, route through the existing "service unavailable" error path. + - Ship this build to production and let adoption build (older installs still send no token). +3. **Flip the backend flag** (after the token-sending build has meaningful adoption): + - Redeploy with `--set-env-vars APP_CHECK_ENABLED=true,FIREBASE_PROJECT_NUMBER=894254677159` (keep the `GEMINI_*` vars). + - Use App Check **monitor mode** in the Firebase console first to measure how much traffic is unverified before enforcing. +4. **Optional hardening after App Check holds:** revisit per-device quotas/rate limits only if abuse continues; + add Cloud Armor + an external LB only if IP/geo/WAF controls become necessary. + +## Cost guardrail (recommended, manual) + +Billing is enabled (account `013337-E67B0A-EFBA0E`), but budgets need the budgets API plus a billing-account role that +project-level IAM does not grant — so this is a manual step for someone with billing access: + +``` +gcloud services enable billingbudgets.googleapis.com --project=b2-ultra-processed +gcloud billing budgets create --billing-account=013337-E67B0A-EFBA0E \ + --display-name="ultraprocessed Vertex guard" \ + --budget-amount=50USD \ + --threshold-rule=percent=0.5 --threshold-rule=percent=0.9 --threshold-rule=percent=1.0 +``` + +## Least-privilege note (separate cleanup) + +The runtime service account `up-app-service@b2-ultra-processed.iam.gserviceaccount.com` holds **both** +`roles/aiplatform.user` and legacy `roles/ml.developer`. `aiplatform.user` already covers Vertex; the legacy role +looks redundant. After confirming nothing uses the legacy AI Platform API, remove it: + +``` +gcloud projects remove-iam-policy-binding b2-ultra-processed \ + --member=serviceAccount:up-app-service@b2-ultra-processed.iam.gserviceaccount.com \ + --role=roles/ml.developer +``` + +## Verify + +- Backend: `cd backend && pytest` — `tests/test_app_check.py` covers disabled-passthrough, missing/invalid/valid + token, fail-closed-when-misconfigured, `/chat` protected, and `/healthz` open. +- After enabling: `curl -X POST .../analyze` with no token → `401 app_check_required`; the app (with a valid token) → `200`. diff --git a/documentation/README.md b/documentation/README.md index e8b1b8e..496a90e 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -24,6 +24,7 @@ Repo-level agent and version entrypoints live at [../AGENTS.md](../AGENTS.md), [ - [12-module-versioning.md](12-module-versioning.md) - root module version manifest, root version log, production tracking contract, and verification rules. - [13-agent-brief.md](13-agent-brief.md) - short first-read handoff for agents and new contributors. - [14-decision-log.md](14-decision-log.md) - summarized architecture and product decisions that future agents must preserve. +- [15-backend-abuse-controls.md](15-backend-abuse-controls.md) - public proxy exposure, mitigations in place, and the ordered plan to enforce Firebase App Check / Play Integrity before broad rollout. - [performance_benchmark.md](performance_benchmark.md) - deployed backend response-time measurements, bottleneck analysis, and latency improvement plan. ## Current Product Contract diff --git a/module_versions.json b/module_versions.json index 53d0d44..9977d00 100644 --- a/module_versions.json +++ b/module_versions.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "product": "Zest", - "releaseVersion": "1.0.2", + "releaseVersion": "1.0.4", "modules": [ { "id": "android-app", @@ -14,7 +14,7 @@ "id": "backend-proxy", "kind": "cloud-run-service", "path": "backend", - "version": "1.0.2", + "version": "1.0.4", "runtimeSurface": "FastAPI app.version and GET /version" }, { @@ -91,7 +91,7 @@ "id": "documentation", "kind": "repo-documentation", "path": "documentation", - "version": "1.0.0", + "version": "1.0.1", "runtimeSurface": "repo documentation and handoff docs" }, {