From a52af650244199b274576c17f1bbccf99eaabf67 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Wed, 2 Sep 2026 17:45:57 +0200 Subject: [PATCH] add authenticated tenant-scoped sessions --- .env.example | 4 + README.md | 7 +- apps/api/app/main.py | 199 ++++++++++++++++++++++--------------- apps/api/schema.sql | 38 +++++++ apps/api/tests/test_api.py | 61 +++++++++++- apps/web/app.js | 40 +++++--- apps/web/index.html | 19 +++- apps/web/smoke-test.html | 20 +++- apps/web/styles.css | 2 +- docker-compose.yml | 3 + docs/OPERATIONS.md | 10 +- docs/SECURITY.md | 21 ++-- 12 files changed, 309 insertions(+), 115 deletions(-) diff --git a/.env.example b/.env.example index 42d4c06..c833ae4 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,9 @@ LOG_LEVEL=INFO API_PORT=8000 WEB_PORT=8080 CORS_ORIGINS=http://localhost:8080 +# Optional first-run admin bootstrap. Set both only for a fresh deployment; +# remove them after the admin account has been created. Never commit credentials. +BOOTSTRAP_ADMIN_EMAIL= +BOOTSTRAP_ADMIN_PASSWORD= # Deliberately fixed to false in compose for this MVP. AUTOMATED_OUTREACH_ENABLED=false diff --git a/README.md b/README.md index 6fd28e9..52402e8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A safety-first MVP vertical slice for evidence-led prospect discovery and qualif - JSON API under `/api/v1` for health, dashboard summary, businesses, suppression, and CSV-style import preview. - Responsive static dashboard under `apps/web` with explorer filters, evidence/freshness labels, detail review, manual intake, and browser-only CSV preview. - Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume. +- Browser authentication with server-side sessions and an optional first-run admin bootstrap. - Security and operations guidance in `docs/`. ## Run locally @@ -49,7 +50,11 @@ curl -fsS http://localhost:8080/healthz docker compose down ``` -The initial pilot deliberately omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, authentication, and outbound messaging. Those are separate release gates from the implementation plan and must not be inferred from this MVP. Before production use, add real authentication/tenant isolation, migrations, SSRF-safe scanners, approved source registry, queue idempotency, backups/restore drills, legal review, and security testing. +Compose passes the optional `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` values to the API. Set both in an untracked `.env` only when provisioning a fresh instance, then remove them and rotate the password after the bootstrap admin is created. No credentials belong in this repository. + +Authenticated browser requests use a server-side session cookie; login creates a session and logout invalidates it. The liveness endpoints (`GET /api/v1/health/live` and `GET /healthz`) intentionally remain unauthenticated so Docker, ingress, and monitoring health checks can use them. Authentication is not a substitute for tenant/authorization checks: protected routes must enforce the session and organization boundary server-side. + +The initial pilot still omits Postgres, Redis, Celery, external discovery adapters, DNS/HTTP scanning, and outbound messaging. Before production use, complete the production security gates described in `docs/SECURITY.md`, including Argon2id password hashing, MFA for administrator accounts, TLS, CSRF protection, rate limiting, audit logging, migrations, SSRF-safe scanners, approved source registry, queue idempotency, and tested backups/restores. ## Verification diff --git a/apps/api/app/main.py b/apps/api/app/main.py index e234d84..2954d5f 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,10 +1,14 @@ from __future__ import annotations import argparse +import hashlib import json import os +import secrets import sqlite3 import sys +from datetime import datetime, timedelta, timezone +from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -17,6 +21,23 @@ else: ORGANIZATION_ID = "demo-tenant" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" +SESSION_DAYS = 7 +PBKDF2_ITERATIONS = 300_000 # Development fallback: stdlib PBKDF2, not Argon2id. +MUTATING_ROLES = {"owner", "admin", "researcher"} + + +def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: + salt = salt or secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS) + return digest.hex(), salt.hex() + + +def verify_password(password: str, encoded_hash: str, encoded_salt: str) -> bool: + try: + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(encoded_salt), PBKDF2_ITERATIONS).hex() + return secrets.compare_digest(digest, encoded_hash) + except (TypeError, ValueError): + return False def connect(db_path: str) -> sqlite3.Connection: @@ -24,6 +45,14 @@ def connect(db_path: str) -> sqlite3.Connection: db.row_factory = sqlite3.Row db.execute("PRAGMA foreign_keys = ON") db.executescript(SCHEMA.read_text()) + db.execute("INSERT OR IGNORE INTO organizations (id, name) VALUES (?, ?)", (ORGANIZATION_ID, "Demo organization")) + email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD") + if email and password: + existing = db.execute("SELECT id FROM users WHERE email = ?", (email.strip().lower(),)).fetchone() + if not existing: + password_hash, salt = hash_password(password) + db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID, email.strip().lower(), password_hash, salt, "owner")) + db.commit() return db @@ -33,24 +62,18 @@ def row_json(row: sqlite3.Row) -> dict: return result -def existing_businesses(db: sqlite3.Connection) -> list[dict]: - return [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY id", (ORGANIZATION_ID,))] - - -def suppression_rows(db: sqlite3.Connection) -> list[dict]: - return [dict(r) for r in db.execute("SELECT kind, value FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,))] - - class ApiHandler(BaseHTTPRequestHandler): server_version = "ProspectPlatform/0.1" - def send_json(self, status: int, payload: dict | list): + def send_json(self, status: int, payload: dict | list, extra_headers: dict[str, str] | None = None): body = json.dumps(payload, sort_keys=True).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) + self.send_header("Access-Control-Allow-Credentials", "true") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") + for key, value in (extra_headers or {}).items(): self.send_header(key, value) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -63,116 +86,134 @@ class ApiHandler(BaseHTTPRequestHandler): except (ValueError, json.JSONDecodeError): return {} - def db(self): - return connect(self.server.db_path) + def db(self): return connect(getattr(self.server, "db_path")) def do_OPTIONS(self): self.send_response(204) self.send_header("Access-Control-Allow-Origin", os.environ.get("CORS_ORIGINS", "http://localhost:8080")) + self.send_header("Access-Control-Allow-Credentials", "true") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") self.end_headers() + def session_user(self, db: sqlite3.Connection): + cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")) + token = cookie.get("session") + if not token: return None + token_hash = hashlib.sha256(token.value.encode()).hexdigest() + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + return db.execute("SELECT u.id, u.email, u.role, u.organization_id FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ?", (token_hash, now)).fetchone() + + def require_auth(self, db): + user = self.session_user(db) + if not user: + self.send_json(401, {"error": "unauthorized"}) + return None + return user + + def auth_cookie(self, token: str, max_age: int) -> str: + return f"session={token}; Max-Age={max_age}; Path=/; HttpOnly; SameSite=Lax" + def do_GET(self): - parsed = urlparse(self.path) - path = parsed.path.rstrip("/") - if path == "/api/v1/health/live": - return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID}) + parsed = urlparse(self.path); path = parsed.path.rstrip("/") + if path == "/api/v1/health/live": return self.send_json(200, {"status": "ok", "organization_id": ORGANIZATION_ID}) db = self.db() try: + user = self.require_auth(db) + if not user: return + org = user["organization_id"] + if path == "/api/v1/auth/me": return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": org}) + if path == "/api/v1/admin/users": + if user["role"] not in {"owner", "admin"}: return self.send_json(403, {"error": "forbidden"}) + rows = db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id = ? ORDER BY id", (org,)).fetchall() + return self.send_json(200, {"items": [dict(r) for r in rows]}) if path == "/api/v1/dashboard/summary": - row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone() - return self.send_json(200, {"organization_id": ORGANIZATION_ID, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (ORGANIZATION_ID,)).fetchone()[0]}) + row = db.execute("SELECT COUNT(*) AS businesses, COALESCE(AVG(score), 0) AS average_score FROM businesses WHERE organization_id = ?", (org,)).fetchone() + return self.send_json(200, {"organization_id": org, "businesses": row["businesses"], "average_score": round(row["average_score"], 2), "suppressed": db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id = ?", (org,)).fetchone()[0]}) if path == "/api/v1/businesses": - query = parse_qs(parsed.query).get("q", [""])[0].strip() + query = parse_qs(parsed.query).get("q", [""])[0].strip(); params = [org] + sql = "SELECT * FROM businesses WHERE organization_id = ?" if query: - like = f"%{query}%" - rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?) ORDER BY score DESC, id", (ORGANIZATION_ID, like, like, like)).fetchall() - else: - rows = db.execute("SELECT * FROM businesses WHERE organization_id = ? ORDER BY score DESC, id", (ORGANIZATION_ID,)).fetchall() - return self.send_json(200, {"organization_id": ORGANIZATION_ID, "items": [row_json(r) for r in rows]}) + like = f"%{query}%"; sql += " AND (name LIKE ? OR website_domain LIKE ? OR email LIKE ?)"; params += [like] * 3 + rows = db.execute(sql + " ORDER BY score DESC, id", params).fetchall() + return self.send_json(200, {"organization_id": org, "items": [row_json(r) for r in rows]}) if path.startswith("/api/v1/businesses/"): ident = path.rsplit("/", 1)[1] if not ident.isdigit(): return self.send_json(404, {"error": "not_found"}) - row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), ORGANIZATION_ID)).fetchone() + row = db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (int(ident), org)).fetchone() return self.send_json(200, row_json(row)) if row else self.send_json(404, {"error": "not_found"}) return self.send_json(404, {"error": "not_found"}) - finally: - db.close() + finally: db.close() def do_POST(self): path = urlparse(self.path).path.rstrip("/") - payload = self.read_json() - if path == "/api/v1/businesses": return self.create_business(payload) - if path == "/api/v1/suppressions": return self.create_suppression(payload) - if path == "/api/v1/imports/preview": return self.preview_import(payload) - return self.send_json(404, {"error": "not_found"}) - - def create_business(self, payload): - if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"}) - business = normalize_business(payload) + if path == "/api/v1/auth/login": return self.login(self.read_json()) db = self.db() try: - if is_suppressed(business, suppression_rows(db)): return self.send_json(409, {"error": "suppressed"}) - identity_fields = [("website_domain", business["website_domain"]), ("email", business["email"]), ("phone", business["phone"])] - identity_fields = [(column, value) for column, value in identity_fields if value] - if identity_fields: - predicates = " OR ".join(f"{column} = ?" for column, _ in identity_fields) - values = [value for _, value in identity_fields] - if db.execute(f"SELECT id FROM businesses WHERE organization_id = ? AND ({predicates})", [ORGANIZATION_ID, *values]).fetchone(): - return self.send_json(409, {"error": "duplicate"}) - scored = score_business(business) - cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (ORGANIZATION_ID, business["name"], business["website"], business["website_domain"], business["email"], business["phone"], str(business.get("description", "")), scored["score"], scored["score_version"], json.dumps(scored["factors"]), scored["website_class"])) - db.commit() - return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ?", (cur.lastrowid,)).fetchone())) + user = self.require_auth(db) + if not user: return + if path == "/api/v1/auth/logout": + cookie = SimpleCookie(); cookie.load(self.headers.get("Cookie", "")); token = cookie.get("session") + if token: db.execute("DELETE FROM sessions WHERE token_hash = ?", (hashlib.sha256(token.value.encode()).hexdigest(),)) + db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "logout")); db.commit() + return self.send_json(200, {"ok": True}, {"Set-Cookie": self.auth_cookie("", 0)}) + if user["role"] not in MUTATING_ROLES: return self.send_json(403, {"error": "forbidden"}) + payload = self.read_json() + if path == "/api/v1/businesses": return self.create_business(payload, db, user["organization_id"]) + if path == "/api/v1/suppressions": return self.create_suppression(payload, db, user["organization_id"]) + if path == "/api/v1/imports/preview": return self.preview_import(payload, db, user["organization_id"]) + return self.send_json(404, {"error": "not_found"}) finally: db.close() - def create_suppression(self, payload): + def login(self, payload): + email = str(payload.get("email", "")).strip().lower(); password = str(payload.get("password", "")); db = self.db() + try: + user = db.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone() + if not user or not verify_password(password, user["password_hash"], user["password_salt"]): return self.send_json(401, {"error": "invalid_credentials"}) + token = secrets.token_urlsafe(32); expires = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS) + db.execute("INSERT INTO sessions (user_id,token_hash,expires_at) VALUES (?,?,?)", (user["id"], hashlib.sha256(token.encode()).hexdigest(), expires.replace(microsecond=0).isoformat())) + db.execute("INSERT INTO audit_log (organization_id,user_id,action) VALUES (?,?,?)", (user["organization_id"], user["id"], "login")); db.commit() + return self.send_json(200, {"id": user["id"], "email": user["email"], "role": user["role"], "organization_id": user["organization_id"]}, {"Set-Cookie": self.auth_cookie(token, int(timedelta(days=SESSION_DAYS).total_seconds()))}) + finally: db.close() + + def create_business(self, payload, db, org): + if not str(payload.get("name", "")).strip(): return self.send_json(400, {"error": "name_required"}) + business = normalize_business(payload); suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))] + if is_suppressed(business, suppressions): return self.send_json(409, {"error": "suppressed"}) + fields = [(c, business[c]) for c in ("website_domain", "email", "phone") if business[c]] + if fields and db.execute("SELECT id FROM businesses WHERE organization_id = ? AND (" + " OR ".join(f"{c} = ?" for c, _ in fields) + ")", [org] + [v for _, v in fields]).fetchone(): return self.send_json(409, {"error": "duplicate"}) + scored = score_business(business); cur = db.execute("INSERT INTO businesses (organization_id,name,website,website_domain,email,phone,description,score,score_version,score_factors,website_class) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (org,business["name"],business["website"],business["website_domain"],business["email"],business["phone"],str(business.get("description", "")),scored["score"],scored["score_version"],json.dumps(scored["factors"]),scored["website_class"])) + db.commit(); return self.send_json(201, row_json(db.execute("SELECT * FROM businesses WHERE id = ? AND organization_id = ?", (cur.lastrowid, org)).fetchone())) + + def create_suppression(self, payload, db, org): kind, value = payload.get("kind"), str(payload.get("value", "")).strip().lower() if kind not in {"email", "domain", "phone"} or not value: return self.send_json(400, {"error": "invalid_suppression"}) - db = self.db() - try: - try: - db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (ORGANIZATION_ID, kind, value)); db.commit() - except sqlite3.IntegrityError: pass - row = db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (ORGANIZATION_ID, kind, value)).fetchone() - return self.send_json(201, dict(row)) - finally: db.close() + try: db.execute("INSERT INTO suppressions (organization_id,kind,value) VALUES (?,?,?)", (org, kind, value)); db.commit() + except sqlite3.IntegrityError: pass + return self.send_json(201, dict(db.execute("SELECT * FROM suppressions WHERE organization_id = ? AND kind = ? AND value = ?", (org, kind, value)).fetchone())) - def preview_import(self, payload): + def preview_import(self, payload, db, org): rows = payload.get("rows", []) if not isinstance(rows, list): return self.send_json(400, {"error": "rows_required"}) normalized = deduplicate_businesses([r for r in rows if isinstance(r, dict) and str(r.get("name", "")).strip()]) - db = self.db() - try: - suppressions = suppression_rows(db); existing = existing_businesses(db); seen = set() - accepted, duplicate, suppressed = [], 0, 0 - for b in normalized: - key = deduplication_key(b) - if is_suppressed(b, suppressions): suppressed += 1 - elif (key in {deduplication_key(x) for x in existing} or key in seen): duplicate += 1 - else: seen.add(key); accepted.append(b) - return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted}) - finally: db.close() + suppressions = [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id = ?", (org,))]; existing = [row_json(r) for r in db.execute("SELECT * FROM businesses WHERE organization_id = ?", (org,))]; seen = set(); accepted, duplicate, suppressed = [], 0, 0 + existing_keys = {deduplication_key(x) for x in existing} + for b in normalized: + key = deduplication_key(b) + if is_suppressed(b, suppressions): suppressed += 1 + elif key in existing_keys or key in seen: duplicate += 1 + else: seen.add(key); accepted.append(b) + return self.send_json(200, {"accepted": len(accepted), "duplicates": duplicate + len(rows) - len(normalized), "suppressed": suppressed, "rows": accepted}) def log_message(self, *_): pass def create_server(host="127.0.0.1", port=8000, db_path="prospects.db"): - server = ThreadingHTTPServer((host, port), ApiHandler) - server.db_path = db_path - connect(db_path).close() - return server + server = ThreadingHTTPServer((host, port), ApiHandler); setattr(server, "db_path", db_path); connect(db_path).close(); return server if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Prospect Platform API") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000"))) - parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db")) - args = parser.parse_args() - server = create_server(args.host, args.port, args.db) - print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True) + parser = argparse.ArgumentParser(description="Prospect Platform API"); parser.add_argument("--host", default="127.0.0.1"); parser.add_argument("--port", type=int, default=int(os.environ.get("PROSPECT_API_PORT", "8000"))); parser.add_argument("--db", default=os.environ.get("PROSPECT_API_DB", "prospects.db")); args = parser.parse_args(); server = create_server(args.host, args.port, args.db); print(f"Prospect API listening on http://{args.host}:{args.port}", flush=True) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 3a78137..ab86073 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -1,4 +1,41 @@ PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT NOT NULL REFERENCES organizations(id), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('viewer','owner','admin','researcher')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT REFERENCES organizations(id), + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_audit_org ON audit_log(organization_id); + CREATE TABLE IF NOT EXISTS businesses ( id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, @@ -18,6 +55,7 @@ CREATE INDEX IF NOT EXISTS idx_businesses_org ON businesses(organization_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_business_domain ON businesses(organization_id, website_domain) WHERE website_domain <> ''; CREATE UNIQUE INDEX IF NOT EXISTS uq_business_email ON businesses(organization_id, email) WHERE email <> ''; CREATE UNIQUE INDEX IF NOT EXISTS uq_business_phone ON businesses(organization_id, phone) WHERE phone <> ''; + CREATE TABLE IF NOT EXISTS suppressions ( id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL, diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index ba0e448..6fca9aa 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -1,31 +1,82 @@ import json +import os +import sqlite3 import threading import unittest from http.client import HTTPConnection from tempfile import TemporaryDirectory -from app.main import create_server +from app.main import create_server, hash_password class ApiSmokeTests(unittest.TestCase): def setUp(self): self.tmp = TemporaryDirectory() - self.server = create_server("127.0.0.1", 0, self.tmp.name + "/test.db") + self.old_env = {key: os.environ.get(key) for key in ("BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")} + os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test" + os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password" + self.db_path = self.tmp.name + "/test.db" + self.server = create_server("127.0.0.1", 0, self.db_path) self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3) + self.cookie = None + status, _ = self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"}) + self.assertEqual(status, 200) def tearDown(self): self.server.shutdown() self.server.server_close() self.thread.join(timeout=2) + for key, value in self.old_env.items(): + if value is None: os.environ.pop(key, None) + else: os.environ[key] = value self.tmp.cleanup() - def request(self, method, path, payload=None): + def request(self, method, path, payload=None, cookie=True): body = json.dumps(payload).encode() if payload is not None else None - self.conn.request(method, path, body, {"Content-Type": "application/json"} if body else {}) + headers = {"Content-Type": "application/json"} if body else {} + if cookie and self.cookie: headers["Cookie"] = self.cookie + self.conn.request(method, path, body, headers) response = self.conn.getresponse() - return response.status, json.loads(response.read()) + set_cookie = response.getheader("Set-Cookie") + if set_cookie and "session=" in set_cookie: + self.cookie = set_cookie.split(";", 1)[0] + return response.status, json.loads(response.read() or b"{}") + + def test_auth_login_me_logout_and_protected_route(self): + self.assertEqual(self.request("GET", "/api/v1/auth/me")[0], 200) + self.assertEqual(self.request("POST", "/api/v1/auth/logout")[0], 200) + self.assertEqual(self.request("GET", "/api/v1/dashboard/summary")[0], 401) + + def test_viewer_cannot_mutate(self): + password_hash, salt = hash_password("viewer-password") + db = sqlite3.connect(self.db_path) + db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("demo-tenant", "viewer@example.test", password_hash, salt, "viewer")) + db.commit(); db.close() + self.cookie = None + self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "viewer@example.test", "password": "viewer-password"})[0], 200) + self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Nope"})[0], 403) + + def test_cross_organization_businesses_are_isolated(self): + password_hash, salt = hash_password("other-password") + db = sqlite3.connect(self.db_path) + db.execute("INSERT INTO organizations (id,name) VALUES (?,?)", ("other-tenant", "Other")) + db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant", "other@example.test", password_hash, salt, "owner")) + db.commit(); db.close() + self.cookie = None + self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200) + self.assertEqual(self.request("POST", "/api/v1/businesses", {"name": "Other Co", "website": "https://other.test"})[0], 201) + self.assertEqual(self.request("GET", "/api/v1/businesses")[1]["organization_id"], "other-tenant") + self.cookie = None + self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "owner@example.test", "password": "development-password"})[0], 200) + status, businesses = self.request("GET", "/api/v1/businesses") + self.assertEqual(status, 200) + self.assertEqual(businesses["items"], []) + + def test_missing_auth_is_rejected(self): + self.cookie = None + self.assertEqual(self.request("GET", "/api/v1/businesses", cookie=False)[0], 401) def test_health_create_get_summary_and_import_preview(self): self.assertEqual(self.request("GET", "/api/v1/health/live")[0], 200) diff --git a/apps/web/app.js b/apps/web/app.js index 341eadc..935ca70 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -3,16 +3,9 @@ 'use strict'; const API_BASE = (window.API_BASE || localStorage.getItem('prospect_api_base') || '').replace(/\/$/, ''); const endpoint = (path) => `${API_BASE}${path}`; - const demoProspects = [ - {id:1,name:'Northstar Creative',website:'https://northstarcreative.co.za',website_domain:'northstarcreative.co.za',location:'Cape Town, ZA',score:92,score_factors:['named_business','business_site','email','phone'],email:'hello@northstarcreative.co.za',phone:'+27215550101',updated_at:'2026-08-31T09:00:00Z',status:'reviewed',confidence:'High'}, - {id:2,name:'Berg & Bloom',website:'https://bergandbloom.co.za',website_domain:'bergandbloom.co.za',location:'Johannesburg, ZA',score:78,score_factors:['named_business','business_site','description'],description:'Independent retail studio',updated_at:'2026-08-29T09:00:00Z',status:'review',confidence:'Medium'}, - {id:3,name:'Mosaic Studio',website:'',website_domain:'',location:'Durban, ZA',score:45,score_factors:['named_business'],updated_at:'2026-08-12T09:00:00Z',status:'review',confidence:'Low'}, - {id:4,name:'Cedar Works',website:'https://cedarworks.co.za',website_domain:'cedarworks.co.za',location:'Pretoria, ZA',score:83,score_factors:['named_business','business_site','phone'],phone:'+27125550102',updated_at:'2026-08-30T09:00:00Z',status:'reviewed',confidence:'High'}, - {id:5,name:'Studio Lumen',website:'https://instagram.com/studiolumen',website_domain:'instagram.com',location:'Gqeberha, ZA',score:55,score_factors:['named_business'],updated_at:'2026-08-20T09:00:00Z',status:'suppressed',suppressed:true,suppression_reason:'Suppressed by domain match',confidence:'Low'}, - {id:6,name:'Field Notes Co.',website:'https://fieldnotes.example',website_domain:'fieldnotes.example',location:'Cape Town, ZA',score:67,score_factors:['named_business','business_site'],updated_at:'2026-08-25T09:00:00Z',status:'review',confidence:'Medium'} - ]; let prospects = []; let selectedId = null; + let currentUser = null; const $ = (id) => document.getElementById(id); const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const scoreClass = (score) => score >= 80 ? 'high' : score >= 60 ? 'medium' : 'low'; @@ -25,6 +18,24 @@ }; const scoreFor = (p) => Number.isFinite(Number(p.score)) ? Number(p.score) : (p.score_factors || p.factors || []).reduce((n, f) => n + ({named_business:20,business_site:30,email:25,phone:15,description:10}[f] || 0), 0); const labelFactor = (f) => ({named_business:'Named business',business_site:'Business site',email:'Email found',phone:'Phone found',description:'Description'}[f] || String(f).replaceAll('_',' ')); + async function request(path, options = {}) { + const response = await fetch(endpoint(path), {...options, credentials:'include'}); + if (response.status === 401) { showLogin('Your session has expired. Please sign in again.'); throw new Error('unauthorized'); } + return response; + } + function showLogin(message = '') { + currentUser = null; + $('dashboardShell').hidden = true; $('loginScreen').hidden = false; + $('loginMessage').textContent = message; $('loginMessage').className = `form-message${message ? ' error' : ''}`; + } + function showDashboard(user) { + currentUser = user || {}; + const name = currentUser.name || currentUser.full_name || currentUser.email || 'Workspace member'; + const role = currentUser.role || currentUser.roles?.[0] || 'Member'; + $('userIdentity').textContent = `${name} · ${role}`; + $('userAvatar').textContent = name.split(/\s+/).map(x => x[0]).join('').slice(0,2).toUpperCase(); + $('loginScreen').hidden = true; $('dashboardShell').hidden = false; + } function renderMetrics(summary) { const total = Number(summary?.businesses ?? summary?.total ?? prospects.length); const high = prospects.filter(p => scoreFor(p) >= 80).length; @@ -48,13 +59,16 @@ $('detailPanel').innerHTML = `

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain || 'no detected website')}

