diff --git a/.github/workflows/terraform-translator.yml b/.github/workflows/terraform-translator.yml new file mode 100644 index 0000000..17dc747 --- /dev/null +++ b/.github/workflows/terraform-translator.yml @@ -0,0 +1,78 @@ +name: terraform-translator + +on: + push: + paths: + - "terraform-translator/**" + - ".github/workflows/terraform-translator.yml" + pull_request: + paths: + - "terraform-translator/**" + - ".github/workflows/terraform-translator.yml" + +defaults: + run: + working-directory: terraform-translator + +jobs: + # Always runs — needs no secrets. The translator lazily imports the Anthropic + # SDK, so the offline suite runs without dependencies or an API key. + offline-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Run offline unit tests + run: python -m unittest discover -s tests -v + + # Runs only when an ANTHROPIC_API_KEY repository secret is configured. + # Generates real Terraform via the CLI and validates it with the Terraform CLI. + live-check: + runs-on: ubuntu-latest + needs: offline-tests + steps: + - uses: actions/checkout@v4 + + - name: Detect API key + id: detect + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::No ANTHROPIC_API_KEY secret set — skipping live check." + fi + + - uses: actions/setup-python@v5 + if: steps.detect.outputs.present == 'true' + with: + python-version: "3.11" + + - name: Install dependencies + if: steps.detect.outputs.present == 'true' + run: pip install -r requirements.txt + + - name: Generate Terraform from a prompt + if: steps.detect.outputs.present == 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + mkdir -p tf-check + python cli.py "a resource group named demo-rg in West Europe with a storage account" > tf-check/main.tf + echo "----- generated main.tf -----" + cat tf-check/main.tf + + - uses: hashicorp/setup-terraform@v3 + if: steps.detect.outputs.present == 'true' + + - name: Validate generated HCL + if: steps.detect.outputs.present == 'true' + working-directory: terraform-translator/tf-check + run: | + terraform fmt -check + terraform init -backend=false + terraform validate diff --git a/iac-aistudio-update/.gitlab-ci.yml b/iac-aistudio-update/.gitlab-ci.yml new file mode 100644 index 0000000..dba12d3 --- /dev/null +++ b/iac-aistudio-update/.gitlab-ci.yml @@ -0,0 +1,19 @@ +# GitLab CI — runs the security regression tests on every push/MR. +# No secrets required: the suite runs in LOCAL_DEV mode (SQLite, dummy secret). +stages: + - test + +security-tests: + stage: test + image: python:3.12-slim + variables: + LOCAL_DEV: "1" + FLASK_SECRET_KEY: "ci-test-secret" + before_script: + - pip install --no-cache-dir -r requirements-dev.txt + script: + # Run from the bundle root so `import app` and templates/ resolve. + - pytest -v + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH diff --git a/iac-aistudio-update/IMPLEMENTATION_GUIDE.md b/iac-aistudio-update/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..37b876a --- /dev/null +++ b/iac-aistudio-update/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,133 @@ +# IaC Build — security hardening update (deploy guide) + +This bundle contains a hardened `app.py` plus tests. It is a **drop-in replacement +for `app.py`** in your existing deploy bundle — nothing else in your bundle +(Dockerfile, templates, `landing.html`, `app.html`, `requirements.txt`) needs to +change, and **no new environment variables or Azure resources are required** (all +new settings have safe built-in defaults). + +> Scope note: this pass covers the changes that are pure code and low-risk to a +> live deploy. It deliberately does **not** change the model/cost profile, touch +> your SSE streaming, or add infrastructure. See "Still open" at the bottom. + +--- + +## 1. What changed (and why) + +All changes are in `app.py`. Diff it against your current file before deploying +(`diff -u old/app.py app.py`). + +| Area | Change | Security-plan item | +|------|--------|--------------------| +| **LLM model pin** | `/api/generate` now uses a **server-pinned** model (`LLM_MODEL`, default `claude-sonnet-4-6`). The client-supplied `model` field is ignored. | P1-5 | +| **OTP strength** | Codes are now **6-digit, cryptographically random** (`secrets`), stored as an **HMAC** (never plaintext), with **server-side expiry** (10 min) and **lockout** after 5 wrong attempts. Single-use. | P0-1 | +| **Password-reset authorization** | `/recover_reset` now **requires a verified OTP in the session**. Previously the reset step did not check that the code had been entered, so anyone who set `recover_email` (a plain POST to `/recover_lookup`) could POST `/recover_reset` and change another account's password. This is the most important fix here. | (gap found during review) | +| **Registration authorization** | `/profile` (account creation) likewise requires a verified OTP, so accounts can't be created for an unverified email by skipping the code step. | P0-1 related | +| **Session fixation** | `login()` and the OAuth callback now rotate the session (`session.clear()` then re-issue) via a shared `_login_user()` helper. | P1-3 | +| **Email enumeration** | `/recover_lookup` returns a **uniform** "if that account exists, a code has been sent" response whether or not the email exists. | P2 (upgrades) | +| **Session version** | `SESSION_VERSION` bumped `3 → 4`, so all pre-deploy sessions are invalidated once (everyone is logged out a single time after deploy). | — | + +New env vars (all **optional**, defaults shown): + +| Env var | Default | Purpose | +|---------|---------|---------| +| `LLM_MODEL` | `claude-sonnet-4-6` | The pinned generation model. | +| `LLM_MAX_TOKENS` | `8000` | Hard clamp on output tokens (unchanged value). | +| `LLM_DEFAULT_TOKENS` | `4000` | Default when the client omits `max_tokens` (unchanged value). | +| `OTP_TTL_SECONDS` | `600` | OTP validity window. | +| `OTP_MAX_ATTEMPTS` | `5` | Wrong attempts before lockout. | + +--- + +## 2. Test before you ship (optional but recommended) + +From the bundle root (where `app.py` and `templates/` are): + +```bash +pip install -r requirements-dev.txt +LOCAL_DEV=1 FLASK_SECRET_KEY=test-secret pytest -v +``` + +This exercises the OTP logic, the reset-authorization fix, and the model pin +with no Azure dependencies and no network calls. + +--- + +## 3. Deploy (Azure Cloud Shell) + +This follows your existing build/deploy pattern. Replace only `app.py` in the +bundle, then rebuild and roll the revision. + +```bash +# 0) Note the current image so you can roll back instantly if needed. +PREV=$(az containerapp show -n ca-iacb-web -g rg-iacb-lean \ + --query "properties.template.containers[0].image" -o tsv) +echo "rollback image: $PREV" + +# 1) Unzip your current deploy bundle (adjust the zip name/path to yours). +rm -rf ~/iacb && mkdir -p ~/iacb && unzip -o ~/your-deploy-bundle.zip -d ~/iacb + +# 2) Drop in the updated app.py (from this update bundle). +cp /path/to/iac-aistudio-update/app.py ~/iacb/app.py +cd ~/iacb + +# 3) Build the image in ACR (Dockerfile base must already exist in ACR: +# FROM acriacblean.azurecr.io/python:3.12-slim-bookworm). +TAG=$(date +%Y%m%d-%H%M) +az acr build --registry acriacblean --image iacb-web:$TAG . + +# 4) Roll the Container App to the new image. +az containerapp update -n ca-iacb-web -g rg-iacb-lean \ + --image "acriacblean.azurecr.io/iacb-web:$TAG" +``` + +Optional — override any of the new settings (not required; defaults are baked in): + +```bash +az containerapp update -n ca-iacb-web -g rg-iacb-lean \ + --set-env-vars LLM_MODEL=claude-sonnet-4-6 OTP_TTL_SECONDS=600 OTP_MAX_ATTEMPTS=5 +``` + +--- + +## 4. Post-deploy verification + +```bash +# Health (also proves DB connectivity) +curl -fsS https://iac-aistudio.com/healthz && echo +``` + +Then click through once: + +1. **Register** a throwaway account → confirm the email code is now **6 digits**, + that a **wrong** code is rejected, and that completing the flow logs you in. +2. **Forgot password** for that account → confirm reset works **only after** + entering the code, and that entering an **unknown** email gives the same + "if that account exists…" message (no "no account found"). +3. **Reset bypass is closed**: a direct POST to `/recover_reset` without first + verifying a code must NOT change any password (the test asserts this too). +4. **Model pin**: in App Insights / container logs, confirm generations run on + `LLM_MODEL` regardless of what the client sends. +5. Everyone is logged out once (expected — `SESSION_VERSION` bump). + +--- + +## 5. Rollback (instant) + +```bash +az containerapp update -n ca-iacb-web -g rg-iacb-lean --image "$PREV" +``` + +--- + +## 6. Still open (not in this code drop) + +- **P0-2 — shared rate-limit store.** The limiter is still in-memory (per + replica). This needs an **Azure Cache for Redis** resource provisioned first, + then `storage_uri="redis://…"` on the `Limiter(...)`. Infrastructure, not a + pure code change — do it as a follow-up. +- **CSP `'unsafe-inline'`** on `script-src` (P1-4) — requires externalizing + inline JS to `/static/js/*.js` and moving to nonces. Large, deferred. +- **Central input-validation layer** (P1-5 cont.) — Pydantic/marshmallow on POST + bodies. Deferred. +- **Monthly LLM spend cap / WAF / image scanning** — roadmap items. diff --git a/iac-aistudio-update/README.md b/iac-aistudio-update/README.md new file mode 100644 index 0000000..aa454dc --- /dev/null +++ b/iac-aistudio-update/README.md @@ -0,0 +1,37 @@ +# IaC Build — security hardening update + +Drop-in replacement for `app.py` in the IaC-AIStudio deploy bundle, plus tests +and CI. Hardens authentication and pins the LLM model server-side, with no new +Azure resources and no required config changes. + +**Start here:** [`IMPLEMENTATION_GUIDE.md`](./IMPLEMENTATION_GUIDE.md) — what +changed, how to test, and the exact Cloud Shell deploy + rollback steps. + +## Contents + +| File | What it is | +|------|------------| +| `app.py` | Hardened application (replaces your current `app.py`) | +| `IMPLEMENTATION_GUIDE.md` | Deploy guide + change log + verification checklist | +| `tests/test_security.py` | Security regression tests (OTP, reset auth, model pin) | +| `requirements-dev.txt` | Minimal deps to run the tests in `LOCAL_DEV` mode | +| `.gitlab-ci.yml` | Runs the tests on every push/MR (no secrets) | +| `requirements.txt` | Your runtime deps (unchanged copy, for reference) | + +## Quick test + +```bash +pip install -r requirements-dev.txt +LOCAL_DEV=1 FLASK_SECRET_KEY=test-secret pytest -v +``` + +## Summary of changes + +- **Model pin (P1-5):** `/api/generate` ignores the client `model`; pinned via `LLM_MODEL`. +- **OTP (P0-1):** 6-digit crypto codes, HMAC-stored, 10-min expiry, 5-attempt lockout, single-use. +- **Reset/registration authorization:** both now require a verified OTP (closes a password-reset bypass). +- **Session fixation (P1-3):** session rotates on login and OAuth callback. +- **Email enumeration:** uniform recovery response. + +See the guide for the full table and the items still left open (Redis limiter, +CSP nonces, validation layer). diff --git a/iac-aistudio-update/SETUP-VSCODE.md b/iac-aistudio-update/SETUP-VSCODE.md new file mode 100644 index 0000000..ab40e23 --- /dev/null +++ b/iac-aistudio-update/SETUP-VSCODE.md @@ -0,0 +1,84 @@ +# Move the IaC Build app to a git repo + VS Code (deploy without Cloud Shell) + +Today the app is deployed from a **zip bundle**. This sets it up as a proper git +repo you edit in VS Code and deploy from the VS Code integrated terminal. + +## Prerequisites (install once on your workstation) + +- **Azure CLI** (`az`) — https://learn.microsoft.com/cli/azure/install-azure-cli +- **Git** +- **VS Code** + extensions: Claude Code (you have it), and optionally + *Azure Container Apps*, *Docker*, and *Python*. +- You do **not** need Docker Desktop — `az acr build` builds in Azure. + +## 1. Make the app a git repo + +Use the bundle you already unzipped (it has the hardened `app.py`): + +```bash +cd ~/iacb/code # the folder with Dockerfile, app.py, app.html, templates/ + +cp /path/to/iac-aistudio-update/deploy.sh ./deploy.sh && chmod +x deploy.sh +cp /path/to/iac-aistudio-update/app.gitignore ./.gitignore + +git init -b main +git add . +git commit -m "IaC Build app (hardened) — initial repo" +``` + +> Keep the repo **private** — it contains your application logic (no secrets: +> those stay in Key Vault). Do not commit `.env` files or local databases +> (the `.gitignore` handles this). + +## 2. Push to your git host (pick one) + +**GitLab** (you mentioned this): +```bash +git remote add origin https://gitlab.com//iac-build.git +git push -u origin main +``` + +**GitHub:** +```bash +git remote add origin https://github.com//iac-build.git +git push -u origin main +``` + +## 3. Open in VS Code + +```bash +code ~/iacb/code +``` +Or in VS Code: **File ▸ Open Folder…** → select the repo. Use the Claude Code +extension in that window to make changes. + +## 4. Deploy from VS Code (replaces Cloud Shell) + +In the VS Code integrated terminal (**Ctrl+`**): + +```bash +az login # once per session/workstation +az account set --subscription 35b00a54-aaaf-46cb-9ec3-783ab739b084 +./deploy.sh +``` + +`deploy.sh` builds in ACR, rolls the Container App, prints revision health, and +shows the exact rollback command. That's your whole deploy now — edit in VS +Code, commit, run `./deploy.sh`. + +## 5. Day-to-day loop + +1. Edit code in VS Code (Claude Code can make the changes). +2. `git commit` (and `git push` to back it up). +3. `./deploy.sh` in the terminal. +4. Verify: the script's health line, plus a click-through on https://iac-aistudio.com. + +Rollback any time: `az containerapp update -n ca-iacb-web -g rg-iacb-lean --image ""`. + +## Notes + +- The base image `python:3.12-slim-bookworm` now lives in your ACR. If a build + ever fails with `manifest unknown`, uncomment the `az acr import` line in + `deploy.sh` and run it once. +- For automated checks on every push, wire up the security tests + (`tests/test_security.py` + `.gitlab-ci.yml`) from this update bundle. diff --git a/iac-aistudio-update/app.gitattributes b/iac-aistudio-update/app.gitattributes new file mode 100644 index 0000000..cbcd7dd --- /dev/null +++ b/iac-aistudio-update/app.gitattributes @@ -0,0 +1,8 @@ +# Rename to ".gitattributes" in your app repo root. +# Forces LF line endings so files built into the Linux container image stay +# correct even when edited on Windows. +* text=auto +*.py text eol=lf +*.sh text eol=lf +*.html text eol=lf +Dockerfile text eol=lf diff --git a/iac-aistudio-update/app.gitignore b/iac-aistudio-update/app.gitignore new file mode 100644 index 0000000..4471f9d --- /dev/null +++ b/iac-aistudio-update/app.gitignore @@ -0,0 +1,15 @@ +# Rename this to ".gitignore" in your app repo root. +# Keeps secrets, local venvs, and build cruft out of git. +__pycache__/ +*.pyc +.venv/ +venv/ +env/ +.env +.env.* +*.local +.DS_Store +.pytest_cache/ +# Don't commit local SQLite dev databases +users.db +instance/ diff --git a/iac-aistudio-update/app.py b/iac-aistudio-update/app.py new file mode 100644 index 0000000..b1be92b --- /dev/null +++ b/iac-aistudio-update/app.py @@ -0,0 +1,974 @@ +""" +IaC Build — Flask app for Azure Container Apps deployment. + +Entra-only PostgreSQL authentication via the Container App's system-assigned +managed identity. Flask secret key is pulled from Azure Key Vault at startup. +PG-backed sessions, in-app rate limiting, security headers. + +Environment variables (all required in cloud mode): + KEY_VAULT_URI https://kv-iacb-lean.vault.azure.net/ + PG_HOST pg-iacb-lean.postgres.database.azure.com + PG_DB appdb + PG_USER ca-iacb-web (the MI's name, case-sensitive) + APPLICATIONINSIGHTS_CONNECTION_STRING (optional, enables AI export) + +Local-dev mode: + Set LOCAL_DEV=1 to use SQLite + an env-var-based secret key. Useful + for running on your laptop without Azure dependencies. +""" +import os +import requests +import json +import tempfile +import subprocess +import re +import html +import random +import string +import time +import hmac +import hashlib +import logging +from datetime import datetime, timedelta +from urllib.parse import quote_plus + +from flask import (Flask, render_template, request, session, redirect, + url_for, Response, make_response) +from flask_sqlalchemy import SQLAlchemy +from flask_session import Session +from flask_compress import Compress +from flask_wtf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from werkzeug.security import generate_password_hash, check_password_hash +import secrets +from authlib.integrations.flask_client import OAuth +from werkzeug.middleware.proxy_fix import ProxyFix +from sqlalchemy import event +from sqlalchemy.engine import Engine + +# ----- Environment ---------------------------------------------------------- + +LOCAL_DEV = os.environ.get("LOCAL_DEV", "").lower() in ("1", "true", "yes") +KV_URI = os.environ.get("KEY_VAULT_URI") +PG_HOST = os.environ.get("PG_HOST") +PG_DB = os.environ.get("PG_DB", "appdb") +PG_USER = os.environ.get("PG_USER") + +OSSRDBMS_SCOPE = "https://ossrdbms-aad.database.windows.net/.default" + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s") +log = logging.getLogger("iacb") + +# ----- Azure Identity ------------------------------------------------------- + +_cred = None +if not LOCAL_DEV: + from azure.identity import DefaultAzureCredential + from azure.keyvault.secrets import SecretClient + _cred = DefaultAzureCredential(exclude_interactive_browser_credential=True) + +# ----- Application Insights (auto-instrumentation) ------------------------- +# Configures the OTLP exporter to Azure Monitor and auto-instruments Flask, +# requests, urllib, logging, and SQLAlchemy. No code changes elsewhere needed. +# Must run BEFORE Flask app is created so Flask routes get instrumented. +if not LOCAL_DEV and os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"): + try: + from azure.monitor.opentelemetry import configure_azure_monitor + configure_azure_monitor( + logger_name="iacb", + disable_offline_storage=True, # ACA filesystem is ephemeral + ) + log.info("Application Insights instrumentation configured") + except Exception as e: + # Never let telemetry init crash the app on cold-start. + log.warning("App Insights setup failed (continuing without): %s", e) + +# Quiet Azure SDK HTTP logging: at root INFO it dumps every telemetry POST +# (URL, headers, status) to stdout, which then floods ContainerAppConsoleLogs_CL +# and App Insights traces. App's own "iacb" logger stays at INFO. +for _n in ("azure", "azure.core.pipeline.policies.http_logging_policy", + "azure.monitor.opentelemetry.exporter", "azure.identity"): + logging.getLogger(_n).setLevel(logging.WARNING) + +# ----- Flask app ------------------------------------------------------------ + +app = Flask(__name__) +Compress(app) # gzip/br responses (HTML/CSS/JS/JSON) to cut transfer size +csrf = CSRFProtect(app) # protects all HTML form POSTs; JSON proxy + logout exempted below + +# Trust ACA's TLS termination — sets request.is_secure correctly behind the +# Container Apps edge load balancer so secure cookies and url_for(_scheme) +# work as expected. +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + +# ----- Secret key: Key Vault in cloud, env var local ----------------------- + +if LOCAL_DEV: + app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-only-do-not-ship") + log.warning("LOCAL_DEV=1 — using insecure dev secret key") +else: + if not KV_URI: + raise RuntimeError("KEY_VAULT_URI must be set in cloud mode") + _sc = SecretClient(vault_url=KV_URI, credential=_cred) + # Stable signing key from Key Vault. Keeps sessions and CSRF tokens valid + # across redeploys/restarts instead of logging every user out on each deploy. + app.secret_key = _sc.get_secret("flask-secret-key").value + log.info("Loaded stable session secret from Key Vault") + +# ----- LLM API key for the server-side /api/generate proxy ------------------ +# Fetched once at startup (KV in cloud, env var locally). The route returns 503 +# if the key is absent, so the app still boots before the secret is created. +_LLM_KEY = os.environ.get("LLM_API_KEY") +if not LOCAL_DEV: + try: + _LLM_KEY = _sc.get_secret("llm-api-key").value + except Exception as _e: + log.warning("LLM key not in Key Vault yet: %s", _e) + +# SECURITY (P1-5): pin the generation model and output cap server-side. +# Previously /api/generate took "model" straight from the client payload +# (only max_tokens was clamped), so a caller could request an arbitrary, more +# expensive model. These are now fixed here and overridable ONLY via env vars +# on the Container App — never by the request body. +LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-4-6") +LLM_MAX_TOKENS = int(os.environ.get("LLM_MAX_TOKENS", "8000")) +LLM_DEFAULT_TOKENS = int(os.environ.get("LLM_DEFAULT_TOKENS", "4000")) + +# ----- OAuth social login (Google + GitHub) -------------------------------- +# Client IDs are public; secrets come from Key Vault (same vault as llm-api-key). +# TWO GitHub apps (one per domain) since a classic OAuth App locks to one host. +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "810180534624-mm6n7c8a50k2r98pmgrnksn983gcp05m.apps.googleusercontent.com") +GITHUB_CLIENT_ID_IAC = os.environ.get("GITHUB_CLIENT_ID_IAC", "Ov23litRKnOeOcC5YIBS") +GITHUB_CLIENT_ID_ONLINE = os.environ.get("GITHUB_CLIENT_ID_ONLINE", "Ov23ctEWg1GUBxzQtpdU") + +_OAUTH_SECRETS = {} +if not LOCAL_DEV: + for _sn in ("google-oauth-secret", "github-oauth-secret-iac", "github-oauth-secret-online"): + try: + _OAUTH_SECRETS[_sn] = _sc.get_secret(_sn).value + except Exception as _e: + log.warning("OAuth secret %s not loaded: %s", _sn, _e) + +oauth = OAuth(app) +if _OAUTH_SECRETS.get("google-oauth-secret"): + oauth.register( + name="google", + client_id=GOOGLE_CLIENT_ID, + client_secret=_OAUTH_SECRETS["google-oauth-secret"], + server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, + ) +for _gk, _gid, _gname in (("github-oauth-secret-iac", GITHUB_CLIENT_ID_IAC, "github_iac"), + ("github-oauth-secret-online", GITHUB_CLIENT_ID_ONLINE, "github_online")): + if _OAUTH_SECRETS.get(_gk): + oauth.register( + name=_gname, + client_id=_gid, + client_secret=_OAUTH_SECRETS[_gk], + access_token_url="https://github.com/login/oauth/access_token", + authorize_url="https://github.com/login/oauth/authorize", + api_base_url="https://api.github.com/", + client_kwargs={"scope": "read:user user:email"}, + ) + +OAUTH_ENABLED = bool(_OAUTH_SECRETS) + +def _github_client_name(): + host = (request.host or "").lower().split(":")[0] + if host.startswith("www."): + host = host[4:] + return "github_online" if "online-shield.com" in host else "github_iac" + +# ----- Session policy ------------------------------------------------------- + +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=30) +app.config["IDLE_SECONDS"] = 1800 +app.config["SESSION_COOKIE_HTTPONLY"] = True +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +# Secure cookies only in cloud (HTTPS); LOCAL_DEV runs HTTP. +app.config["SESSION_COOKIE_SECURE"] = not LOCAL_DEV + +# Bump SESSION_VERSION to invalidate ALL existing sessions on next deploy. +# Bumped 3 -> 4 with the auth hardening (OTP/session-rotation) so no pre-deploy +# session lingers in a half-migrated state. Users are logged out once. +SESSION_VERSION = 4 + +# ----- Database ------------------------------------------------------------- + +if LOCAL_DEV: + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///users.db" +else: + # Password placeholder in URL is overwritten per-connection by the + # do_connect event listener below. + app.config["SQLALCHEMY_DATABASE_URI"] = ( + f"postgresql+psycopg://{quote_plus(PG_USER)}@{PG_HOST}/{PG_DB}?sslmode=require" + ) + app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { + "pool_pre_ping": True, + "pool_recycle": 1800, # < ~1h token TTL + } +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +db = SQLAlchemy(app) + +# Per-connection Entra token injection. Fires when SQLAlchemy opens a new +# physical connection (not per-query). Only affects PostgreSQL dialect, so +# local SQLite dev mode is unaffected. +@event.listens_for(Engine, "do_connect") +def _provide_token(dialect, conn_rec, cargs, cparams): + if not LOCAL_DEV and dialect.name == "postgresql": + cparams["password"] = _cred.get_token(OSSRDBMS_SCOPE).token + +# ----- PG-backed sessions (cloud only) ------------------------------------- + +if not LOCAL_DEV: + app.config["SESSION_TYPE"] = "sqlalchemy" + app.config["SESSION_SQLALCHEMY"] = db + Session(app) # auto-creates a 'sessions' table on first request + +# ----- User model ----------------------------------------------------------- + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + first_name = db.Column(db.String(50), nullable=False) + last_name = db.Column(db.String(50), nullable=False) + phone = db.Column(db.String(20), nullable=False) + + +class GenLog(db.Model): + __tablename__ = "gen_log" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, index=True, nullable=False) + ts = db.Column(db.DateTime, default=datetime.utcnow, index=True, nullable=False) + + +# ----- Access control ------------------------------------------------------- +# Admins (comma-separated emails in ADMIN_EMAILS) may run real AI generation. +# Everyone else is locked to DEMO mode so test users cannot spend API credits. +# Fail-closed: if ADMIN_EMAILS is unset, NO account can run live generation. +ADMIN_EMAILS = {e.strip().lower() for e in os.environ.get("ADMIN_EMAILS", "").split(",") if e.strip()} + +def _is_admin(user): + return bool(user) and bool(user.email) and user.email.lower() in ADMIN_EMAILS + + +with app.app_context(): + # No-op if tables already exist. Creates User table + (in cloud) sessions + # table on first run after deploy. + db.create_all() + +# ----- Rate limiter --------------------------------------------------------- + +limiter = Limiter( + app=app, + key_func=get_remote_address, + default_limits=[], + storage_uri="memory://", # single-replica prototype; OK per docx +) + +# ----- Security & cache headers -------------------------------------------- + +# Content-Security-Policy. +# HONEST CAVEAT: this is a PERMISSIVE CSP, not a strict one. The existing +# HTML has 110+ inline event handlers (onclick=..., onload=..., etc.) and 11 +# inline + + diff --git a/terraform-translator/tests/test_translator.py b/terraform-translator/tests/test_translator.py new file mode 100644 index 0000000..795fb30 --- /dev/null +++ b/terraform-translator/tests/test_translator.py @@ -0,0 +1,75 @@ +"""Offline tests for translator.py. + +These run without an ANTHROPIC_API_KEY and without the `anthropic` package, +by injecting a fake client that mimics the SDK's streaming surface. + + python -m unittest discover -s tests # or: pytest +""" + +import os +import sys +import unittest +from types import SimpleNamespace + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from translator import TranslationError, _strip_code_fences, translate + + +class _FakeStream: + """Stands in for the context manager returned by client.messages.stream(...).""" + + def __init__(self, message): + self._message = message + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def get_final_message(self): + return self._message + + +class _FakeClient: + """Minimal fake exposing client.messages.stream(...).""" + + def __init__(self, text, stop_reason="end_turn"): + message = SimpleNamespace( + stop_reason=stop_reason, + content=[SimpleNamespace(type="text", text=text)], + ) + self.messages = SimpleNamespace(stream=lambda **kwargs: _FakeStream(message)) + + +class StripFenceTests(unittest.TestCase): + def test_hcl_fence(self): + self.assertEqual(_strip_code_fences('```hcl\nresource "x" "y" {}\n```'), + 'resource "x" "y" {}') + + def test_bare_fence(self): + self.assertEqual(_strip_code_fences("```\nfoo\n```"), "foo") + + def test_no_fence(self): + self.assertEqual(_strip_code_fences(" plain "), "plain") + + +class TranslateTests(unittest.TestCase): + def test_returns_code_and_strips_fence(self): + client = _FakeClient('```hcl\nprovider "azurerm" {\n features {}\n}\n```') + out = translate("a resource group", client=client) + self.assertEqual(out, 'provider "azurerm" {\n features {}\n}') + + def test_empty_prompt_raises(self): + with self.assertRaises(TranslationError): + translate(" ", client=_FakeClient("ignored")) + + def test_refusal_raises(self): + client = _FakeClient("", stop_reason="refusal") + with self.assertRaises(TranslationError): + translate("something", client=client) + + +if __name__ == "__main__": + unittest.main() diff --git a/terraform-translator/translator.py b/terraform-translator/translator.py new file mode 100644 index 0000000..783fd11 --- /dev/null +++ b/terraform-translator/translator.py @@ -0,0 +1,92 @@ +"""Core translation logic: plain language -> Azure Terraform (HCL). + +Uses the Anthropic Python SDK with Claude Opus 4.8. Streaming is used so that +long generations don't hit the SDK's HTTP timeout, and adaptive thinking lets +the model reason about the right Azure resources before emitting code. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # only needed for type checkers, not at runtime + from anthropic import Anthropic + +MODEL = "claude-opus-4-8" + +SYSTEM_PROMPT = """\ +You are an expert cloud infrastructure engineer specializing in Microsoft Azure \ +and Terraform (HCL). Convert the user's plain-language infrastructure \ +description into production-quality Terraform configuration using the official \ +`azurerm` provider. + +Guidelines: +- Output valid, well-formatted HCL. +- Include a `terraform` block that pins a recent `azurerm` provider version, and \ +a `provider "azurerm"` block with `features {}`. +- Create a resource group unless the user explicitly says to use an existing one. +- Use descriptive, Azure-valid resource names and locations. +- Parameterize key values with `variable` blocks (with sensible defaults) and \ +expose useful values (IDs, endpoints, connection info) via `output` blocks. +- Apply Azure + Terraform best practices: tags, secure defaults, least-privilege. +- Add concise `#` comments explaining non-obvious choices. +- If the request is ambiguous, pick reasonable defaults and note them in comments. + +Return ONLY the Terraform code. Do not add any explanation before or after it.""" + + +class TranslationError(RuntimeError): + """Raised when the model cannot produce Terraform for the request.""" + + +def _strip_code_fences(text: str) -> str: + """Remove a surrounding ```hcl ... ``` (or ```) fence if the model added one.""" + fenced = re.match(r"^\s*```[a-zA-Z]*\n(.*?)\n```\s*$", text, re.DOTALL) + if fenced: + return fenced.group(1).strip() + return text.strip() + + +def translate(prompt: str, *, client: Anthropic | None = None) -> str: + """Translate a plain-language description into Azure Terraform HCL. + + Args: + prompt: Natural-language description of the desired Azure infrastructure. + client: Optional pre-configured Anthropic client. If omitted, a default + client is created (reads ANTHROPIC_API_KEY from the environment). + + Returns: + The generated Terraform configuration as a string. + """ + prompt = (prompt or "").strip() + if not prompt: + raise TranslationError("Please describe the infrastructure you want.") + + if client is None: + from anthropic import Anthropic # imported lazily so tests can inject a fake + + client = Anthropic() + + with client.messages.stream( + model=MODEL, + max_tokens=64000, + thinking={"type": "adaptive"}, + output_config={"effort": "high"}, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": prompt}], + ) as stream: + message = stream.get_final_message() + + if message.stop_reason == "refusal": + raise TranslationError( + "The request was declined by the model's safety system." + ) + + text = "".join( + block.text for block in message.content if block.type == "text" + ) + code = _strip_code_fences(text) + if not code: + raise TranslationError("The model did not return any Terraform code.") + return code