From b4da163189d5ee21321ad5e27f82c5868c66a98e Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 12 Jul 2026 15:31:53 -0700 Subject: [PATCH] [Slackbot Config] Allow us to configure the Slackbot from admin instead of env vars --- .env.example | 4 + CLAUDE.md | 6 + api/__init__.py | 2 + api/praisebot/__init__.py | 0 api/praisebot/praisebot_service.py | 325 ++++++++++++++++++ api/praisebot/praisebot_views.py | 69 ++++ api/praisebot/tests/__init__.py | 0 api/praisebot/tests/test_praisebot_service.py | 180 ++++++++++ common/utils/api_key.py | 20 ++ 9 files changed, 606 insertions(+) create mode 100644 api/praisebot/__init__.py create mode 100644 api/praisebot/praisebot_service.py create mode 100644 api/praisebot/praisebot_views.py create mode 100644 api/praisebot/tests/__init__.py create mode 100644 api/praisebot/tests/test_praisebot_service.py create mode 100644 common/utils/api_key.py diff --git a/.env.example b/.env.example index 1a994b9..330377a 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ ENC_DEC_KEY="DISABLED" SLACK_BOT_TOKEN=AKEYTODO SLACK_WEBHOOK=WEBHOOKTHINGTODO +# Shared secret the praise-bot uses to read /api/praise-bot/config +# (falls back to BACKEND_PRAISE_TOKEN if unset) +BACKEND_BOT_CONFIG_TOKEN=CHANGEME + diff --git a/CLAUDE.md b/CLAUDE.md index eeb3296..2102383 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,3 +165,9 @@ Read-only admin aggregation for the frontend `/admin/feedback` dashboard. New bl - `GET /api/admin/feedback/peer` — peer-to-peer `feedback` collection, newest first, giver/receiver names resolved via a one-shot `fetch_users()` directory (giver hidden when `is_anonymous`); light `by_relationship`/`by_role` summary. - `GET /api/admin/feedback/onboarding` — `onboarding_feedbacks` collection, newest first, + rating & ease distributions. Keeps `clientInfo.userAgent`, drops the IP. **Field shape (load-bearing for the admin UI)**: maps `contactForFollowup` → `contact: {willing, firstName, email}` (the form stores `firstName`+`willing`, NOT `name` — don't revert to `name`); `overallRating` 0 = unrated (skipped in the average); timestamps are normalized to ISO, stripping a `__Timestamp__` export sentinel (see `scripts/sync_hackathons_from_csv.py`) if present. Event surveys reuse the `api/surveys` admin routes (`/responses`, `/summary`, `/overview`) — not duplicated here. + +## Praise-bot remote config (`api/praisebot/`, `praise_bot_config` collection) +Config store for the Slack praise-bot (repo `ohack-slack-bot/praise-bot`): the bot polls it every ~60s so channels/repos/crons/toggles are managed from `/admin/praise-bot` instead of Fly.io env vars. Blueprint `api/praisebot/praisebot_views.py` + `praisebot_service.py`. +- Doc types in one collection: fixed doc id `global` (dry_run/llm_enabled/timezone), `github_watcher` (source `mode: hackathon|repos`, digest + optional rollup crons), `calendar_reminder`, and singleton `community` (intro matchmaker + weekly digest). Audit fields `created_at/updated_at/updated_by` on every doc. +- `GET /api/praise-bot/config` — bot-facing, authed via `X-Api-Key` against `BACKEND_BOT_CONFIG_TOKEN` (falls back to `BACKEND_PRAISE_TOKEN`) through the shared `common/utils/api_key.py:check_api_key` (hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returns `configured: false` when the collection is empty → bot uses its env defaults. +- `GET/POST /api/praise-bot/admin/config`, `PATCH/DELETE /api/praise-bot/admin/config/` — `volunteer.admin`-gated. Validation is whitelist-per-type (`_ALLOWED_KEYS` — the enforcement point that keeps secrets out of docs); crons validated as 5 fields (bot re-validates with `cron.validate()`); repos normalized to `owner/repo`; `global` is upsert-only (no DELETE); `community` is a singleton (POST 400s if one exists). `source.orgs` is accepted/stored but ignored by the bot until org-watching ships. 15s TTL cache on the assembled config, cleared on mutation. Tests: `api/praisebot/tests/` (mockfirestore, run with `ENVIRONMENT=test`). diff --git a/api/__init__.py b/api/__init__.py index b181a04..8e75bd8 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -189,6 +189,7 @@ def add_headers(response): from api.email_templates import email_templates_views from api.surveys import surveys_views from api.feedback import feedback_views + from api.praisebot import praisebot_views app.register_blueprint(messages_views.bp) app.register_blueprint(exception_views.bp) @@ -213,5 +214,6 @@ def add_headers(response): app.register_blueprint(email_templates_views.bp) app.register_blueprint(surveys_views.bp) app.register_blueprint(feedback_views.bp) + app.register_blueprint(praisebot_views.bp) return app diff --git a/api/praisebot/__init__.py b/api/praisebot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/praisebot/praisebot_service.py b/api/praisebot/praisebot_service.py new file mode 100644 index 0000000..03b6340 --- /dev/null +++ b/api/praisebot/praisebot_service.py @@ -0,0 +1,325 @@ +"""Service layer for praise-bot configuration. + +Stores bot behavior (channels, repos, cron schedules, feature toggles) in the +`praise_bot_config` Firestore collection so the Slack bot can be reconfigured +from /admin without touching Fly.io env vars. Secrets never live here — the +per-type key whitelists below are the enforcement point. + +Doc types: + - global (fixed doc id "global": dry_run, llm_enabled, timezone) + - github_watcher (repo-tpm digest/rollup config) + - calendar_reminder (Google Calendar ICS reminder config) + - community (#introductions matchmaker + weekly community digest) +""" +import re +import threading +import time +import uuid +from datetime import datetime, timezone + +from db.db import get_db +from common.log import get_logger + +logger = get_logger(__name__) + +COLLECTION = "praise_bot_config" +GLOBAL_DOC_ID = "global" + +# 5 whitespace-separated cron fields; the bot re-validates with cron.validate() +_CRON_RE = re.compile(r"^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+\s*$") +_REPO_SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$") +_REPO_URL_RE = re.compile(r"github\.com/([\w.-]+)/([\w.-]+)", re.IGNORECASE) + +_ALLOWED_KEYS = { + "global": {"dry_run", "llm_enabled", "timezone"}, + "github_watcher": {"name", "enabled", "source", "digest", "rollup", "dry_run"}, + "calendar_reminder": { + "name", "enabled", "calendar_id", "channels", "lead_minutes", + "events_page_url", "poll_cron", + }, + "community": { + "name", "enabled", "intro_channel", "matchmaker", "digest", + "lookback_days", "dry_run", + }, +} +DOC_TYPES = set(_ALLOWED_KEYS.keys()) + +# Tiny last-good cache so the bot's 60s poll doesn't hammer Firestore +_cache_lock = threading.Lock() +_cache = {"value": None, "at": 0.0} +_CACHE_TTL_SECONDS = 15 + + +def _now_iso(): + return datetime.now(timezone.utc).isoformat() + + +def _filter_payload(doc_type, json_in): + allowed = _ALLOWED_KEYS[doc_type] + return {k: v for k, v in (json_in or {}).items() if k in allowed} + + +def _normalize_repo(repo): + """Accept 'owner/repo' or a full GitHub URL; return 'owner/repo' or None.""" + if not isinstance(repo, str): + return None + repo = repo.strip() + if _REPO_SHORTHAND_RE.match(repo): + return repo + m = _REPO_URL_RE.search(repo) + if m: + return f"{m.group(1)}/{m.group(2).removesuffix('.git')}" + return None + + +def _normalize_channels(channels): + """Accept a list or comma-separated string of channel names/IDs.""" + if isinstance(channels, str): + channels = channels.split(",") + if not isinstance(channels, list): + return [] + return [c.strip().lstrip("#") for c in channels if isinstance(c, str) and c.strip()] + + +def _validate_cron(expr, field, errors, required=False): + if expr in (None, ""): + if required: + errors.append(f"{field} is required") + return + if not isinstance(expr, str) or not _CRON_RE.match(expr): + errors.append(f"{field} must be a 5-field cron expression") + + +def _validate_doc(doc_type, payload): + """Validate + normalize a filtered payload in place. Returns error list.""" + errors = [] + + if doc_type == "global": + for key in ("dry_run", "llm_enabled"): + if key in payload and not isinstance(payload[key], bool): + errors.append(f"{key} must be a boolean") + if "timezone" in payload and not isinstance(payload["timezone"], str): + errors.append("timezone must be a string") + + elif doc_type == "github_watcher": + source = payload.get("source") + if source is not None: + if not isinstance(source, dict): + errors.append("source must be an object") + else: + mode = source.get("mode") + if mode not in ("hackathon", "repos"): + errors.append("source.mode must be 'hackathon' or 'repos'") + elif mode == "hackathon": + if not source.get("event_id"): + errors.append("source.event_id is required in hackathon mode") + else: + repos = [_normalize_repo(r) for r in source.get("repos") or []] + if not repos or None in repos: + errors.append( + "source.repos must be a non-empty list of owner/repo or GitHub URLs") + else: + source["repos"] = repos + channels = _normalize_channels(source.get("channels")) + if not channels: + errors.append("source.channels is required in repos mode") + else: + source["channels"] = channels + # Accepted and stored now, ignored by the bot until org + # watching ships — avoids a schema migration later. + if "orgs" in source and not isinstance(source["orgs"], list): + errors.append("source.orgs must be a list") + for section, cron_required in (("digest", True), ("rollup", False)): + block = payload.get(section) + if block is None: + continue + if not isinstance(block, dict): + errors.append(f"{section} must be an object") + continue + if block.get("enabled"): + _validate_cron(block.get("cron"), f"{section}.cron", errors, required=cron_required) + elif block.get("cron"): + _validate_cron(block.get("cron"), f"{section}.cron", errors) + rollup = payload.get("rollup") + if isinstance(rollup, dict) and rollup.get("enabled") and not rollup.get("channel"): + errors.append("rollup.channel is required when rollup is enabled") + + elif doc_type == "calendar_reminder": + if "channels" in payload: + channels = _normalize_channels(payload["channels"]) + if not channels: + errors.append("channels must be a non-empty list") + else: + payload["channels"] = channels + if "lead_minutes" in payload: + lead = payload["lead_minutes"] + if not isinstance(lead, int) or not 1 <= lead <= 240: + errors.append("lead_minutes must be an integer between 1 and 240") + _validate_cron(payload.get("poll_cron"), "poll_cron", errors) + + elif doc_type == "community": + for section in ("matchmaker", "digest"): + block = payload.get(section) + if block is not None and not isinstance(block, dict): + errors.append(f"{section} must be an object") + digest = payload.get("digest") + if isinstance(digest, dict) and digest.get("enabled"): + _validate_cron(digest.get("cron"), "digest.cron", errors, required=True) + if not digest.get("channel"): + errors.append("digest.channel is required when the community digest is enabled") + matchmaker = payload.get("matchmaker") + if isinstance(matchmaker, dict) and "max_matches" in matchmaker: + mm = matchmaker["max_matches"] + if not isinstance(mm, int) or not 1 <= mm <= 10: + errors.append("matchmaker.max_matches must be an integer between 1 and 10") + if "lookback_days" in payload: + lb = payload["lookback_days"] + if not isinstance(lb, int) or not 1 <= lb <= 3650: + errors.append("lookback_days must be an integer between 1 and 3650") + + return errors + + +def _clear_cache(): + with _cache_lock: + _cache["value"] = None + _cache["at"] = 0.0 + + +def _all_docs(): + docs = [] + for doc in get_db().collection(COLLECTION).stream(): + adict = doc.to_dict() or {} + adict["id"] = doc.id + docs.append(adict) + return docs + + +def get_full_config(include_audit=False): + """Assemble the config payload for the bot (and the admin UI). + + include_audit=True keeps created_at/updated_at/updated_by on each doc. + """ + if not include_audit: + with _cache_lock: + if _cache["value"] is not None and time.time() - _cache["at"] < _CACHE_TTL_SECONDS: + return _cache["value"] + + docs = _all_docs() + audit_keys = () if include_audit else ("created_at", "updated_at", "updated_by") + + global_cfg = {"dry_run": False, "llm_enabled": True} + github_watchers = [] + calendar_reminders = [] + community = None + + for doc in docs: + doc_type = doc.get("type") + cleaned = {k: v for k, v in doc.items() if k not in audit_keys and k != "type"} + if doc.get("id") == GLOBAL_DOC_ID or doc_type == "global": + cleaned.pop("id", None) + global_cfg.update(cleaned) + elif doc_type == "github_watcher": + github_watchers.append(cleaned) + elif doc_type == "calendar_reminder": + calendar_reminders.append(cleaned) + elif doc_type == "community": + community = cleaned + else: + logger.warning("Ignoring praise_bot_config doc %s with unknown type %s", + doc.get("id"), doc_type) + + github_watchers.sort(key=lambda d: d.get("name") or "") + calendar_reminders.sort(key=lambda d: d.get("name") or "") + + result = { + "configured": len(docs) > 0, + "global": global_cfg, + "github_watchers": github_watchers, + "calendar_reminders": calendar_reminders, + "community": community, + } + + if not include_audit: + with _cache_lock: + _cache["value"] = result + _cache["at"] = time.time() + return result + + +def create_config_doc(json_in, actor): + doc_type = (json_in or {}).get("type") + if doc_type not in DOC_TYPES: + return {"error": f"type must be one of {sorted(DOC_TYPES)}"}, 400 + if doc_type == "global": + return {"error": "use PATCH /admin/config/global for global settings"}, 400 + if doc_type == "community": + existing = [d for d in _all_docs() if d.get("type") == "community"] + if existing: + return {"error": f"a community config already exists (id {existing[0]['id']}) — PATCH it instead"}, 400 + + payload = _filter_payload(doc_type, json_in) + errors = _validate_doc(doc_type, payload) + if errors: + return {"error": "; ".join(errors)}, 400 + + payload["type"] = doc_type + payload.setdefault("enabled", False) + payload["created_at"] = payload["updated_at"] = _now_iso() + payload["updated_by"] = actor + + doc_id = str(uuid.uuid4()) + get_db().collection(COLLECTION).document(doc_id).set(payload) + _clear_cache() + logger.info("Created praise_bot_config %s doc %s by %s", doc_type, doc_id, actor) + return {"id": doc_id}, 201 + + +def update_config_doc(doc_id, json_in, actor): + db = get_db() + + if doc_id == GLOBAL_DOC_ID: + payload = _filter_payload("global", json_in) + errors = _validate_doc("global", payload) + if errors: + return {"error": "; ".join(errors)}, 400 + payload["type"] = "global" + payload["updated_at"] = _now_iso() + payload["updated_by"] = actor + db.collection(COLLECTION).document(GLOBAL_DOC_ID).set(payload, merge=True) + _clear_cache() + return {"id": GLOBAL_DOC_ID}, 200 + + ref = db.collection(COLLECTION).document(doc_id) + snapshot = ref.get() + if not snapshot.exists: + return {"error": "not found"}, 404 + doc_type = (snapshot.to_dict() or {}).get("type") + if doc_type not in DOC_TYPES: + return {"error": f"document has unknown type {doc_type}"}, 400 + + payload = _filter_payload(doc_type, json_in) + if not payload: + return {"error": "no updatable fields in payload"}, 400 + errors = _validate_doc(doc_type, payload) + if errors: + return {"error": "; ".join(errors)}, 400 + + payload["updated_at"] = _now_iso() + payload["updated_by"] = actor + ref.update(payload) + _clear_cache() + logger.info("Updated praise_bot_config doc %s by %s", doc_id, actor) + return {"id": doc_id}, 200 + + +def delete_config_doc(doc_id): + if doc_id == GLOBAL_DOC_ID: + return {"error": "global settings cannot be deleted"}, 400 + ref = get_db().collection(COLLECTION).document(doc_id) + if not ref.get().exists: + return {"error": "not found"}, 404 + ref.delete() + _clear_cache() + logger.info("Deleted praise_bot_config doc %s", doc_id) + return {"id": doc_id}, 200 diff --git a/api/praisebot/praisebot_views.py b/api/praisebot/praisebot_views.py new file mode 100644 index 0000000..c070bd3 --- /dev/null +++ b/api/praisebot/praisebot_views.py @@ -0,0 +1,69 @@ +from flask import Blueprint, jsonify, request + +from api.praisebot.praisebot_service import ( + create_config_doc, + delete_config_doc, + get_full_config, + update_config_doc, +) +from common.auth import auth, auth_user +from common.log import get_logger +from common.utils.api_key import check_api_key + +logger = get_logger("praisebot_views") + +bp = Blueprint('praisebot', __name__, url_prefix='/api') + + +def getOrgId(req): + # Get the org_id from the req + return req.headers.get("X-Org-Id") + + +def _actor_from_request(): + try: + return { + "propel_user_id": auth_user.user_id if auth_user else None, + "email": getattr(auth_user, "email", None) if auth_user else None, + } + except Exception: + return None + + +@bp.route("/praise-bot/config", methods=["GET"]) +def bot_get_config(): + """Bot-facing config read, authed via X-Api-Key (no PropelAuth).""" + if not check_api_key(request, "BACKEND_BOT_CONFIG_TOKEN", "BACKEND_PRAISE_TOKEN"): + return "Unauthorized", 401 + return jsonify(get_full_config()) + + +@bp.route("/praise-bot/admin/config", methods=["GET"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_get_config(): + return jsonify(get_full_config(include_audit=True)) + + +@bp.route("/praise-bot/admin/config", methods=["POST"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_create_config(): + body, status = create_config_doc(request.get_json(), _actor_from_request()) + return jsonify(body), status + + +@bp.route("/praise-bot/admin/config/", methods=["PATCH"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_update_config(doc_id): + body, status = update_config_doc(doc_id, request.get_json(), _actor_from_request()) + return jsonify(body), status + + +@bp.route("/praise-bot/admin/config/", methods=["DELETE"]) +@auth.require_user +@auth.require_org_member_with_permission("volunteer.admin", req_to_org_id=getOrgId) +def admin_delete_config(doc_id): + body, status = delete_config_doc(doc_id) + return jsonify(body), status diff --git a/api/praisebot/tests/__init__.py b/api/praisebot/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/praisebot/tests/test_praisebot_service.py b/api/praisebot/tests/test_praisebot_service.py new file mode 100644 index 0000000..186e0b4 --- /dev/null +++ b/api/praisebot/tests/test_praisebot_service.py @@ -0,0 +1,180 @@ +"""Tests for the praise-bot config service (praise_bot_config collection).""" +import pytest +from unittest.mock import patch +from mockfirestore import MockFirestore + +from api.praisebot import praisebot_service as svc + + +ACTOR = {"propel_user_id": "test-user", "email": "greg@ohack.org"} + + +@pytest.fixture +def db(): + mock_db = MockFirestore() + svc._clear_cache() + with patch.object(svc, "get_db", return_value=mock_db): + yield mock_db + svc._clear_cache() + + +def _valid_watcher(**overrides): + doc = { + "type": "github_watcher", + "name": "Core repos", + "enabled": True, + "source": { + "mode": "repos", + "repos": ["opportunity-hack/frontend-ohack.dev", + "https://github.com/opportunity-hack/backend-ohack.dev"], + "channels": "ohack-dev, #general", + }, + "digest": {"enabled": True, "cron": "0 16 * * *"}, + "rollup": {"enabled": False}, + } + doc.update(overrides) + return doc + + +class TestGetFullConfig: + def test_empty_collection_is_unconfigured(self, db): + cfg = svc.get_full_config() + assert cfg["configured"] is False + assert cfg["github_watchers"] == [] + assert cfg["calendar_reminders"] == [] + assert cfg["community"] is None + assert cfg["global"] == {"dry_run": False, "llm_enabled": True} + + def test_assembles_docs_by_type_and_strips_audit(self, db): + svc.create_config_doc(_valid_watcher(), ACTOR) + svc.update_config_doc("global", {"dry_run": True}, ACTOR) + svc._clear_cache() + + cfg = svc.get_full_config() + assert cfg["configured"] is True + assert cfg["global"]["dry_run"] is True + assert len(cfg["github_watchers"]) == 1 + watcher = cfg["github_watchers"][0] + assert "updated_by" not in watcher + assert "id" in watcher + + admin_cfg = svc.get_full_config(include_audit=True) + assert admin_cfg["github_watchers"][0]["updated_by"] == ACTOR + + +class TestCreate: + def test_create_watcher_normalizes_repos_and_channels(self, db): + body, status = svc.create_config_doc(_valid_watcher(), ACTOR) + assert status == 201 + stored = db.collection(svc.COLLECTION).document(body["id"]).get().to_dict() + assert stored["source"]["repos"] == [ + "opportunity-hack/frontend-ohack.dev", + "opportunity-hack/backend-ohack.dev", + ] + assert stored["source"]["channels"] == ["ohack-dev", "general"] + + def test_create_rejects_bad_cron(self, db): + bad = _valid_watcher(digest={"enabled": True, "cron": "every day at 9"}) + body, status = svc.create_config_doc(bad, ACTOR) + assert status == 400 + assert "cron" in body["error"] + + def test_create_rejects_unknown_type_and_global(self, db): + assert svc.create_config_doc({"type": "nope"}, ACTOR)[1] == 400 + assert svc.create_config_doc({"type": "global"}, ACTOR)[1] == 400 + + def test_hackathon_mode_requires_event_id(self, db): + doc = _valid_watcher(source={"mode": "hackathon"}) + body, status = svc.create_config_doc(doc, ACTOR) + assert status == 400 + assert "event_id" in body["error"] + + def test_rollup_enabled_requires_channel(self, db): + doc = _valid_watcher(rollup={"enabled": True, "cron": "0 14 * * 1"}) + body, status = svc.create_config_doc(doc, ACTOR) + assert status == 400 + assert "rollup.channel" in body["error"] + + def test_secret_looking_keys_are_dropped(self, db): + doc = _valid_watcher(github_token="sekret", api_key="sekret") + body, status = svc.create_config_doc(doc, ACTOR) + assert status == 201 + stored = db.collection(svc.COLLECTION).document(body["id"]).get().to_dict() + assert "github_token" not in stored + assert "api_key" not in stored + + def test_calendar_reminder_bounds(self, db): + base = { + "type": "calendar_reminder", "name": "Office hours", "enabled": True, + "calendar_id": "abc@group.calendar.google.com", + "channels": ["general"], "lead_minutes": 15, + "poll_cron": "*/5 * * * *", + } + assert svc.create_config_doc(base, ACTOR)[1] == 201 + assert svc.create_config_doc({**base, "lead_minutes": 0}, ACTOR)[1] == 400 + assert svc.create_config_doc({**base, "lead_minutes": 500}, ACTOR)[1] == 400 + + def test_community_is_singleton(self, db): + community = { + "type": "community", "enabled": True, "intro_channel": "introductions", + "matchmaker": {"enabled": True, "max_matches": 3}, + "digest": {"enabled": True, "cron": "0 17 * * 1", "channel": "general"}, + } + assert svc.create_config_doc(community, ACTOR)[1] == 201 + body, status = svc.create_config_doc(community, ACTOR) + assert status == 400 + assert "already exists" in body["error"] + + +class TestUpdateDelete: + def test_global_upsert_and_delete_refused(self, db): + body, status = svc.update_config_doc("global", {"llm_enabled": False}, ACTOR) + assert status == 200 + svc._clear_cache() + assert svc.get_full_config()["global"]["llm_enabled"] is False + assert svc.delete_config_doc("global")[1] == 400 + + def test_update_validates_against_stored_type(self, db): + doc_id = svc.create_config_doc(_valid_watcher(), ACTOR)[0]["id"] + body, status = svc.update_config_doc( + doc_id, {"digest": {"enabled": True, "cron": "bad"}}, ACTOR) + assert status == 400 + + body, status = svc.update_config_doc(doc_id, {"enabled": False}, ACTOR) + assert status == 200 + stored = db.collection(svc.COLLECTION).document(doc_id).get().to_dict() + assert stored["enabled"] is False + assert stored["updated_by"] == ACTOR + + def test_update_and_delete_missing_doc_404(self, db): + assert svc.update_config_doc("nope", {"enabled": True}, ACTOR)[1] == 404 + assert svc.delete_config_doc("nope")[1] == 404 + + def test_delete_watcher(self, db): + doc_id = svc.create_config_doc(_valid_watcher(), ACTOR)[0]["id"] + assert svc.delete_config_doc(doc_id)[1] == 200 + svc._clear_cache() + assert svc.get_full_config()["configured"] is False + + +class TestApiKeyHelper: + def test_check_api_key(self, monkeypatch): + from common.utils.api_key import check_api_key + + class FakeRequest: + def __init__(self, key): + self.headers = {"X-Api-Key": key} if key else {} + + monkeypatch.delenv("BACKEND_BOT_CONFIG_TOKEN", raising=False) + monkeypatch.delenv("BACKEND_PRAISE_TOKEN", raising=False) + # No env configured -> fail closed + assert not check_api_key(FakeRequest("x"), "BACKEND_BOT_CONFIG_TOKEN", "BACKEND_PRAISE_TOKEN") + + monkeypatch.setenv("BACKEND_PRAISE_TOKEN", "fallback") + assert check_api_key(FakeRequest("fallback"), "BACKEND_BOT_CONFIG_TOKEN", "BACKEND_PRAISE_TOKEN") + + # Dedicated token takes precedence over fallback + monkeypatch.setenv("BACKEND_BOT_CONFIG_TOKEN", "primary") + assert check_api_key(FakeRequest("primary"), "BACKEND_BOT_CONFIG_TOKEN", "BACKEND_PRAISE_TOKEN") + assert not check_api_key(FakeRequest("fallback"), "BACKEND_BOT_CONFIG_TOKEN", "BACKEND_PRAISE_TOKEN") + assert not check_api_key(FakeRequest(None), "BACKEND_BOT_CONFIG_TOKEN") diff --git a/common/utils/api_key.py b/common/utils/api_key.py new file mode 100644 index 0000000..6e66b6b --- /dev/null +++ b/common/utils/api_key.py @@ -0,0 +1,20 @@ +import hmac +import os + +from flask import Request + + +def check_api_key(request: Request, *env_var_names: str) -> bool: + """Validate the X-Api-Key header against the first non-empty env var. + + Used for backend-to-backend (bot) auth where PropelAuth doesn't apply. + Returns False when no env var is configured so routes fail closed. + """ + provided = request.headers.get("X-Api-Key") + if not provided: + return False + for name in env_var_names: + expected = os.getenv(name) + if expected: + return hmac.compare_digest(provided, expected) + return False