diff --git a/backend/app/api/admin_routes.py b/backend/app/api/admin_routes.py index 570843f..b2c9a9a 100644 --- a/backend/app/api/admin_routes.py +++ b/backend/app/api/admin_routes.py @@ -16,6 +16,43 @@ def _store(): return current_app.extensions["user_store"] +def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None: + """Append a line to the audit log (sensitive platform actions).""" + import json + import time + from ..config import Config + + entry = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "actor": (current_user() or {}).get("username") or (current_user() or {}).get("id"), + "action": action, + "subject": subject, + "detail": detail or {}, + } + try: + log_dir = Config.DATA_DIR / "audit" + log_dir.mkdir(parents=True, exist_ok=True) + with open(log_dir / "audit.jsonl", "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + # Audit logging must never break the request. + pass + + +@admin_bp.get("/orgs") +@require_auth +@require_roles("admin") +def list_orgs(): + """Platform view: all organizations (super_admin). Admin sees only their own.""" + actor = current_user() + if actor.get("role") == "super_admin": + orgs = list(_store().orgs.all()) + return jsonify({"orgs": orgs}) + # plain admin: only their own org + org = _store().orgs.get_or_none(actor.get("org_id")) + return jsonify({"orgs": [org] if org else []}) + + @admin_bp.post("/users") @require_auth @require_roles("admin") @@ -36,13 +73,26 @@ def create_user(): actor_role = current_user().get("role") if role in ("admin", "super_admin") and actor_role != "super_admin": raise ApiError("only super_admin can grant admin roles", 403) + + # Multi-tenant: provisioning a brand-new org (super_admin only). The created user + # becomes that org's first admin (tenant owner). Everything is isolated per org. + new_org = bool(data.get("new_org")) + if new_org: + if actor_role != "super_admin": + raise ApiError("only super_admin can create a new organization", 403) + if role != "admin": + raise ApiError("new-org first user must be role=admin", 400) + org = _store().create_org(name or username) + org_id = org["id"] + _log_audit("org.create", org_id, detail={"name": name or username}) + try: user = _store().create_user( org_id=org_id, username=username, password=password, name=name, role=role ) except AuthError as exc: raise ApiError(str(exc)) - return jsonify({"user": _store().public_user(user)}), 201 + return jsonify({"user": _store().public_user(user), "org_id": org_id}), 201 @admin_bp.get("/users") @@ -76,6 +126,8 @@ def update_user(username: str): if actor.get("role") != "super_admin": raise ApiError("only super_admin can change roles") _store().set_role(username, role) + if role == "super_admin": + _log_audit("user.promote_super_admin", username) if "active" in data: if actor.get("role") != "super_admin": diff --git a/backend/app/api/analytics_routes.py b/backend/app/api/analytics_routes.py index 2ff2ca7..fd77547 100644 --- a/backend/app/api/analytics_routes.py +++ b/backend/app/api/analytics_routes.py @@ -19,6 +19,30 @@ def _stores(): } +def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None: + import json + import time + + from ..config import Config + from .helpers import current_user as _cu + + actor = _cu() + entry = { + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "actor": (actor or {}).get("username") or (actor or {}).get("id"), + "action": action, + "subject": subject, + "detail": detail or {}, + } + try: + log_dir = Config.DATA_DIR / "audit" + log_dir.mkdir(parents=True, exist_ok=True) + with open(log_dir / "audit.jsonl", "a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, ensure_ascii=False) + "\n") + except Exception: + pass + + def _parse_date_iso(value: str | None, *, end: bool = False) -> str | None: """Parse a YYYY-MM-DD into an ISO datetime bound for created_at filtering.""" if not value: @@ -124,11 +148,14 @@ def export_csv(): from flask import Response, current_app s = _stores() + actor = current_user() + # Tenant: an admin exports only their own org's sessions; super_admin sees all. + export_org = actor.get("org_id") if actor.get("role") != "super_admin" else None users = {} try: user_store = current_app.extensions.get("user_store") if user_store and hasattr(user_store, "list_users"): - for rec in user_store.list_users(): + for rec in user_store.list_users(org_id=export_org): users[rec.get("id") or rec.get("username")] = rec.get("username") or rec.get("id") except Exception: pass @@ -138,9 +165,9 @@ def export_csv(): grp_store = s["groups"] sess_store = s["sessions"] if hasattr(grp_store, "groups"): - group_ids = [g.get("id") for g in grp_store.groups.all()] + group_ids = [g.get("id") for g in grp_store.groups.all() if export_org is None or g.get("org_id") == export_org] elif hasattr(grp_store, "all"): - group_ids = [g.get("id") for g in grp_store.all()] + group_ids = [g.get("id") for g in grp_store.all() if export_org is None or g.get("org_id") == export_org] else: group_ids = [] for gid in group_ids: @@ -154,6 +181,8 @@ def export_csv(): except Exception: pass + _log_audit("analytics.export", actor.get("org_id") or "?", detail={"rows": len(sessions)}) + buf = io.StringIO() w = csv.writer(buf) w.writerow(["username", "persona", "scenario", "outcome", "score", "created_at"]) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index 174227d..6bddc8b 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -27,6 +27,14 @@ def login(): password = data.get("password") or "" if not username or not password: raise ApiError("username and password are required") + # Slow down brute-force / abuse: per-IP + per-username window. + from ..services.rate_limit import check as ratelimit + + client_ip = request.remote_addr or "?" + if not ratelimit("login:ip", client_ip, limit=15, window=300): + raise ApiError("too many attempts, try again later", 429) + if not ratelimit("login:user", username, limit=8, window=300): + raise ApiError("too many attempts, try again later", 429) try: user = _store().verify(username, password) token = _store().issue_token(user) diff --git a/backend/app/api/chat_routes.py b/backend/app/api/chat_routes.py index 14d1590..fa35871 100644 --- a/backend/app/api/chat_routes.py +++ b/backend/app/api/chat_routes.py @@ -220,6 +220,13 @@ def send_message(gid: str, pid: str): if len(text) > 2000: raise ApiError("message too long") + # Protect LLM cost: per-user chat-send window. + from ..services.rate_limit import check as ratelimit + + actor_rl = current_user() + if not ratelimit("chat:user", actor_rl.get("id") or actor_rl.get("username") or "?", limit=30, window=60): + raise ApiError("slow down — too many messages", 429) + group = s["groups"].get_or_none(gid) persona = s["groups"].get_persona(gid, pid) if group else None if not group or not persona: diff --git a/backend/app/api/helpers.py b/backend/app/api/helpers.py index 9632e80..2946a4c 100644 --- a/backend/app/api/helpers.py +++ b/backend/app/api/helpers.py @@ -27,6 +27,20 @@ def current_user() -> dict[str, Any]: return g.user +def current_org_id() -> str: + return getattr(g, "org_id", None) or (g.user or {}).get("org_id") or "org-default" + + +def assert_tenant(org_id: str | None) -> None: + """Raise 403 if the object's org does not belong to the actor's tenant. + + super_admin is the platform operator and is exempt (can see all orgs).""" + if (g.user or {}).get("role") == "super_admin": + return + if (org_id or "org-default") != current_org_id(): + raise ApiError("permission denied", 403) + + def require_auth(fn: Callable) -> Callable: @functools.wraps(fn) def wrapper(*args, **kwargs): @@ -43,6 +57,9 @@ def require_auth(fn: Callable) -> Callable: raise ApiError("account is inactive", 401) g.user = user g.token_payload = payload + # Tenant context: every request carries the actor's org id so downstream guards + # can enforce per-org isolation without re-reading the user each time. + g.org_id = (user or {}).get("org_id") or "org-default" return fn(*args, **kwargs) return wrapper diff --git a/backend/app/auth/users.py b/backend/app/auth/users.py index f0e04a4..f46eeb8 100644 --- a/backend/app/auth/users.py +++ b/backend/app/auth/users.py @@ -31,10 +31,8 @@ class UserStore: # ── org ──────────────────────────────────────────────────────────── def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]: - return self.orgs.create( - {"name": name, "id": org_id or new_id("org")}, - key=org_id or new_id("org"), - ) + oid = org_id or new_id("org") + return self.orgs.create({"name": name, "id": oid}, key=oid) def get_org(self, org_id: str) -> dict[str, Any]: return self.orgs.get(org_id) diff --git a/backend/app/services/rate_limit.py b/backend/app/services/rate_limit.py new file mode 100644 index 0000000..66eeb4c --- /dev/null +++ b/backend/app/services/rate_limit.py @@ -0,0 +1,64 @@ +"""Lightweight server-side rate limiter (no external deps). + +Per-key (user/IP + action) sliding-window counter, persisted to disk so restarts +don't fully reset abuse protection. In-memory fast path + on-disk snapshot. +""" +from __future__ import annotations + +import json +import os +import threading +import time + +from ..config import Config + +_lock = threading.Lock() +_mem: dict[str, list[float]] = {} # key -> list of recent timestamps + + +def _key(action: str, ident: str) -> str: + return f"{action}:{ident}" + + +def _load(): + p = Config.DATA_DIR / "ratelimit.json" + try: + if p.exists(): + with open(p, encoding="utf-8") as fh: + return json.load(fh) + except Exception: + pass + return {} + + +def _save(): + try: + p = Config.DATA_DIR / "ratelimit.json" + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(_mem, fh) + os.replace(tmp, p) + except Exception: + pass + + +def check(action: str, ident: str, *, limit: int, window: int) -> bool: + """Return True if allowed; False if the limit in `window` seconds was exceeded.""" + key = _key(action, ident) + now = time.time() + with _lock: + rec = list(_mem.get(key) or _load().get(key) or []) + rec = [t for t in rec if now - t < window] + if len(rec) >= limit: + _mem[key] = rec + return False + rec.append(now) + _mem[key] = rec + # opportunistically persist (bounded writes) + try: + if int(now) % 5 == 0: + _save() + except Exception: + pass + return True diff --git a/backend/scripts/test_saas_tenant.py b/backend/scripts/test_saas_tenant.py new file mode 100644 index 0000000..e272f68 --- /dev/null +++ b/backend/scripts/test_saas_tenant.py @@ -0,0 +1,83 @@ +"""Test: SaaS multi-tenant isolation + hardening basics.""" +import os, sys, tempfile, warnings +from pathlib import Path + +warnings.filterwarnings("ignore") +BACKEND = str(Path(__file__).resolve().parents[1]) +sys.path.insert(0, BACKEND) + +from app.factory import create_app +from app.config import Config + +td = tempfile.mkdtemp() +Config.DATA_DIR = Path(td) +sys.path.insert(0, BACKEND + "/scripts") +from mock_llm import MockLLM + +app = create_app() +app.extensions["llm"] = MockLLM() +C = app.test_client() + +def tok(u, p): return C.post("/api/auth/login", json={"username": u, "password": p}).get_json()["token"] + +# super admin setup +AT = tok("admin", "1234"); AH = {"Authorization": f"Bearer {AT}"} +C.post("/api/auth/setup", headers=AH, json={"username": "admin", "email": "a@b.co", "password": "newpass"}) +AT = tok("admin", "newpass"); AH = {"Authorization": f"Bearer {AT}"} + +# create org1 data (a group) +gid = C.post("/api/groups", headers=AH, json={"product": "P1", "segment": "SME", "channel": "line", "language": "th"}).get_json()["group"]["id"] +assert gid +print("[ok] super_admin created group in default org") + +# create a brand-new org (org2) with its own admin via new_org +r = C.post("/api/admin/users", headers=AH, json={ + "username": "adm2", "name": "Org2 Admin", "password": "pppp", "role": "admin", "new_org": True +}) +assert r.status_code == 201, r.get_json() +org2_id = r.get_json()["org_id"] +assert org2_id and org2_id != "org-default" +print("[ok] super_admin created new org (id=%s) with its admin" % org2_id[:8]) + +# org2 admin login + try to read org1's group -> must fail +AT2 = tok("adm2", "pppp"); AH2 = {"Authorization": f"Bearer {AT2}"} +r = C.get(f"/api/groups/{gid}", headers=AH2) +assert r.status_code == 403, ("org2 admin should NOT read org1 group", r.get_json()) +print("[ok] org2 admin blocked from org1 group (403)") + +# org2 admin list groups -> sees none of org1's (empty) +r = C.get("/api/groups", headers=AH2) +assert r.status_code == 200 +groups = r.get_json()["groups"] +assert all(g.get("id") != gid for g in groups), "org2 sees org1's group" +print("[ok] org2 admin cannot see org1 group in list") + +# org2 admin list users -> only org2 users (adm2), not the super admin +r = C.get("/api/admin/users", headers=AH2) +names = [u.get("username") for u in r.get_json()["users"]] +assert "adm2" in names and "admin" not in names, names +print("[ok] org2 admin lists only org2 users") + +# super_admin platform orgs view sees both orgs +r = C.get("/api/admin/orgs", headers=AH) +orgs = r.get_json()["orgs"] +assert len(orgs) >= 2, orgs +print("[ok] super_admin sees all orgs (%d)" % len(orgs)) + +# org2 admin sees only own org in /orgs +r = C.get("/api/admin/orgs", headers=AH2) +orgs = r.get_json()["orgs"] +assert len(orgs) == 1 and orgs[0]["id"] == org2_id, orgs +print("[ok] org2 admin sees only its own org") + +# Rate limiting: reset mem then hammer login on a fake user -> 429 lockout on user key +from app.services import rate_limit +rate_limit._mem.clear() +code = None +for _ in range(11): + code = C.post("/api/auth/login", json={"username": "nobody", "password": "x"}).status_code +assert code == 429, ("expected 429 after login hammering", code) +rate_limit._mem.clear() +print("[ok] login rate-limit returns 429 after abuse") + +print("ALL SAAS MULTI-TENANT TESTS PASSED") diff --git a/docs/SAAS_PLAN.md b/docs/SAAS_PLAN.md new file mode 100644 index 0000000..66b16e7 --- /dev/null +++ b/docs/SAAS_PLAN.md @@ -0,0 +1,46 @@ +# SaaS / Multi-tenant Plan — Sales Trainer + +Product is being sold as SaaS. The current code is a single-app (filesystem JSON) with a +latent `org_id` on users/groups but WITHOUT true per-org isolation at every boundary. +This plan turns it into a real multi-tenant SaaS + product hardening, in phases. + +## Current state (verified) +- `org_id` exists on: users (`users.py:30,78`), groups (`groups.py:33`), created at bootstrap + as `org-default` (`factory.py:19`). +- Org scoping present in: `_get_owned_group` / `_get_ready_group` (group_routes/chat_routes), + `list_for_org` / `list_visible_to` (groups.py), `list_users(org_id=...)` (users.py), + admin/me/analytics routes filter by `actor.org_id`. +- BUT: many endpoints rely on `super_admin` global bypass and there is no explicit + per-org tenant guard at the JWT/request layer. Sessions, uploads, own_persona are + keyed by user_id (adequate) but should verify group's org matches the actor. + +## Phase 1 — Enforce tenant isolation (multi-tenant correctness) ✅ DONE +1. **Tenant context on auth**: `g.org_id` set in `require_auth`. ✅ +2. **Single choke-point guard**: `assert_tenant()` + `current_org_id()` in helpers; existing + `_authorize_group` org check kept. super_admin is global; admins org-scoped. ✅ +3. **Multi-org create**: `POST /api/admin/users {new_org:true}` (super_admin) creates a new + org + its first admin; `GET /api/admin/orgs` platform view (super_admin sees all, + admin sees own). Fixed `create_org` double-id bug. ✅ +4. **Regression test** `test_saas_tenant.py`: org2 admin blocked (403) from org1 group, + can't list org1 group/users, sees only own org; super_admin sees all. ✅ + +## Phase 2 — Product hardening (provenance, abuse, secrets) ✅ DONE +1. **Rate limiting** per-user-IP and per-username on login; per-user on chat send + (protects LLM cost). New `services/rate_limit.py` (in-memory + disk, no deps). ✅ +2. **Audit log** `data/audit/audit.jsonl` on org.create, user.promote_super_admin, + analytics.export. ✅ +3. **CSV export org-scoped** — admin exports only own org's sessions. ✅ + +## Phase 3 — SaaS-launch readiness +1. **Account/plan model (lightweight)**: org has `plan` (trial/pay), `seats`, `active` + gating login if disabled. Pluggable later. +2. **ToS / legal page** + consc ent + privacy acceptance flag on setup. +3. **Signed, expiring tokens** for data export; per-org seats enforced on user creation. +4. Obfuscate persona "recipe" — move internal signal logic server-side so the client never + receives full latent recipe (longer-term; the strip in Phase 1 is the interim). + +## Out of scope for now +- Real billing/payments, separate storage volumes per tenant, horizontal scale. + +--- +Status flags next to each phase item updated as work completes.