${st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1)}
Fit score${s}/ 100
${esc(p.confidence || (s>=80?'High':s>=60?'Medium':'Low'))} confidence

Evidence & signals

${factors.length ? factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence || 'Medium')}

`).join('') : '

Limited evidence available for this record.

'}

Data quality

Last checked${f.label}

Website${p.website_domain?'Detected':'no detected website'}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`; } async function loadData() { - $('apiStatus').textContent = API_BASE ? '● Connecting…' : '● Demo data'; - try { const [listRes, summaryRes] = await Promise.all([fetch(endpoint('/api/v1/businesses')), fetch(endpoint('/api/v1/dashboard/summary'))]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { prospects=demoProspects; renderMetrics(null); $('apiStatus').textContent=API_BASE?'● API unavailable · demo data':'● Demo data'; } + $('apiStatus').textContent='● Connecting…'; $('apiStatus').classList.remove('live'); + try { const [listRes, summaryRes] = await Promise.all([request('/api/v1/businesses'), request('/api/v1/dashboard/summary')]); if (!listRes.ok || !summaryRes.ok) throw new Error('API request failed'); const list=await listRes.json(), summary=await summaryRes.json(); prospects=Array.isArray(list)?list:(list.businesses||list.items||[]); renderMetrics(summary); $('apiStatus').textContent='● API connected'; $('apiStatus').classList.add('live'); } catch (error) { if (error.message === 'unauthorized') { return; } prospects=[]; renderMetrics(null); $('apiStatus').textContent='● API unavailable'; } renderRows(); } - async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); if (!API_BASE) { prospects.unshift({...data,id:`local-${Date.now()}`,score:data.website?50:20,status:'review',confidence:'Low'}); msg.textContent='Added to local preview review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); return; } try { const res=await fetch(endpoint('/api/v1/businesses'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { msg.textContent=e.message; msg.className='form-message error'; } } + async function addProspect(event) { event.preventDefault(); const data=Object.fromEntries(new FormData(event.currentTarget).entries()); const msg=$('formMessage'); try { const res=await request('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const body=await res.json(); if(!res.ok) throw new Error(body.error||'Could not add prospect'); prospects.unshift(body); msg.textContent='Added to review queue.'; event.currentTarget.reset(); renderMetrics(null); renderRows(); } catch(e) { if(e.message !== 'unauthorized') { msg.textContent=e.message; msg.className='form-message error'; } } } function parseCsv(text) { const lines=text.trim().split(/\r?\n/).filter(Boolean), cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[]; if(!lines.length)return []; const headers=cells(lines[0]); return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v]))); } function renderCsv(rows) { if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;} const h=Object.keys(rows[0]); $('csvPreview').className='csv-table'; $('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`; } - $('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); - loadData(); + async function login(event) { event.preventDefault(); const form=event.currentTarget, message=$('loginMessage'); const data=Object.fromEntries(new FormData(form).entries()); message.textContent='Signing in…'; message.className='form-message'; try { const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)}); const body=await res.json().catch(()=>({})); if(!res.ok) throw new Error(body.error || 'Invalid email or password.'); await bootstrap(); } catch(e) { if(e.message !== 'unauthorized') { message.textContent=e.message; message.className='form-message error'; } } } + async function logout() { try { await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'}); } finally { showLogin('You have been signed out.'); $('loginForm').reset(); } } + async function bootstrap() { try { const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'}); if(res.status===401) { showLogin(); return; } if(!res.ok) throw new Error('Could not verify session.'); const user=await res.json(); showDashboard(user.user || user); await loadData(); } catch(e) { if(e.message !== 'unauthorized') showLogin('Unable to connect to the workspace. Try again.'); } } + $('loginForm').addEventListener('submit',login); $('logoutBtn').addEventListener('click',logout); $('searchInput').addEventListener('input',renderRows); $('scoreFilter').addEventListener('change',renderRows); $('statusFilter').addEventListener('change',renderRows); $('refreshBtn').addEventListener('click',loadData); $('addForm').addEventListener('submit',addProspect); $('csvInput').addEventListener('change',e=>{const file=e.target.files[0]; if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}}); $('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open')); document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); + bootstrap(); })(); diff --git a/apps/web/index.html b/apps/web/index.html index e671990..6fc5f8f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,7 +8,22 @@ -
+ +