diff --git a/Documentation.md b/Documentation.md index fc81fb8..73a735e 100644 --- a/Documentation.md +++ b/Documentation.md @@ -87,8 +87,10 @@ Flask app (`app.py`) + `waitress`, backed by Postgres (`smb-db`, `postgres:16-al - `PATCH /api//` — partial update, same auth. - `DELETE /api//` — delete, same auth. - `GET /api/bookings.ics` — read-only iCal feed for calendar apps (Apple/Google Calendar can't - send custom headers, so this is gated by a separate `?token=$ICS_TOKEN` query param instead of - the header token). Supports `?client_id=` to scope to one client. + send custom headers, so this is gated by a `?token=` query param instead of the header token). + The token is per-client (`clients.ics_token`, lazily generated on first visit to + `/owner/settings`), so it both authenticates and scopes the feed to that one client — there's + no separate `client_id` param to swap. - `GET /healthz` — DB connectivity check. - `GET /` — the dashboard (`static/index.html`; **Leads**, **Clients** and **Credentials** tabs — read + add (+Neu) + edit (✎) + delete (🗑), all token-gated, all audit-logged. The Credentials @@ -192,9 +194,10 @@ see `deploy/clients/README.md` and each group's own compose file for the exact r - Browser-facing surfaces (`onboard.mivanchenko.de`, dashboards) sit behind Caddy basic-auth. - Machine-to-machine writes (n8n → CRM) are gated by the `CRM_API_TOKEN` header, checked in `backoffice/app/app.py::authed()`. -- The iCal feed is gated by a separate query-string token (`ICS_TOKEN`) since calendar clients - can't send custom headers — treat that token as effectively public-linkable and rotate it if a - feed URL leaks. +- The iCal feed is gated by a per-client query-string token (`clients.ics_token`) since calendar + clients can't send custom headers — treat that token as effectively public-linkable and rotate + it (clear the column, a fresh one is generated on next `/owner/settings` visit) if a feed URL + leaks. - Credentials for client-owned accounts are recorded two ways: `clients.vault_ref` points to a Vaultwarden item for anything the operator manually stashes there; the `credentials` table holds secrets the *system itself* generates (currently: the Easy!Appointments provider login diff --git a/backoffice/app/app.py b/backoffice/app/app.py index 5b03da6..c07552c 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -38,9 +38,6 @@ app.register_blueprint(owner_settings_bp) app.secret_key = os.environ.get("SESSION_SECRET_KEY", "") CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") -# Separate read-only token for the public iCal feed (calendar apps can't send -# the basic-auth header, so the feed is gated by this query token instead). -ICS_TOKEN = os.environ.get("ICS_TOKEN", "") # Static dashboard, with the CRM token injected so the (basic-auth-gated) # operator page can call the token-protected mutation endpoints. @@ -256,21 +253,23 @@ def _ics_esc(t): @app.get("/api/bookings.ics") def bookings_ics(): - """Read-only iCal feed for Apple/Google Calendar subscription. Gated by the - ICS_TOKEN query param (no header auth, so calendar apps can fetch it).""" - if not ICS_TOKEN or request.args.get("token") != ICS_TOKEN: + """Read-only iCal feed for Apple/Google Calendar subscription, scoped to + one client via their own clients.ics_token (#22). No header auth, since + calendar apps can't send one -- gated by the query token instead, but + unlike the old shared ICS_TOKEN + client_id pair, the token itself + resolves the client, so there's no separate client_id param that could + be swapped to view another tenant's bookings.""" + client = bdb.get_client_by_ics_token(request.args.get("token")) + if client is None: return Response("forbidden\n", status=403, mimetype="text/plain") - cid = request.args.get("client_id") + cid = client["client_id"] with db.connect() as conn, conn.cursor() as cur: - if cid: - cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,)) - else: - cur.execute("SELECT * FROM bookings ORDER BY start_time") + cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,)) rows = cur.fetchall() now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//smb-crm//bookings//DE", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", - "X-WR-CALNAME:" + _ics_esc("Buchungen " + cid if cid else "Buchungen")] + "X-WR-CALNAME:" + _ics_esc("Buchungen " + cid)] for r in rows: st = _ics_dt(r.get("start_time")) if not st: diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 5407f43..8b1beac 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -340,6 +340,46 @@ def get_client_by_slug(slug): return cur.fetchone() +def get_client_by_ics_token(token): + """Resolve a client for the per-client ICS feed (#22) -- like + get_client_by_slug, this is the one lookup that goes straight from + untrusted request input (the ?token= query param) to a client_id, so the + feed can be scoped to exactly one tenant without a separate client_id + param that could be swapped independently of the token.""" + if not token: + return None + with db.connect() as conn, conn.cursor() as cur: + cur.execute("SELECT * FROM clients WHERE ics_token = %s", (token,)) + return cur.fetchone() + + +def ensure_ics_token(client_id): + """Return the client's ics_token, generating and persisting one on first + use (#22). Lazy rather than a one-off backfill migration, so clients + onboarded before this ticket still get a working subscribe URL the first + time their settings page loads. The UPDATE ... WHERE ics_token IS NULL + guard means a losing concurrent call re-reads the winner's token instead + of overwriting it -- same single-connection read-then-write shape as + consume_password_reset_token.""" + with db.connect() as conn, conn.cursor() as cur: + cur.execute("SELECT ics_token FROM clients WHERE client_id = %s", (client_id,)) + row = cur.fetchone() + if row is None: + return None + if row["ics_token"]: + return row["ics_token"] + cur.execute( + "UPDATE clients SET ics_token = %s WHERE client_id = %s AND ics_token IS NULL " + "RETURNING ics_token", + (secrets.token_urlsafe(24), client_id)) + row = cur.fetchone() + if row is None: + cur.execute("SELECT ics_token FROM clients WHERE client_id = %s", (client_id,)) + row = cur.fetchone() + conn.commit() + return row["ics_token"] + + _CLIENT_UPDATABLE = {"auto_confirm", "notify_channel"} diff --git a/backoffice/app/owner_settings.py b/backoffice/app/owner_settings.py index 1246924..fa5f03d 100644 --- a/backoffice/app/owner_settings.py +++ b/backoffice/app/owner_settings.py @@ -61,9 +61,11 @@ def settings(): hours_by_resource = { r["resource_id"]: bdb.get_resource_hours(client_id, r["resource_id"]) for r in resources} + ics_token = bdb.ensure_ics_token(client_id) return render_template( "owner/settings.html", client=client, services=bdb.list_services(client_id), resources=resources, hours_by_resource=hours_by_resource, weekdays=WEEKDAYS, + ics_url=url_for("bookings_ics", token=ics_token, _external=True), error=request.args.get("error")) diff --git a/backoffice/app/templates/owner/settings.html b/backoffice/app/templates/owner/settings.html index b449288..37c834a 100644 --- a/backoffice/app/templates/owner/settings.html +++ b/backoffice/app/templates/owner/settings.html @@ -155,6 +155,13 @@ +
+

Kalender-Abo

+

Diese Adresse in Apple/Google Kalender als Abo hinzufügen, um Ihre eigenen + Termine automatisch angezeigt zu bekommen.

+ +
+

Zurück

diff --git a/backoffice/app/tests/test_bookings_ics.py b/backoffice/app/tests/test_bookings_ics.py new file mode 100644 index 0000000..f797ad3 --- /dev/null +++ b/backoffice/app/tests/test_bookings_ics.py @@ -0,0 +1,120 @@ +"""Flask test client / real-DB integration tests for the per-client ICS +calendar feed (#22): each client's clients.ics_token gates and scopes +/api/bookings.ics, replacing the old shared ICS_TOKEN + client_id query +param. Same testing decision as #20/#21: assert on HTTP response + DB state. +""" +from datetime import datetime, timedelta, timezone + +import pytest + +import booking_db as bdb +from app import app as flask_app + +CLIENT_A = "C-TEST-ICS-A" +CLIENT_B = "C-TEST-ICS-B" + + +@pytest.fixture +def client(): + flask_app.config["TESTING"] = True + flask_app.secret_key = "test-secret" + return flask_app.test_client() + + +def _setup(client_id, business_name): + with bdb.db.connect() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO clients (client_id, business_name, timezone, auto_confirm, ics_token) " + "VALUES (%s, %s, %s, %s, NULL) ON CONFLICT (client_id) DO UPDATE SET " + "business_name = EXCLUDED.business_name, ics_token = NULL", + (client_id, business_name, "Europe/Berlin", True)) + conn.commit() + resource = bdb.create_resource(client_id, "Chair 1") + start = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(days=1) + booking = bdb.create_booking( + client_id, resource["resource_id"], "Ivy", "ivy@example.com", + "Haircut", start, start + timedelta(minutes=30)) + return resource, booking + + +def _login(client, client_id, email="owner@example.com", password="correct horse"): + bdb.create_user(client_id, email, password) + client.post("/owner/login", data={"email": email, "password": password}) + + +# ---- token gating ---- + +def test_missing_token_is_forbidden(client): + _setup(CLIENT_A, "Happy Nails") + resp = client.get("/api/bookings.ics") + assert resp.status_code == 403 + + +def test_bogus_token_is_forbidden(client): + _setup(CLIENT_A, "Happy Nails") + resp = client.get("/api/bookings.ics", query_string={"token": "not-a-real-token"}) + assert resp.status_code == 403 + + +def test_valid_token_returns_only_that_clients_bookings(client): + _setup(CLIENT_A, "Happy Nails") + _setup(CLIENT_B, "Cafe Lichtblick") + token_a = bdb.ensure_ics_token(CLIENT_A) + resp = client.get("/api/bookings.ics", query_string={"token": token_a}) + assert resp.status_code == 200 + body = resp.get_data(as_text=True) + assert "Ivy" in body + assert CLIENT_A in body + assert CLIENT_B not in body + + +def test_swapping_client_id_query_param_has_no_effect(client): + """The old feed let any token holder view another client's bookings by + changing client_id -- the new feed resolves the client from the token + alone, so a client_id param (even another tenant's) is simply ignored.""" + _setup(CLIENT_A, "Happy Nails") + resource_b, booking_b = _setup(CLIENT_B, "Cafe Lichtblick") + token_a = bdb.ensure_ics_token(CLIENT_A) + resp = client.get("/api/bookings.ics", query_string={ + "token": token_a, "client_id": CLIENT_B}) + assert resp.status_code == 200 + body = resp.get_data(as_text=True) + assert CLIENT_B not in body + assert CLIENT_A in body + + +# ---- token provisioning ---- + +def test_ensure_ics_token_generates_and_persists(client): + _setup(CLIENT_A, "Happy Nails") + assert bdb.get_client(CLIENT_A)["ics_token"] is None + token = bdb.ensure_ics_token(CLIENT_A) + assert token + assert bdb.get_client(CLIENT_A)["ics_token"] == token + + +def test_ensure_ics_token_is_stable_across_calls(client): + _setup(CLIENT_A, "Happy Nails") + first = bdb.ensure_ics_token(CLIENT_A) + second = bdb.ensure_ics_token(CLIENT_A) + assert first == second + + +def test_ensure_ics_token_differs_per_client(client): + _setup(CLIENT_A, "Happy Nails") + _setup(CLIENT_B, "Cafe Lichtblick") + assert bdb.ensure_ics_token(CLIENT_A) != bdb.ensure_ics_token(CLIENT_B) + + +# ---- owner dashboard surfacing ---- + +def test_settings_page_shows_subscribe_url_with_own_token(client): + _setup(CLIENT_A, "Happy Nails") + _login(client, CLIENT_A) + resp = client.get("/owner/settings") + assert resp.status_code == 200 + body = resp.get_data(as_text=True) + token = bdb.get_client(CLIENT_A)["ics_token"] + assert token + assert f"token={token}" in body + assert "bookings.ics" in body diff --git a/backoffice/db/init.sql b/backoffice/db/init.sql index faee5fd..8579953 100644 --- a/backoffice/db/init.sql +++ b/backoffice/db/init.sql @@ -213,3 +213,8 @@ CREATE INDEX IF NOT EXISTS services_client_idx ON services (client_id); CREATE INDEX IF NOT EXISTS bookings_client_idx ON bookings (client_id); CREATE INDEX IF NOT EXISTS users_client_idx ON users (client_id); CREATE INDEX IF NOT EXISTS password_reset_tokens_user_idx ON password_reset_tokens (user_id); + +-- Partial (NULL-excluding) so many clients can share ics_token IS NULL before +-- their first ensure_ics_token() call lazily backfills a real value (#22). +CREATE UNIQUE INDEX IF NOT EXISTS clients_ics_token_idx ON clients (ics_token) + WHERE ics_token IS NOT NULL; diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index c76fcd5..f68fd71 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -26,7 +26,6 @@ services: CRM_API_TOKEN: ${CRM_API_TOKEN} BOOKING_TOKEN_SECRET: ${BOOKING_TOKEN_SECRET} SESSION_SECRET_KEY: ${SESSION_SECRET_KEY} - ICS_TOKEN: ${ICS_TOKEN} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USERNAME: ${SMTP_USERNAME} diff --git a/playbooks/lead-to-customer.md b/playbooks/lead-to-customer.md index cab876f..b0218c8 100644 --- a/playbooks/lead-to-customer.md +++ b/playbooks/lead-to-customer.md @@ -46,8 +46,9 @@ In the EA backend (admin login — see `.secrets/easyappointments.txt`), set the their **provider login** so they manage their own calendar. ## 7. Hand over & go live -- Give them either their **EA provider login** (self-hosted calendar view) or the **iCal feed** - `…/crm/api/bookings.ics?client_id=C-xxxx&token=…` to subscribe in Apple / Google Calendar. +- Give them either their **EA provider login** (self-hosted calendar view) or the **iCal + subscribe URL** shown on their own `/owner/settings` page (per-client token, #22) to add to + Apple / Google Calendar. - A customer booking now flows: their site → EA → CRM `bookings` → your Telegram. - Set the **Client → `active`**.