From 4fbe2d7f61e43747a25d4a64db1bb64787daacbb Mon Sep 17 00:00:00 2001 From: Eman Cickusic Date: Thu, 16 Jul 2026 15:00:19 +0200 Subject: [PATCH 1/2] Add backend abuse controls for the public Cloud Run proxy /analyze and /chat are public (allUsers run.invoker) and hit paid Vertex AI with no attestation, rate limit, or cost cap. Land the backend half without breaking the currently shipped app (which sends no token): - backend/app_check.py: Firebase App Check (Play Integrity) token verification, enforced on /analyze and /chat via a dependency. Gated OFF by default (APP_CHECK_ENABLED=false) so it is a no-op until the app sends tokens. Fails closed if enabled but misconfigured. /healthz and /version stay open. - pyjwt[crypto] added; Dockerfile copies app_check.py (repo-root context). - backend/tests/test_app_check.py: disabled-passthrough, missing/invalid/valid token, fail-closed, /chat protected, /healthz open. Full suite 28 passed. - documentation/15-backend-abuse-controls.md: exposure, mitigations, ordered rollout. - Conform to repo process: VERSION_LOG 1.0.4, module_versions.json bump, decision-log + agent-brief updated. Cloud Run --max-instances also capped 100 -> 10 (applied separately) to bound the Vertex cost/quota blast radius. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION_LOG.md | 9 ++ backend/Dockerfile | 2 +- backend/README.md | 17 +++- backend/app_check.py | 96 +++++++++++++++++++ backend/main.py | 7 +- backend/requirements.txt | 2 + backend/tests/test_app_check.py | 104 +++++++++++++++++++++ documentation/09-todo-roadmap.md | 2 +- documentation/13-agent-brief.md | 2 +- documentation/14-decision-log.md | 2 +- documentation/15-backend-abuse-controls.md | 99 ++++++++++++++++++++ documentation/README.md | 1 + module_versions.json | 4 +- 13 files changed, 334 insertions(+), 13 deletions(-) create mode 100644 backend/app_check.py create mode 100644 backend/tests/test_app_check.py create mode 100644 documentation/15-backend-abuse-controls.md diff --git a/VERSION_LOG.md b/VERSION_LOG.md index 052fd2d..e1c01b7 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -4,6 +4,15 @@ 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. + ## 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/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..f3dfc40 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" }, { From 2c010aed99a9fceb5d30a6f16a9fda22cef3d0bc Mon Sep 17 00:00:00 2001 From: Eman Cickusic Date: Fri, 17 Jul 2026 17:11:21 +0200 Subject: [PATCH 2/2] Sync module-version docs with the manifest bump verifyModuleVersionManifest (the Android build guard) failed on PR #26 because the backend-proxy bump to 1.0.4 was in module_versions.json + VERSION_LOG but not in documentation/12-module-versioning.md, which the guard requires to match. Update the doc table, and bump the documentation module to 1.0.1 for the added abuse-controls doc. Verified locally: ./gradlew :app:verifyModuleVersionManifest -> BUILD SUCCESSFUL. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION_LOG.md | 1 + documentation/12-module-versioning.md | 4 ++-- module_versions.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/VERSION_LOG.md b/VERSION_LOG.md index e1c01b7..fa98d3d 100644 --- a/VERSION_LOG.md +++ b/VERSION_LOG.md @@ -12,6 +12,7 @@ Date: 2026-07-16 - 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 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/module_versions.json b/module_versions.json index f3dfc40..9977d00 100644 --- a/module_versions.json +++ b/module_versions.json @@ -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" }, {