diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa6a5dd..60da712 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,59 @@ jobs: - name: go test run: go test ./... + + # The same suite against PostgreSQL. Separate job rather than a matrix on `check` + # because only the Go half is engine-dependent: svelte-check and pnpm build would + # run twice for no reason. The frontend build is still needed here, since the Go + # binary go:embeds frontend/build and `go build` fails without it. + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: calnode_ci + POSTGRES_DB: calnode + ports: + - 5432:5432 + # Without a health check the first connection races the server's startup, + # which fails as "connection refused" and reads like a bad DSN. + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install frontend deps + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Build frontend (required by the Go embed) + working-directory: frontend + run: pnpm build + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + # CALNODE_TEST_POSTGRES_DSN is what switches internal/dbtest onto Postgres. + # Unset — which is every other job and every local run — the suite uses + # in-memory SQLite exactly as before, so this job is additive: it cannot change + # what a contributor sees. + - name: go test (PostgreSQL) + env: + CALNODE_TEST_POSTGRES_DSN: postgres://postgres:calnode_ci@127.0.0.1:5432/calnode?sslmode=disable + run: go test ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 1445c11..dd5ad5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,108 @@ exact tag (`ghcr.io/calnode/calnode:0.1.0`) if you need stability between upgrad ## [Unreleased] +### Added +- **Canadian French (`fr-CA`) on the booker-facing surfaces.** A visitor whose browser asks + for `fr-CA` now gets Canadian French rather than the France copy; `fr` and `fr-FR` are + unaffected. It is the first regional locale, and a separate file rather than a fallback + because the differences are real: `courriel` rather than `e-mail`, `reporter`/`report` + rather than `reprogrammer`, `renseignements personnels` (the Quebec statutory term) rather + than `données personnelles`, no space before `!` `?` `;` where France puts one, and CLDR + itself spells July `juill.` here against `juil.` in France. + + ⚠️ **The wording is an unreviewed draft**, like every non-English locale in this + repository: the structure is verified by the same three guards (same keys, printf-verb + parity, date tables cross-checked against CLDR), but no native Canadian French speaker has + read the copy. Corrections are welcome and easy to merge — see CONTRIBUTING. + +- **`booking.reminder` webhook event.** Reminders were email-only, so an integration had no + way to know one had gone out — you could hear about a booking being made, moved or + cancelled, but not about the nudge before it. Subscribe to it in Settings → Webhooks. + + The payload is booking-shaped like the other booking events plus `hours_before`, because + an event type can configure several reminders and a subscriber needs to know which one + fired. It is sent after the email and only when the email succeeded: the event means the + attendee has been reminded, and the job retries, so firing it on a failed send would be + both untrue and eventually duplicated. A host who has reminder emails switched off sends + no reminder, so there is no event either. + +- **`STT_BASE_URL`: choose which speech-to-text endpoint transcribes your recordings.** + The host was hardcoded, so meeting audio always went to the provider's global endpoint — + a problem if you need it transcribed inside one jurisdiction. The default is unchanged. + + Only the host is configurable; the path, model and transcription options stay ours, so + this picks a region rather than a different request. The effective value is reported + read-only as `stt_base_url` in `GET /v1/settings/notetaker`, because an admin should be + able to see where audio is sent without reading a running container's environment — and + should not be able to repoint it from a browser session, which is why it is not a + settings field. + +- **`GET /metrics`: Prometheus metrics, off until you set `METRICS_TOKEN`.** Build + identity, requests by surface and status, a request-duration histogram, pending and + failed job counts, bookings created/cancelled/rescheduled, process start time and two Go + runtime gauges. No new dependency — the exposition format is a page of text, and a + scrape endpoint is not worth a dependency tree in a binary you self-host. + + Without the token, and with a wrong one, it answers 404 rather than 401: these numbers + are a business feed, and an operator who has not configured a token has not agreed to + publish it, so there is nothing to advertise either. The `class` label comes from the + path prefix and nothing else, so the series count is fixed at five times the handful of + status codes and a request cannot invent a new one. + +- **`FRAME_ANCESTORS`: embed the admin UI in your own console.** Space-separated origins + (`https://console.example.com 'self'`); when set, `/admin/` sends + `Content-Security-Policy: frame-ancestors `. The public booking pages are + untouched and still deny framing outright — this is about the console, not the pages + that take card details. + + Two deliberate refusals. An entry that is not `https://host[:port]` or `'self'` stops + the app booting rather than being ignored, because a browser drops a source list it + cannot parse, which would leave the admin UI *more* embeddable than the setting being + unset. And no `X-Frame-Options` is sent beside it: that header has no allow-list form, + so the only value it could carry is `SAMEORIGIN`, which browsers honour instead of the + CSP and would break the embedding this exists for. + +- **`TRUSTED_PROXY_CIDRS`: per-IP rate limits that work behind a CDN.** Rate limits key + on the TCP peer, which is right for a directly-reachable instance and useless behind a + fronting CDN, where every visitor arrives from the same handful of addresses and shares + one bucket. List the networks you control and the client IP is taken from + `CF-Connecting-IP`, or from `X-Forwarded-For` walked right to left past your own hops. + + Nothing changes if you do not set it: a header from a peer you have not listed is still + not read at all, because it is a value the client chose. Within the header the *leftmost* + entry is likewise client-chosen, so the walk stops at the rightmost address one of your + proxies actually observed, and a malformed hop ends the walk on the peer rather than + being stepped over. + +- **Sign out everywhere.** `POST /v1/auth/sessions/revoke-all` ends every session you + have except the one you asked from, so losing a laptop no longer means waiting out a + 30-day cookie. Pass `{"user_id": "..."}` and an admin can do the same for someone + else: an admin may revoke a member, only the owner may revoke another admin, and the + owner's own sessions can only be ended by the owner. + + It also revokes that person's MCP OAuth tokens, which is the part that makes it an + offboarding tool rather than a convenience. A connected agent authenticates with a + bearer token and not the session cookie, so ending the sessions alone would have left + it holding exactly the access that was just withdrawn. + +- **Signed session hand-off, so an identity system you already run can sign people in.** + `GET /v1/auth/sso?token=` accepts a short-lived HS256 JWT signed with a shared + secret and starts an ordinary Calnode session, redirecting to `/admin/` (or to a + same-origin `?next=` path). Off unless `CALNODE_SSO_SHARED_SECRET` is set — an + unconfigured instance answers 404, so it cannot be turned on by accident. + + The token must carry `iss`, `aud` (your `BASE_URL`), `sub` (email), `name`, `role`, + `iat`, `exp` and a unique `jti`. It may live at most 60 seconds, 30 seconds of clock + skew is tolerated either way, and the `jti` is recorded in a new `sso_nonces` table + before the session is created, so a replay inside that window is refused rather than + handed a second session. A `wid` claim is accepted and ignored today. + + This is the only path that creates a user without an invite, which is the trade the + shared secret buys: the caller is your own identity system, not a visitor with a + Google account. On creation the claimed role is applied; for someone who already + exists the role is left alone, except that a claim asking for `owner` bootstraps + ownership when the instance has none. Archived accounts are still refused. + ## [0.8.0] - 2026-09-03 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 0037a37..64c0dce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,7 +131,7 @@ no string plumbing with the Go templates), and admin-authored content (event nam descriptions, questions, custom email copy). Locale is resolved per request from `Accept-Language` + a `?lang=` override + the operator's fallback setting (`internal/handler/i18n.go`), and the booker's locale is stored on the booking so later -reminders match. Ships `en es fr de it pt nl sv`. Full detail: ARCHITECTURE §23. +reminders match. Ships `en es fr fr-CA de it pt nl sv`. Full detail: ARCHITECTURE §23. **Adding a locale = adding `internal/i18n/locales/.json`.** Nothing else. `init()` globs the directory; the switcher, the fallback dropdown and the public API payload all read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148c9d2..76870aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,7 @@ prefer additive, nullable/defaulted columns. ## Translations -Calnode ships 8 locales (`en es fr de it pt nl sv`) across the booker-facing surfaces: +Calnode ships 9 locales (`en es fr fr-CA de it pt nl sv`) across the booker-facing surfaces: booking page, manage/reschedule page, embed widget, the four emails, and the calendar invite. The admin UI and the built-in video room are English-only. diff --git a/DEPLOY.md b/DEPLOY.md index e5e6297..2907f7c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -25,9 +25,13 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy |---|---|---|---| | `CALNODE_ENCRYPTION_KEY` | **prod: yes** | — | KEK input (Argon2id). **Required when `BASE_URL` is https** — the app refuses to start without it. Use a long random string: `openssl rand -hex 32`. **Losing it makes encrypted data unrecoverable** unless you set the recovery secret below. | | `CALNODE_RECOVERY_SECRET` | recommended | — | Escrow secret so the data key can be recovered if the encryption key is rotated/lost. Store it somewhere separate. | +| `CALNODE_SSO_SHARED_SECRET` | no | — | HMAC key for the signed session hand-off (`GET /v1/auth/sso`). Unset ⇒ that endpoint **404s**. Anything holding this secret can mint a session and create a user, so treat it like the encryption key: `openssl rand -hex 32`, env only, never in the admin UI. | +| `METRICS_TOKEN` | no | — | Bearer token for `GET /metrics` (Prometheus text exposition). Unset ⇒ that endpoint **404s**, so an instance never publishes its request volume, booking rate or queue depth by accident. Scrape with `Authorization: Bearer $METRICS_TOKEN`; a wrong token gets the same 404 as an unconfigured one. | | `BASE_URL` | **yes (prod)** | `http://localhost:3000` | Identity host — admin UI, OAuth callbacks, invite links. **Must include the scheme** (`https://booking.example.com`). The `https://` prefix flips the app into production mode (secure cookies, encryption-key enforcement). | | `PUBLIC_BASE_URL` | no | = `BASE_URL` | Booker-facing host for booking links/emails, if different from the identity host. | -| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. | +| `DATABASE_URL` | no | `sqlite://./data/calnode.db` | Point at the persistent volume, e.g. `sqlite:///data/calnode.db`. A `postgres://user:pass@host:5432/dbname` URL selects PostgreSQL instead; anything else is SQLite. | +| `DB_MAX_OPEN_CONNS` | no | `10` | **PostgreSQL only.** Size of the connection pool. It has to fit inside the server's own `max_connections`, shared with every other client — raise it for a busy instance on a well-sized server, lower it behind PgBouncer or on a shared one. Must be a positive integer; anything else is ignored (with a warning) and the default stands. **Ignored on SQLite, which is always 1**: the single connection is what serialises write transactions, not a tuning choice. | +| `DB_MAX_IDLE_CONNS` | no | `5` | **PostgreSQL only.** How many idle connections the pool keeps rather than closing. Positive integer, and capped at `DB_MAX_OPEN_CONNS` (a larger value is clamped, since `database/sql` would silently do the same). | | `PORT` | no | `3000` | The app listens on `$PORT`. Many platforms inject their own (Railway injects `8080`) — let them. | | `EMAIL_SMTP_HOST` / `_PORT` / `_USER` / `_PASS` | no¹ | — / `587` | SMTP. Can also be set later in Settings → Email (DB-stored, encrypted). | | `EMAIL_SMTP_TLS` / `_STARTTLS` | no | `false` | `STARTTLS` for 587, implicit `TLS` for 465. | @@ -35,7 +39,10 @@ This guide covers a generic Docker deploy and a step-by-step **Railway** deploy | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | no | — | Google sign-in + calendar. Can also be set in Settings → Google OAuth. | | `LITESTREAM_REPLICA_URL` | recommended | — | Enables continuous SQLite backup (see §6). | | `COOKIE_SECURE` | no | https→true | Override cookie Secure flag; defaults from `BASE_URL` scheme. | +| `TRUSTED_PROXY_CIDRS` | no | — | Comma-separated CIDRs (a bare address = one host) whose `CF-Connecting-IP` / `X-Forwarded-For` are believed when keying per-IP rate limits, e.g. `10.0.0.0/8`. Unset ⇒ those headers are ignored and the limit keys on the TCP peer, so behind a fronting CDN every visitor shares one bucket. **Only list networks you control**: anything in the list can name any client IP it likes. | +| `FRAME_ANCESTORS` | no | — | **Space**-separated origins allowed to embed the **admin UI** in a frame, e.g. `https://console.example.com 'self'`. Each entry must be `https://host[:port]` or `'self'` — anything else and **the app refuses to start**, because browsers drop a policy they cannot parse. Does not affect the public booking pages, which always deny framing. | | `LOG_LEVEL` | no | `info` | `debug`/`info`/`warn`/`error`. | +| `STT_BASE_URL` | no | `https://api.deepgram.com` | Speech-to-text endpoint **host** for meeting transcription, e.g. a regional endpoint so recording audio stays in one jurisdiction. Host only — the path, model and options are fixed. Shown read-only in Settings → Notetaker as `stt_base_url`. | ¹ Email is optional to boot, but bookings won't send confirmations until SMTP is configured (env **or** the admin UI). Precedence is **env var > DB setting > default**. diff --git a/audit/claims.yaml b/audit/claims.yaml index 28bca98..14c4647 100644 --- a/audit/claims.yaml +++ b/audit/claims.yaml @@ -276,25 +276,36 @@ claims: - id: rate-limit-keys-on-tcp-source-address claim: > - Per-IP rate limiting (internal/server/middleware.go's RateLimit) keys - strictly on the TCP-level remote address of the connection, never on client- - supplied X-Forwarded-For/X-Real-IP headers — a client cannot spoof those - headers to evade or split its rate-limit bucket. + Per-IP rate limiting (internal/server/middleware.go's RateLimit) keys on the + TCP-level remote address of the connection, and never on client-supplied + X-Forwarded-For/X-Real-IP/CF-Connecting-IP headers unless the peer that sent + them is inside an operator-configured TRUSTED_PROXY_CIDRS range — so a client + cannot spoof those headers to evade or split its rate-limit bucket. verify: - "internal/server/middleware.go's remoteIP — net.SplitHostPort(r.RemoteAddr) - only; the proxy headers are never read." + via peerIP, unless TrustClientIP has resolved a client IP into the request + context, which it only does for a peer matching a trusted CIDR." + - "internal/server/middleware.go's resolveClientIP — returns the peer outright + for an untrusted peer, before any header is read." - "internal/server/ratelimit_test.go's TestRemoteIP_* — assert X-Forwarded-For and X-Real-IP are ignored even when RemoteAddr is loopback." + - "internal/server/trustedproxy_test.go — asserts an untrusted peer's spoofed + headers are ignored, that the X-Forwarded-For walk goes right-to-left past + trusted hops (never the client-seeded leftmost entry), and that a malformed + header falls back to the peer." status: verified caveat: > Recorded here because a prior Layer 2 audit pass flagged this as trusting spoofable proxy headers — it doesn't; the flagged behavior traced back to a stale doc comment describing the opposite of what the code does (fixed - alongside this entry). Correct behavior does require the deployment's - reverse proxy to connect to Calnode directly (or over a trusted private - network) — see the deployment docs for reverse-proxy requirements (forward - the original Host header, connect over a trusted path, strip client-supplied - proxy headers at the edge). + alongside this entry). TRUSTED_PROXY_CIDRS is empty by default, so an + unconfigured instance behaves exactly as this claim originally described. + Anything an operator does list can name any client IP it likes — that is what + trusting a proxy means — so the list must hold only networks they control. + Correct behavior otherwise requires the deployment's reverse proxy to connect + to Calnode directly (or over a trusted private network) — see the deployment + docs for reverse-proxy requirements (forward the original Host header, connect + over a trusted path, strip client-supplied proxy headers at the edge). - id: caldav-connect-self-service-no-admin-gate claim: > diff --git a/cmd/calnode/main.go b/cmd/calnode/main.go index 29a38aa..1810c3c 100644 --- a/cmd/calnode/main.go +++ b/cmd/calnode/main.go @@ -50,28 +50,90 @@ func main() { })) slog.SetDefault(logger) + // Before the database is opened: every combination Validate refuses would + // otherwise present as a bug somewhere much later — a tenant reading another + // tenant's rows, or a demo reset wiping a fleet. + if err := cfg.Validate(); err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + bi := buildinfo.Get() logger.Info("starting calnode", "version", bi.Version, "commit", bi.Commit, "build_time", bi.BuildTime, "dirty", bi.Dirty) + // Config whose wrong value is worse than its absence is checked here rather than + // tolerated at request time — see (*config.Config).Validate. + if err := cfg.Validate(); err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.GoogleClientID != "" { slog.Info("Google OAuth configured", "client_id_prefix", cfg.GoogleClientID[:20]) } else { slog.Warn("Google OAuth NOT configured — GOOGLE_CLIENT_ID is empty") } - database, err := db.Open(cfg.DatabaseURL) - if err != nil { - logger.Error("failed to open database", "error", err) - os.Exit(1) + // One handle in single-tenant mode; two in multi-tenant mode, because + // migrations, EnableRLS and every cross-tenant read are the PLATFORM role's + // work. DATABASE_URL is then a NOBYPASSRLS role that does not own the schema + // and cannot run DDL at all. database.Platform() answers with the right one + // either way, so nothing downstream has to know which mode this is. + var ( + database, platform *db.DB + err error + ) + if cfg.MultiTenant { + database, platform, err = db.OpenPair(cfg.DatabaseURL, cfg.DatabaseAdminURL) + if err != nil { + logger.Error("failed to open the database pair", "error", err) + os.Exit(1) + } + defer platform.Close() + } else { + database, err = db.OpenDB(cfg.DatabaseURL) + if err != nil { + logger.Error("failed to open database", "error", err) + os.Exit(1) + } + platform = database } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := platform.Migrate(); err != nil { logger.Error("failed to run migrations", "error", err) os.Exit(1) } logger.Info("database migrations applied") + // ⛔ Refuse to serve if either of these fails. Without EnableRLS every policy + // created by migration 00060 is inert and the process comes up looking + // multi-tenant while separating nothing; without VerifyRoles the same is true + // of a DATABASE_URL that happens to bypass or to own the tables. Both failures + // are otherwise silent — every request works, and each one can read every + // workspace. EnableRLS is idempotent, so booting twice is fine, and it is + // deliberately gated on MULTI_TENANT: FORCE ROW LEVEL SECURITY applies a policy + // to the table's owner too, and in single-tenant mode DATABASE_URL is that + // owner (see db.EnableRLS). + if cfg.MultiTenant { + if err := platform.EnableRLS(context.Background()); err != nil { + logger.Error("failed to enable row-level security; refusing to serve", "error", err) + os.Exit(1) + } + // The seeded `default` workspace is nobody's tenant on a multi-tenant instance; + // suspending it keeps the background sweeps off it. Not fatal: a sweep over an + // empty workspace is waste, not damage, so a failure here is worth a line in the + // log and not a refusal to serve. + if err := platform.SuspendDefaultWorkspace(context.Background()); err != nil { + logger.Error("could not suspend the default workspace", "error", err) + } + if err := database.VerifyRoles(context.Background()); err != nil { + logger.Error("database roles cannot enforce tenant isolation; refusing to serve", "error", err) + os.Exit(1) + } + logger.Info("row-level security enabled", "tables", len(db.TenantTables)) + } + // Open the key vault. devMode allows an ephemeral DEK when no secret is set // (handy for local development); production deployments must set // CALNODE_ENCRYPTION_KEY or the vault will refuse to start. diff --git a/cmd/calnode/mcp.go b/cmd/calnode/mcp.go index aa6984d..db87b8a 100644 --- a/cmd/calnode/mcp.go +++ b/cmd/calnode/mcp.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "log/slog" "os" "os/signal" @@ -26,18 +27,28 @@ import ( func runMCPStdio(_ []string) { cfg := config.Load() + // ⛔ Refused under MULTI_TENANT. The stdio transport carries no credential and no + // Host, so there is nothing to resolve a tenant from and the tools would run on + // the unbound handle — which under the policies matches no row, so an operator + // would get an empty workspace and no explanation. The HTTP transport resolves + // the tenant from the bearer credential (D10). + if err := refuseMCPStdio(cfg.MultiTenant); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + // stdout carries the MCP protocol — never log to it. All logs → stderr. logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: cfg.LogLevel})) slog.SetDefault(logger) - database, err := db.Open(cfg.DatabaseURL) + database, err := db.OpenDB(cfg.DatabaseURL) if err != nil { logger.Error("mcp: failed to open database", "error", err) os.Exit(1) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := database.Migrate(); err != nil { logger.Error("mcp: failed to run migrations", "error", err) os.Exit(1) } diff --git a/cmd/calnode/recover_key.go b/cmd/calnode/recover_key.go index ebd285f..224d26f 100644 --- a/cmd/calnode/recover_key.go +++ b/cmd/calnode/recover_key.go @@ -17,6 +17,15 @@ import ( // CALNODE_RECOVERY_SECRET must be in the environment. // After a successful recovery, set CALNODE_ENCRYPTION_KEY= // before the next server start. +// Multi-tenant mode needs no change here, and this note is why rather than an +// oversight: recover-key operates only on crypto_keystore, which migration 00060 leaves +// EXEMPT from tenancy (D2) because there is one DEK per process (D3). It is +// platform-wide by construction, so DATABASE_URL's handle reaches it whether or not +// the row-level-security policies are enabled — crypto_keystore has none. +// +// ⚠️ If per-tenant DEKs are ever adopted, both commands need a --workspace flag and +// this comment becomes wrong. cmd/calnode/tenancy.go's platformWideCLI is the list a +// test asserts against. func runRecoverKey(args []string) { if len(args) != 1 || args[0] == "" { fmt.Fprintln(os.Stderr, "usage: calnode recover-key ") @@ -36,7 +45,7 @@ func runRecoverKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/reset_admin.go b/cmd/calnode/reset_admin.go index 03495b4..9317b1f 100644 --- a/cmd/calnode/reset_admin.go +++ b/cmd/calnode/reset_admin.go @@ -3,7 +3,6 @@ package main import ( "fmt" "os" - "strings" "golang.org/x/crypto/bcrypt" @@ -13,32 +12,48 @@ import ( // runResetAdmin is invoked when the binary is called as: // -// calnode reset-admin +// calnode reset-admin [--workspace=] // // It resets the password for the named user and enables email_login on their // account. This is the last-resort recovery path when SMTP is unavailable and // the admin is locked out. func runResetAdmin(args []string) { - if len(args) != 2 { - fmt.Fprintln(os.Stderr, "usage: calnode reset-admin ") - os.Exit(1) - } - email := strings.TrimSpace(strings.ToLower(args[0])) - password := args[1] - - if len(password) < 8 || len(password) > 72 { - fmt.Fprintln(os.Stderr, "error: password must be 8–72 characters") - os.Exit(1) - } - cfg := config.Load() - database, err := db.Open(cfg.DatabaseURL) + req, err := parseResetAdmin(args, cfg.MultiTenant) if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to open database: %v\n", err) + fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } - defer database.Close() + email, password := req.Email, req.Password + + // ⛔ In multi-tenant mode the UPDATE must be bound. users.email is unique per + // WORKSPACE since D9, so `WHERE email = ?` on the platform handle would match + // every workspace that has a user with that address and reset all of their + // passwords — a recovery tool that hands out access to tenants the operator was + // not asked about. + var database *db.DB + if cfg.MultiTenant { + app, platform, oerr := db.OpenPair(cfg.DatabaseURL, cfg.DatabaseAdminURL) + if oerr != nil { + fmt.Fprintf(os.Stderr, "error: failed to open database pair: %v\n", oerr) + os.Exit(1) + } + defer platform.Close() + defer app.Close() + database = app.ForWorkspace(req.Workspace) + if database.Err() != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", database.Err()) + os.Exit(1) + } + } else { + database, err = db.OpenDB(cfg.DatabaseURL) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to open database: %v\n", err) + os.Exit(1) + } + defer database.Close() + } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { diff --git a/cmd/calnode/rotate_key.go b/cmd/calnode/rotate_key.go index f1d1085..7842d04 100644 --- a/cmd/calnode/rotate_key.go +++ b/cmd/calnode/rotate_key.go @@ -14,6 +14,15 @@ import ( // The current secret is read from CALNODE_ENCRYPTION_KEY (env or .env file). // After a successful rotation, update CALNODE_ENCRYPTION_KEY to // before the next server start. +// Multi-tenant mode needs no change here, and this note is why rather than an +// oversight: rotate-key operates only on crypto_keystore, which migration 00060 leaves +// EXEMPT from tenancy (D2) because there is one DEK per process (D3). It is +// platform-wide by construction, so DATABASE_URL's handle reaches it whether or not +// the row-level-security policies are enabled — crypto_keystore has none. +// +// ⚠️ If per-tenant DEKs are ever adopted, both commands need a --workspace flag and +// this comment becomes wrong. cmd/calnode/tenancy.go's platformWideCLI is the list a +// test asserts against. func runRotateKey(args []string) { if len(args) != 1 || args[0] == "" { fmt.Fprintln(os.Stderr, "usage: calnode rotate-key ") @@ -37,7 +46,7 @@ func runRotateKey(args []string) { dbURL = "sqlite://./data/calnode.db" } - database, err := db.Open(dbURL) + database, err := db.OpenDB(dbURL) if err != nil { fmt.Fprintf(os.Stderr, "error: open database: %v\n", err) os.Exit(1) diff --git a/cmd/calnode/tenancy.go b/cmd/calnode/tenancy.go new file mode 100644 index 0000000..9730669 --- /dev/null +++ b/cmd/calnode/tenancy.go @@ -0,0 +1,126 @@ +package main + +import ( + "errors" + "fmt" + "strings" +) + +// The CLI subcommands under multi-tenant mode. +// +// Each of the four is one of two things, and which it is follows from what it +// touches: +// +// rotate-key, recover-key — crypto_keystore, which is EXEMPT from tenancy (D2) +// and holds one DEK per process (D3). Platform-wide by +// construction, so they run unchanged on the platform +// handle and need no workspace. +// reset-admin — users, a tenant table. ⛔ And since D9 made the +// unique (workspace_id, email) rather than email, an +// unscoped UPDATE ... WHERE email = ? matches EVERY +// workspace that has a user with that address and +// resets all of their passwords. It needs --workspace. +// mcp — the stdio transport has no credential and no Host, so +// there is nothing to resolve a tenant from. Refused. +var ( + // errWorkspaceRequired is reset-admin's refusal under MULTI_TENANT. + errWorkspaceRequired = errors.New( + "reset-admin needs --workspace= when MULTI_TENANT is set: users.email is unique per " + + "workspace, so an unscoped reset would change the password of every user with that " + + "address in every workspace") + + // errMCPStdioMultiTenant is the stdio transport's refusal. + errMCPStdioMultiTenant = errors.New( + "calnode mcp (stdio) is not available when MULTI_TENANT is set: the stdio transport carries " + + "no credential and no Host, so there is no workspace to resolve and the tools would run " + + "unscoped. Use the HTTP transport at POST /mcp with a workspace's API key or OAuth token, " + + "which resolves the tenant from the credential") +) + +// resetAdminRequest is a parsed `calnode reset-admin` invocation. +type resetAdminRequest struct { + Email string + Password string + Workspace string // "" in single-tenant mode +} + +// parseResetAdmin parses the subcommand's arguments and applies the multi-tenant +// rule. --workspace may appear anywhere among the positional arguments, in either +// the `--workspace=id` or `--workspace id` form. +// +// In single-tenant mode --workspace is accepted and ignored rather than rejected, +// so a script written for a multi-tenant fleet keeps working against a +// single-tenant instance. +func parseResetAdmin(args []string, multiTenant bool) (resetAdminRequest, error) { + var req resetAdminRequest + var positional []string + + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case strings.HasPrefix(a, "--workspace="): + req.Workspace = strings.TrimPrefix(a, "--workspace=") + case a == "--workspace": + if i+1 >= len(args) { + return req, errors.New("--workspace needs a value") + } + i++ + req.Workspace = args[i] + default: + positional = append(positional, a) + } + } + + if len(positional) != 2 { + return req, errors.New("usage: calnode reset-admin [--workspace=] ") + } + req.Email = strings.TrimSpace(strings.ToLower(positional[0])) + req.Password = positional[1] + + if len(req.Password) < 8 || len(req.Password) > 72 { + return req, errors.New("password must be 8–72 characters") + } + + if multiTenant { + if req.Workspace == "" { + return req, errWorkspaceRequired + } + if !validWorkspaceID(req.Workspace) { + return req, fmt.Errorf("--workspace=%q is not a workspace id ([a-z0-9_-], 1-64 chars)", req.Workspace) + } + } + + return req, nil +} + +// refuseMCPStdio reports whether `calnode mcp` may run, and why not. +func refuseMCPStdio(multiTenant bool) error { + if multiTenant { + return errMCPStdioMultiTenant + } + return nil +} + +// platformWideCLI names the subcommands that operate on crypto_keystore and are +// therefore tenant-independent. Kept as data so the test can assert the +// classification rather than re-derive it. +var platformWideCLI = map[string]bool{ + "rotate-key": true, + "recover-key": true, +} + +// validWorkspaceID mirrors db.ValidWorkspaceID without importing it, because this +// runs before any database handle exists. +func validWorkspaceID(id string) bool { + if len(id) == 0 || len(id) > 64 { + return false + } + for _, r := range id { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': + default: + return false + } + } + return true +} diff --git a/cmd/calnode/tenancy_test.go b/cmd/calnode/tenancy_test.go new file mode 100644 index 0000000..0172223 --- /dev/null +++ b/cmd/calnode/tenancy_test.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// TestParseResetAdmin_requiresWorkspaceUnderMultiTenant. +// +// ⛔ The reason this is a refusal and not a default: since D9 the unique on users is +// (workspace_id, email), so an unscoped `UPDATE users SET password_hash = ? WHERE +// email = ?` matches every workspace that has a user with that address. A recovery +// tool that quietly resets three tenants' owners because they all use the same +// address is worse than one that refuses. +func TestParseResetAdmin_requiresWorkspaceUnderMultiTenant(t *testing.T) { + _, err := parseResetAdmin([]string{"owner@example.com", "hunter2hunter2"}, true) + if !errors.Is(err, errWorkspaceRequired) { + t.Fatalf("err = %v; want errWorkspaceRequired", err) + } + // The message has to say what to do, not just that it refused. + for _, want := range []string{"--workspace", "unique per", "every user"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %s", want, err) + } + } +} + +func TestParseResetAdmin_acceptsBothFlagForms(t *testing.T) { + for _, args := range [][]string{ + {"--workspace=acme", "owner@example.com", "hunter2hunter2"}, + {"--workspace", "acme", "owner@example.com", "hunter2hunter2"}, + {"owner@example.com", "hunter2hunter2", "--workspace=acme"}, + } { + req, err := parseResetAdmin(args, true) + if err != nil { + t.Fatalf("parseResetAdmin(%v) = %v", args, err) + } + if req.Workspace != "acme" { + t.Errorf("parseResetAdmin(%v) workspace = %q", args, req.Workspace) + } + if req.Email != "owner@example.com" || req.Password != "hunter2hunter2" { + t.Errorf("parseResetAdmin(%v) = %+v", args, req) + } + } +} + +// TestParseResetAdmin_singleTenantAcceptsAndIgnoresTheFlag: a script written for a +// multi-tenant fleet keeps working against a single-tenant instance. +func TestParseResetAdmin_singleTenantAcceptsAndIgnoresTheFlag(t *testing.T) { + if _, err := parseResetAdmin([]string{"owner@example.com", "hunter2hunter2"}, false); err != nil { + t.Errorf("single-tenant without --workspace: %v", err) + } + req, err := parseResetAdmin([]string{"--workspace=acme", "owner@example.com", "hunter2hunter2"}, false) + if err != nil { + t.Fatalf("single-tenant with --workspace: %v", err) + } + if req.Workspace != "acme" { + t.Errorf("the flag was dropped: %+v", req) + } +} + +func TestParseResetAdmin_rejectsAMalformedWorkspace(t *testing.T) { + for _, bad := range []string{"ACME", "ws a", "ws'a", "../etc", strings.Repeat("a", 65)} { + if _, err := parseResetAdmin([]string{"--workspace=" + bad, "o@e.com", "hunter2hunter2"}, true); err == nil { + t.Errorf("parseResetAdmin accepted --workspace=%q", bad) + } + } +} + +func TestParseResetAdmin_stillChecksTheBasics(t *testing.T) { + if _, err := parseResetAdmin([]string{"--workspace=acme", "o@e.com"}, true); err == nil { + t.Error("accepted a missing password") + } + if _, err := parseResetAdmin([]string{"--workspace=acme", "o@e.com", "short"}, true); err == nil { + t.Error("accepted a password under 8 characters") + } + if _, err := parseResetAdmin([]string{"--workspace"}, true); err == nil { + t.Error("accepted --workspace with no value") + } +} + +// TestRefuseMCPStdio: the stdio transport has no credential and no Host, so there is +// nothing to resolve a tenant from. +func TestRefuseMCPStdio(t *testing.T) { + if err := refuseMCPStdio(false); err != nil { + t.Errorf("single-tenant stdio was refused: %v", err) + } + err := refuseMCPStdio(true) + if !errors.Is(err, errMCPStdioMultiTenant) { + t.Fatalf("err = %v; want errMCPStdioMultiTenant", err) + } + // It has to name the path that DOES work, or an operator is stuck. + for _, want := range []string{"HTTP transport", "/mcp", "API key"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not mention %q: %s", want, err) + } + } +} + +// TestPlatformWideCLI pins the classification. rotate-key and recover-key touch only +// crypto_keystore, which 00060 leaves exempt (D2) because there is one DEK per +// process (D3) — so they are tenant-independent and take no --workspace. +func TestPlatformWideCLI(t *testing.T) { + for _, cmd := range []string{"rotate-key", "recover-key"} { + if !platformWideCLI[cmd] { + t.Errorf("%s should be classified platform-wide", cmd) + } + } + for _, cmd := range []string{"reset-admin", "mcp"} { + if platformWideCLI[cmd] { + t.Errorf("%s is not platform-wide: it touches tenant rows or needs a tenant", cmd) + } + } +} + +// TestResetAdmin_scopesToTheNamedWorkspace is the behavioural half, and the reason +// the flag exists: two workspaces whose owners share an email address. The reset must +// change exactly one. +func TestResetAdmin_scopesToTheNamedWorkspace(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + ctx := context.Background() + + const shared = "owner@example.com" + for _, ws := range []string{"acme", "globex"} { + if _, err := platform.ExecContext(ctx, + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', 'active')`, + ws, ws, ws+".example.com"); err != nil { + t.Fatalf("workspace %s: %v", ws, err) + } + // ⛔ The same address in both. Legal since D9 made the unique + // (workspace_id, email), and it is the ordinary case for an agency running + // several client workspaces. + if _, err := app.ForWorkspace(ws).ExecContext(ctx, + `INSERT INTO users (id, email, name, password_hash, email_login) VALUES (?, ?, ?, 'old', 0)`, + ws+"-owner", shared, ws+" owner"); err != nil { + t.Fatalf("user %s: %v", ws, err) + } + } + + req, err := parseResetAdmin([]string{"--workspace=acme", shared, "hunter2hunter2"}, true) + if err != nil { + t.Fatalf("parseResetAdmin: %v", err) + } + + // The same statement runResetAdmin runs, on the same bound handle it builds. + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.MinCost) + if err != nil { + t.Fatalf("bcrypt: %v", err) + } + res, err := app.ForWorkspace(req.Workspace).ExecContext(ctx, + `UPDATE users SET password_hash = ?, email_login = 1 WHERE email = ?`, string(hash), req.Email) + if err != nil { + t.Fatalf("reset: %v", err) + } + n, _ := res.RowsAffected() + if n != 1 { + t.Errorf("the reset touched %d rows; want exactly 1", n) + } + + // acme's owner can log in with the new password; globex's is untouched. + for _, tc := range []struct { + ws string + wantChanged bool + }{{"acme", true}, {"globex", false}} { + var stored string + var login int + if err := platform.QueryRowContext(ctx, + `SELECT password_hash, email_login FROM users WHERE workspace_id = ?`, tc.ws). + Scan(&stored, &login); err != nil { + t.Fatalf("read %s's owner: %v", tc.ws, err) + } + changed := stored != "old" + if changed != tc.wantChanged { + t.Errorf("%s's password changed = %v; want %v", tc.ws, changed, tc.wantChanged) + } + if tc.wantChanged && login != 1 { + t.Errorf("%s's email_login was not enabled", tc.ws) + } + if !tc.wantChanged && login != 0 { + t.Errorf("%s's email_login was enabled by another workspace's reset", tc.ws) + } + } +} + +// TestResetAdmin_theOldShapeResetsEveryWorkspace is the negative control: the +// unscoped statement, on the platform handle, which is what the command did before +// --workspace existed. +func TestResetAdmin_theOldShapeResetsEveryWorkspace(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + ctx := context.Background() + + const shared = "owner@example.com" + for _, ws := range []string{"acme", "globex"} { + if _, err := platform.ExecContext(ctx, + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', 'active')`, + ws, ws, ws+".example.com"); err != nil { + t.Fatalf("workspace %s: %v", ws, err) + } + if _, err := app.ForWorkspace(ws).ExecContext(ctx, + `INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, 'old')`, + ws+"-owner", shared, ws+" owner"); err != nil { + t.Fatalf("user %s: %v", ws, err) + } + } + + res, err := platform.ExecContext(ctx, + `UPDATE users SET password_hash = 'new', email_login = 1 WHERE email = ?`, shared) + if err != nil { + t.Fatalf("unscoped reset: %v", err) + } + n, _ := res.RowsAffected() + if n != 2 { + t.Fatalf("the unscoped reset touched %d rows; the control proves nothing if it is not 2", n) + } + t.Logf("negative control: one unscoped `WHERE email = %q` reset %d workspaces' owners — "+ + "since D9 the unique is (workspace_id, email), so a shared address is legal and common", shared, n) + + _ = db.DefaultWorkspaceID // the default workspace has no user with this address +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e81e86c..218a354 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,6 +54,15 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary - `MICROSOFT_CLIENT_ID/SECRET` and `MICROSOFT_TENANT` (default `common`; use the multi-tenant `common` so any work/personal Microsoft account can connect/sign in) - `COOKIE_SECURE` (defaults true when BASE_URL is https) + - `STT_BASE_URL` — speech-to-text endpoint **host** for the notetaker; defaults to + `stt.DefaultBaseURL` (`https://api.deepgram.com`). Set it to a regional endpoint to + keep recording audio inside one jurisdiction. Only the host is configurable: the path, + model and transcription options stay Calnode's (`internal/stt`'s `listenPath`), so an + operator picks a region and not a different request. Surfaced **read-only** as + `stt_base_url` in `GET /v1/settings/notetaker` — an admin should be able to read where + audio is sent without shelling into the container, and should not be able to repoint it + from a browser session, which is why it is env-only rather than a DB setting like the + API key beside it. - Startup (`internal/server/server.go: New`): open DB → run goose migrations → open keyvault (unwrap DEK) → configure mailer (DB settings override env) → start webhook/reminder **worker** → load Google creds (DB > env) → build one @@ -63,19 +72,60 @@ app you must `pnpm build` in `frontend/` **and** rebuild/restart the Go binary return handler + a `drain` func. - Ops endpoints: `GET /healthz`, `GET /readyz` (readiness gate), `GET /version` (build stamp from `internal/buildinfo`). +- **`GET /metrics`** — Prometheus text exposition, hand-written in `internal/metrics` + (no client library, for the reason `internal/livekit` signs its own tokens: a + one-page stable text protocol is not worth a dependency tree in a self-hosted + binary). Series: `calnode_build_info{version,commit}`, + `calnode_http_requests_total{class,status}`, + `calnode_http_request_duration_seconds` (histogram, fixed buckets), + `calnode_jobs_pending`, `calnode_jobs_failed_total`, + `calnode_bookings_total{event}` for created/cancelled/rescheduled, + `process_start_time_seconds`, `go_goroutines`, `go_memstats_alloc_bytes`. + ⛔ **Gated on `Authorization: Bearer $METRICS_TOKEN`, and it answers 404** — identical + to the mux's own not-found — when the token is unset or wrong. Not 401: a 401 confirms + the endpoint exists, and these numbers are a business feed (bookings per hour, request + volume by surface) on an instance meant to be publicly reachable. Not rate-limited + either; a scrape runs every few seconds by design and a limiter would punch gaps that + read as downtime. + `class` is derived from the path **prefix only** (`public|admin|api|mcp|ops`), so the + label set is closed and can never be attacker-chosen — the usual way a metrics endpoint + becomes an out-of-memory vector. Read it as "which surface of the URL space", not "how + the request authenticated": `POST /v1/bookings` is public and unauthenticated and still + counts as `api`. Requests are counted in the existing `Logging` middleware, the only + place that already knows the final status and elapsed time; job depth is read from the + `jobs` table per scrape, because any instance can claim any job. A host **reassignment** + fires the `booking.rescheduled` webhook but is deliberately **not** counted as a + reschedule — it does not move the meeting. - Graceful shutdown drains the worker and in-flight requests before `db.Close()`. --- -## 4. Persistence (SQLite) — and the single-connection rule - -- `internal/db`: opens SQLite with **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, - **WAL** journal mode, `busy_timeout=5000`. One writer connection by design. -- Migrations: **goose** SQL files in `internal/db/migrations/` (00001→00029). Run - automatically on startup. `ALTER TABLE ADD COLUMN` is reversible-by-convention - only (SQLite can't easily drop columns). - -### ⚠️ The single-connection gotcha (bit us once) +## 4. Persistence (SQLite or PostgreSQL) — and the single-connection rule + +- `internal/db`: `OpenDB(DATABASE_URL)` picks the engine from the URL scheme. A + `postgres://` URL selects PostgreSQL; everything else (`sqlite://./rel`, + `sqlite:///abs`, `:memory:`, a bare path) selects SQLite, so every configuration + that worked before still works unchanged. +- SQLite: **`SetMaxOpenConns(1)`** + `SetMaxIdleConns(1)`, **WAL** journal mode, + `busy_timeout=5000`. One writer connection by design. +- PostgreSQL: a normal pool (10 open / 5 idle). The single connection above is a + SQLite constraint, not a Calnode design choice, and carrying it over would + serialise the whole instance on an engine with its own concurrency control. +- The handle rebinds placeholders: Calnode's SQL is written once with `?` and + rewritten to `$1…$n` on PostgreSQL. ⚠️ `db.DB` embeds `*sql.DB` and the field is + exported, so `h.DB.Query(...)` compiles and **skips rebinding** — it passes on + SQLite and fails on PostgreSQL with a syntax error far from the edit. Call the + wrapper's own methods. The handful of statements no single spelling covers use + `Dialect.SQL(sqlite, postgres)`. +- Timestamps are computed in Go (`internal/dbtime`) rather than by the engine; + `datetime('now')` and `strftime` do not exist in PostgreSQL. `dbtime` keeps the + two layouts the schema already stores, byte-identical to what SQLite wrote. +- Migrations: **goose** SQL files in `internal/db/migrations/{sqlite,postgres}/` — + one schema, two spellings, same version numbers. Run automatically on startup. + `ALTER TABLE ADD COLUMN` is reversible-by-convention only (SQLite can't easily + drop columns). + +### ⚠️ The single-connection gotcha (bit us once) — SQLite only With one connection, **never run a query while a `rows` cursor from the same pool is still open** (i.e. inside a `for rows.Next()` loop). The open cursor holds the @@ -85,9 +135,32 @@ exceeded` (not "database is locked"). **Pattern:** read the cursor fully into a slice, close it, then loop. See `Handler.assignedHosts`, the calendar reconciler, `Reschedule`. (Memory: `sqlite-single-connection`.) -Bonus property: because booking transactions serialize on the single connection, -the app-level overlap check reliably guards **all** hosts (not just the one the -partial unique index covers) — no TOCTOU between concurrent bookings. +This is a consequence of the single connection, so it does not apply on +PostgreSQL — but the materialise-first pattern must stay, because it is the only +thing keeping those three paths working on SQLite. + +### The double-booking guarantee, per engine + +The app-level overlap check reads "is this host free?" and then writes on the +answer. What keeps that free of TOCTOU races differs: + +- **SQLite** — booking transactions serialize on the single connection, so the + check reliably guards **all** hosts, not just the one the partial unique index + covers. +- **PostgreSQL** — the pool has many connections, so two overlapping bookings could + both clear the check. `booking.lockHosts` takes **`pg_advisory_xact_lock`** on + each host id before the first overlap read, in `Create`, `Reschedule` and + `ReassignHost`. The key is SHA-256 of `"calnode:booking:host:" + hostID`, first + eight bytes as an int64; ids are locked in sorted order so two transactions + needing the same pair cannot deadlock. The lock ends with the transaction, so + there is nothing to release. On SQLite it is a no-op. + +Both engines also carry the partial unique index +`idx_bookings_no_double (host_id, start_at) WHERE status != 'cancelled'`. It catches +an **identical** start time and nothing else — a partial overlap is two distinct +keys — which is why the app-level check and the lock exist and why all three stay. +Measured on PostgreSQL, 40 races between overlapping-but-not-identical slots: with +the lock, 0 double bookings; without it, 39, of which the index caught one. ### Data model (key tables) @@ -186,6 +259,43 @@ the platform/recovery secret doesn't expose secrets. Owner-gated actions: grant/revoke admin, transfer ownership. Admins can cancel any booking, see all bookings, manage teams/members. Safe-removal + archive guards prevent orphaning. +- **Sign out everywhere** (`POST /v1/auth/sessions/revoke-all`, `session.go`). With no + body it drops all of the caller's sessions **except the one that made the request** — + "sign out my other devices", as distinct from `POST /v1/auth/logout`, which ends the + current one. (An API-key caller has no current session, so for them every session + goes.) With `{"user_id": "..."}` it is an offboarding tool, gated on the same tiers as + `roles.go`: an admin may revoke a member, only the owner may revoke another admin, and + the owner's sessions are reachable only by the owner. The actor's tier is checked + *before* the target is loaded, so the 404 cannot be used to enumerate user ids. + ⛔ It also deletes the target's rows in **`oauth_access_tokens`**, cutting off any MCP + connector (§19) — those authenticate with a bearer token, not the session cookie, so + revoking sessions alone would leave an agent holding the authority just withdrawn. + Both deletes run in one transaction, so "revoked" is never half-true. +- **Signed session hand-off** (`GET /v1/auth/sso?token=`, `sso.go`) lets an + external identity system that has already authenticated someone drop them into a + Calnode session without a second login. **Off unless `CALNODE_SSO_SHARED_SECRET` is + set** — an unconfigured instance answers **404**, deliberately indistinguishable from + a build without the feature. The token is a compact **HS256** JWT verified in-tree + (`crypto/hmac`, like `internal/livekit`'s signing) with a constant-time compare; only + HS256 is accepted, checked *before* the signature so the `alg: none` downgrade never + reaches it. Claims: `iss` (any non-empty string, logged only), `aud` = `BASE_URL` + (what stops a staging token being spent on production when a secret is shared by + mistake), `sub` = email, `name`, `role` ∈ owner|admin|member, `iat`, `exp` at most + **60 s** after `iat` with **30 s** of clock skew allowed either way, and a unique + `jti`. `wid` is parsed and ignored — a multi-workspace mode will use it. The `jti` is + claimed in **`sso_nonces`** *before* the session is created, so a replay inside the + validity window collides on the primary key rather than racing a read-then-write; the + worker purges expired rows in its GC pass (§13). Success is a 302 to `/admin/`, or to + `?next=` when that is a same-origin absolute path (anything with a scheme, a `//` + prefix, a backslash or a control character is a 400, refused rather than sanitised). + Every other failure is a 401 whose JSON body names the claim that failed. Rate-limited + like the OAuth callbacks. + ⛔ **This is the one path that creates a user without an invite.** Everywhere else an + unknown email is refused (`no_account`); here the shared secret is the difference — the + caller is the operator's own identity system. On creation the claim's `role` is applied; + on an existing user the role is **not** rewritten, except that a claim asking for + `owner` bootstraps ownership when the instance has none (the one-owner invariant §6 + maintains means there is nothing to displace). An archived user is still refused. - **Offboarding = archive** (`users.archived_at`), never hard-delete — preserves bookings, event-type ownership, team links. Archived ⇒ no login, hidden from lists, skipped in routing/slots, event types deactivated. Reversible (restore). @@ -264,7 +374,7 @@ members. mode (role-tagged), then loads each host's availability **concurrently** (goroutines) — the slow part is one Google free/busy round-trip per host, so parallelizing turns N sequential calls into ~one call's latency. DB queries - serialize on the single connection (fast); only the network overlaps. Response + serialize on SQLite's single connection (fast); only the network overlaps. Response includes a `hosts` metadata map (id→name/avatar) for rendering faces. - **Busy** for a host = every non-cancelled booking they attend, via `booking_hosts` (NOT just `bookings.host_id`) — so a non-primary Group/fixed seat @@ -635,14 +745,27 @@ as the desired state: reset to pending +1 min). Retry **backoff is a fixed two-step: 60s then 5 min** (not exponential), `max_attempts` 3. Atomic claim via `UPDATE … WHERE status='pending'` + RowsAffected. -- `internal/webhook`: enqueues `booking.created` / `.cancelled` / `.rescheduled` - plus the notetaker events `recording.completed` / `transcript.ready` / `notes.ready` +- `internal/webhook`: enqueues `booking.created` / `.cancelled` / `.rescheduled` / + `.reminder` plus the notetaker events `recording.completed` / `transcript.ready` / + `notes.ready` (reference payloads — booking-shaped, keyed by id; consumers fetch the artifact body - via REST/MCP). There is **no** `booking.reminder` webhook event. Deliveries are signed + via REST/MCP). Deliveries are signed **HMAC-SHA256**, header `X-Calnode-Signature` (+ `X-Calnode-Event`/`-Delivery`), secret stored encrypted. The worker's HTTP client is **SSRF-guarded** (resolves DNS, blocks private/loopback/CGNAT/ULA IPs, dials the resolved IP to avoid re-resolution) since webhook URLs are user-supplied. +- **`booking.reminder`** is fired by the `reminder.send` job (`internal/worker`), booking-shaped + like its siblings plus **`hours_before`** — an event type can configure several reminders, so + the payload has to say which one this is. It carries no payment fields: those come from + create/cancel, and `paymentStatusForWebhook`'s mapping lives in the handler package. + ⛔ **Fired after the email and only on success**, because the event means "the attendee has + been reminded". Emitting it beside a failed send would be untrue, and the job retries — which + would deliver it twice for one reminder. Conversely an *enqueue* failure does **not** fail the + job: the email has already gone and a retry would send a second one, so it is logged and + dropped. `sendReminder`'s early returns (booking deleted, no longer confirmed, host has + reminder emails off) are all "no reminder happened", so none of them fires it either. + ⚠️ The event needs the **booking's** `host_id`, not the event type's owner — a rotation or a + reassignment moves it, and `Enqueue` selects a subscriber's webhooks by that id. - **Per-webhook payload fields:** each webhook chooses which fields land in the `data` object (`webhooks.fields` JSON, migration 00027) — incl. attendee PII + intake answers. NULL ⇒ the original default set (so existing webhooks are unchanged and @@ -694,17 +817,33 @@ as the desired state: **original `Host` header**. The CSRF same-origin check (§6) compares the request's `Origin`/`Referer` against `Host`, so a proxy that rewrites Host would *false-block admin writes* (403). Fly and Railway preserve Host by default; a hand-rolled nginx - needs `proxy_set_header Host $host;`. Related: per-IP rate limits (§8) key on the - **TCP remote address** (proxy headers like `X-Forwarded-For` are intentionally - ignored as forgeable), so behind a shared proxy the limit keys on the proxy's - connection — fine for per-instance Fly/Railway, worth knowing for a fronting proxy. + needs `proxy_set_header Host $host;`. +- **Per-IP rate limits (§8) key on the TCP remote address by default**, and + `X-Forwarded-For` / `X-Real-IP` / `CF-Connecting-IP` are not read at all. That is not + an oversight: those headers are client-chosen values, so believing them unconditionally + would let anyone split their own rate-limit bucket by sending a different one each + request. Behind a shared proxy the limit therefore keys on the proxy's connection — + fine for a per-instance Fly/Railway deploy, worth knowing for a fronting CDN. + **`TRUSTED_PROXY_CIDRS`** (comma-separated CIDRs, a bare address meaning one host) + opts in per network: for a peer inside one of those ranges, `TrustClientIP` + (`internal/server/middleware.go`) resolves the client IP from `CF-Connecting-IP` if + present, else by walking `X-Forwarded-For` **right to left past trusted hops** and + taking the first untrusted address, else the peer. ⛔ Not the leftmost entry: the left + of that header is whatever the original client sent, and every well-behaved proxy + preserves it. A hop that does not parse ends the walk and falls back to the peer rather + than being skipped, so one malformed entry cannot push the walk onto a value the client + chose. Headers from an **untrusted** peer are never read, which is what keeps the + default un-weakenable by a header. Resolution happens once, in the outermost + middleware, and is carried in the request context. --- ## 17. Cross-cutting gotchas (read before editing) 1. **SQLite single connection** — never query inside an open cursor; materialize - first (§4). + first (§4). Harmless on PostgreSQL, but the pattern stays either way. + On PostgreSQL the same single connection is also what the booking overlap check + loses, which `pg_advisory_xact_lock` replaces (§4). 2. **All times UTC** in storage; convert at the edges. The slot busy-window must be widened for tz boundaries. 3. **Frontend is embedded at compile time** — `pnpm build` + rebuild Go to see @@ -719,6 +858,24 @@ as the desired state: strict default and relaxes only when head code-injection is configured (broad `https:` or the operator's `tracking_csp_allow`). Don't re-hardcode the CSP on the `book`/`manage` handlers — route it through `publicCSP`. +9. **`FRAME_ANCESTORS` is the admin SPA's only, and it must stay that way.** Set it + (space-separated `https://host[:port]` / `'self'`) and the handler under `/admin/` + sends `Content-Security-Policy: frame-ancestors ` so an operator can embed the + console in their own tooling. `internal/server`'s `FrameAncestors` middleware wraps + `frontend.Handler()` and nothing else: the public booking pages keep + `frame-ancestors 'none'` + `X-Frame-Options: DENY` unconditionally, because they are + unauthenticated pages collecting names, emails and card details and clickjacking one + is worth more than framing a console nobody reaches without a session. + ⚠️ **Unset sends no frame header at all, which is what `/admin/` has always sent** — + the SPA is framable by default. This setting deliberately does *not* add a default + deny, since an opt-in flag must not smuggle in a behaviour change; + `TestAdminSPA_sendsNoFrameHeadersWhenUnset` pins the current answer so changing it is + a decision. No `X-Frame-Options` is sent beside the CSP either: that header has no + allow-list form (`ALLOW-FROM` is dead), so the only value it could carry is + `SAMEORIGIN`, which browsers apply *instead of* the CSP and would break the embedding. + An entry that isn't `https://host[:port]` or `'self'` fails `config.Validate()` and the + process **refuses to start** — a browser drops a source list it cannot parse, so a + typo would otherwise leave `/admin/` more embeddable than with the setting unset. --- @@ -984,7 +1141,18 @@ LLM summary) — the next build; consent-gated (§8.11/§15 of the PRD). ## 23. Languages (i18n) -Calnode ships **8 locales**: `en` (source) · `es` · `fr` · `de` · `it` · `pt` · `nl` · `sv`. +Calnode ships **9 locales**: `en` (source) · `es` · `fr` · `fr-CA` · `de` · `it` · `pt` · `nl` · `sv`. + +`fr-CA` is the first **regional** locale, and it is a separate file rather than a fallback +because the differences are real: `courriel` not `e-mail`, `reporter`/`report` not +`reprogrammer`/`reprogrammation`, `renseignements personnels` never `données personnelles` +(the Quebec statutory term), no space before `!` `?` `;` where France puts one, and CLDR +itself disagrees on one abbreviation — `month_short_jul` is `juill.` in fr-CA and `juil.` in +fr, which is exactly what `TestDateTablesMatchCLDR` exists to catch. Both keep the 24-hour +clock and the day-month `date_format`. Currency and percent take a non-breaking space before +`$` and `%` in Canadian French; no key carries either today, so the rule is recorded here +rather than applied. A visitor sending `fr-FR` or plain `fr` is unaffected — the matcher +picks the exact tag first (pinned in `TestResolve`). ### What is translated, and what is not diff --git a/docs/MULTI_TENANT.md b/docs/MULTI_TENANT.md new file mode 100644 index 0000000..1ae272d --- /dev/null +++ b/docs/MULTI_TENANT.md @@ -0,0 +1,271 @@ +# Multi-tenant mode + +One process, many isolated workspaces. `MULTI_TENANT` unset behaves exactly as it always has: +SQLite and single-tenant PostgreSQL are unchanged, and every existing test passes without +modification. That is a gate, not an aspiration. + +## What it is + +A `workspaces` table is the tenant root. Every application table carries a `workspace_id`, and +**PostgreSQL row-level security — not the query author — is what keeps one workspace out of +another's rows.** A query that forgets its predicate returns nothing rather than everything. + +The tenant of a request is resolved from the request `Host` (each workspace has its own public +hostname) or from the credential it carries (API key, session, OAuth token, manage token, invite, +magic link), and every route says which of the two it uses. + +## What it requires + +- **PostgreSQL.** SQLite has no row-level security, so there is nothing to express the isolation + with. Startup refuses `MULTI_TENANT` without a `postgres://` DSN. +- **Two roles, two DSNs.** + + | variable | role | used for | + |---|---|---| + | `DATABASE_URL` | application: `NOBYPASSRLS`, owns no table in the schema | every request | + | `DATABASE_ADMIN_URL` | platform: schema owner, `BYPASSRLS` | migrations, RLS setup, cross-tenant reads, the worker's claim loop, the platform API | + + ⛔ **The two must not be the same role.** One role means the application owns the tables and + every policy is inert against it — and nothing breaks: every request works, and it can also read + every other workspace. That is the misconfiguration hardest to notice, so startup refuses it + outright, along with an application role that is a superuser, has `BYPASSRLS`, or owns any table + (`VerifyRoles`). The platform role is checked to genuinely bypass, because a platform role that + does not silently does no background work at all. +- **Demo mode off.** Demo mode periodically wipes the database, which here is every tenant's data. + The two are mutually exclusive and startup refuses both. + +## Environment + +| key | meaning | +|---|---| +| `MULTI_TENANT` | any non-empty value turns the mode on | +| `DATABASE_URL` / `DATABASE_ADMIN_URL` | the pair above | +| `CALNODE_PLATFORM_TOKEN` | bearer for `/v1/platform/*`. Unset ⇒ those routes 404 | +| `CALNODE_SSO_SHARED_SECRET` | HMAC key for the session hand-off. **Required** if Google or Microsoft login is configured: the callbacks hand off through it | +| `TRUSTED_PROXY_CIDRS` | networks whose forwarded headers are believed. Unset behind a proxy makes each workspace a single rate-limit bucket | +| `CALNODE_ENCRYPTION_KEY` | as before, and it must travel with any workspace moved between instances | +| `BASE_URL` | the identity host (below) | +| `PUBLIC_BASE_URL` | **ignored**; each workspace's `public_host` replaces it | +| `DATA_DIR` | where uploads (avatars, branding) are written; defaults to the relative `data`. A read-only image sets it to its mounted volume | + +## The isolation model + +Three layers, and each catches what the others cannot. + +1. **Row-level security.** Every tenant table has `ENABLE ROW LEVEL SECURITY` and one policy + comparing `workspace_id` to `current_setting('app.workspace_id', true)`. An unset or empty + setting matches no row, so an unbound statement is silently empty rather than silently global. + ⚠️ `FORCE ROW LEVEL SECURITY` is deliberately **not** used: it would apply the policy to the + table owner too, and in single-tenant mode the ordinary DSN *is* the owner — every existing + deployment whose DSN is not a superuser would go blind. `ENABLE` alone already isolates a + non-owner role, which is what the application role is. +2. **Per-statement binding.** A handle from `OpenPair` carries a workspace. Before each statement + it takes a pooled connection, runs `SELECT set_config('app.workspace_id', $1, false)`, runs the + statement there, and releases the connection when the statement finishes. Nothing is pinned + between statements, so a handle is safe to copy into a goroutine that outlives its request. + ⛔ `Prepare` is refused on a bound handle: a prepared statement is re-prepared on whatever + connection the pool hands it, which would run unbound — silently empty rather than an error. +3. **Explicit predicates where there is no policy.** Four tables are exempt because they are not + per tenant: `workspaces`, `crypto_keystore`, `goose_db_version`, `oauth_clients`, plus + `sso_nonces` (a token id is global — the question "has this token been spent" must not depend on + which workspace it names). Anything reading those, and everything on the platform handle, names + its own `workspace_id` in every statement, because there is no policy behind it. + +### The platform handle, and the one rule about it + +The platform handle bypasses the policies. Two consequences that have each caused a real bug here: + +- ⛔ **Every INSERT through it must name `workspace_id`.** It binds the empty string, so an unnamed + column resolves to `''` and the row fails its foreign key. A route that writes on the platform + handle and omits the column does not silently misfile the row — it fails — but it fails at the + database, far from the omission. +- ⛔ **A tenant-scoped handle must be derived from the APPLICATION handle, never from the platform + one.** Binding a workspace onto a bypassing role produces a handle that *names* a tenant without + being *confined* to it: `WHERE id = 1` then matches every workspace's row and returns an + arbitrary one. Reads are the failure mode, and they are silent. + +### Reads that must not be bound + +**A read whose job is to discover the tenant cannot be bound to it.** Credential lookups — +`api_keys`, `sessions`, OAuth bearer tokens — run on the platform handle and select the user's +`workspace_id` alongside. This is why those uniques stay global while `users(workspace_id, email)`, +`event_types(workspace_id, slug)` and `teams(workspace_id, slug)` become composite. + +## Route classification + +Every registration declares its class, and a source-scanning test fails on one that does not: + +| class | tenant from | examples | +|---|---|---| +| host-scoped | `Host` → `workspaces.public_host` | the booking pages, public event-type reads, `POST /v1/bookings`, `/manage/{token}`, `/admin/*` | +| credential-scoped | the verified caller | the whole authenticated API | +| platform | nothing, on purpose | `/healthz`, `/readyz`, `/version`, `/metrics`, `/.well-known/*`, `/oauth/*`, `/mcp`, `/v1/platform/*`, the OAuth login callbacks, the vendor webhooks | + +An unrecognised host is a **404**, never a fallback to a default tenant: falling back would serve +one tenant's booking page on any domain pointed at the instance. A credential that resolves +workspace A on workspace B's host is **403 `{"error":"workspace mismatch"}`**. A suspended +workspace answers **503** with `Retry-After` on its public and admin surfaces. + +## The platform API + +Identity host, `Authorization: Bearer $CALNODE_PLATFORM_TOKEN`, constant-time compare. With the +token unset — or on a single-tenant instance — every route **404s**, so a prober cannot tell a +control plane from an instance that has none. A wrong token is 401. + +### `POST /v1/platform/workspaces` → 201 + +```json +{ + "id": "acme", "slug": "acme", "public_host": "book.acme.example", "region": "us", + "owner_email": "owner@acme.example", "owner_name": "Owner", "owner_timezone": "America/Toronto", + "defaults": { + "embed_allowed_origins": ["https://acme.example"], + "webhook": { "url": "https://hooks.acme.example/in", "secret": "", + "fields": ["booking_id", "start_at"] }, + "event_type": { "slug": "intro", "name": "Intro call", "duration_minutes": 30, + "min_notice_minutes": 60, "max_future_days": 60, + "availability": [{ "day_of_week": 1, "start_time": "09:00", "end_time": "17:00" }] }, + "livekit_url": "...", "livekit_api_key": "...", "livekit_api_secret": "...", + "stt_base_url": "...", + "smtp": { "host": "...", "port": "587", "user": "...", "pass": "...", + "tls": true, "starttls": false, "from": "...", "from_name": "..." }, + "llm": { "endpoint": "...", "model": "...", "api_key": "...", "enabled": true, + "extra_instructions": "..." } + } +} +``` + +Response: `{"api_key": "cno_…", "webhook_secret": "…"}` — **shown once**. One transaction creates +the workspace row, its `server_settings` row (`id = 1` per workspace, so existing `WHERE id = 1` +reads need no change), the owner (with `iana_timezone = owner_timezone`, because availability is +local `HH:MM` and defaulting the zone would move the workspace's hours), the first API key, the +webhook subscribed to **every** event the codebase emits, and the default event type with its +availability. Either the tenant exists complete or it does not exist. + +`defaults.embed_allowed_origins` and `defaults.stt_base_url` are per workspace and are READ: the public +booking endpoints' CORS allowlist is the one stored for the workspace whose host the request names (an +empty list means any origin, and a host no workspace owns gets no `Access-Control-Allow-Origin` at all, +never `*`), and the notetaker sends that workspace's recordings to its own speech-to-text host, falling +through to `STT_BASE_URL` and then the provider default when the column is empty. In this mode +`EMBED_ALLOWED_ORIGINS` is not consulted. + +`day_of_week` is 0 = Sunday. A duplicate `id`, `slug` or `public_host` is 409, and the losing +attempt leaves nothing behind. + +### The rest + +| route | notes | +|---|---| +| `GET /v1/platform/workspaces/{id}` | the row: `id, slug, public_host, region, status, created_at, updated_at` | +| `PATCH …/{id}` | `public_host`, `status` (`active`\|`suspended`), `slug`. Nothing else: the id is referenced by every tenant row and the region is where the data physically is | +| `DELETE …/{id}` | cascades every tenant table; responds `{"recording_object_keys": [...]}` because objects in storage cannot cascade and deleting them is the caller's job | +| `POST …/{id}/export` | one JSON document, tables in replay order | +| `POST …/{id}/import` | 409 unless the workspace is empty | +| `DELETE …/{id}/attendees?email=` | erasure, counts per table | + +## The SSO hand-off + +The identity host cannot set a cookie for a tenant's domain, so after a Google or Microsoft login +the callback mints a short-lived token and redirects to the workspace's own host. + +`GET https:///v1/auth/sso?token=[&next=/path]` + +Claims: `iss`, `aud`, `sub` (email), `name`, `role` (`owner`\|`admin`\|`member`), `iat`, `exp`, +`jti`, `wid`. Verified with the shared secret; only HS256 is accepted, checked before the signature +is looked at. + +- `exp - iat` ≤ 60 s, 30 s of clock skew allowed either way. The mint side uses 30 s. +- `jti` is claimed in `sso_nonces` **before** the session is created, so a replay inside the + validity window loses on the primary key. +- ⛔ **The workspace comes from the HOST, and `wid` is checked against it.** Resolving from `wid` + alone would let a token for workspace A, presented on B's host, create A's session on B's domain + — a good signature, a resolvable `wid` and a matching audience, and the wrong outcome. A mismatch + is 403. +- `aud` must equal `https://`, with no trailing slash. +- The user is created or resolved **in that workspace**, with `workspace_id` named, and the session + row carries it too — every later request runs on a bound handle that could otherwise neither read + nor delete it. +- `role` from the token applies only to a user it creates; an existing user's role is never + rewritten by a sign-in. + +The login start carries the workspace in the state **cookie** (`|`) and sends +only the nonce to the provider. The nonce is compared, the workspace is read: a visitor can rewrite +the query parameter and achieve a failed login, and the value that selects the tenant never left +the server. + +## Export, import, erasure + +**Export** is one JSON document: `format_version`, `exported_at`, the workspace row, a +`dek_fingerprint`, and `tables` as an **ordered array** — parents before children, because import +replays it in the order it receives. Secrets and API-key hashes travel **verbatim**, because a +tenant whose keys and manage links stopped working on migration has not been migrated. The document +is therefore as sensitive as the database. + +The table list is checked against the schema's own tenant-table list at request time, so a table +added by a later migration cannot be silently absent from every backup. + +**Import** refuses (409) unless the workspace is empty, runs in one transaction, and **forces +`workspace_id` to the id in the URL** — the document's own value is discarded, or an export of any +workspace would be a way to write into any other. + +⚠️ Row ids are global primary keys, so **import is a move, not a copy**: replaying a document into a +second workspace while the first still holds its rows collides on the primary key. The supported +sequence is export → delete → import, normally into another instance. + +⛔ **The DEK fingerprint rule.** `crypto_keystore` holds one wrapped data key per **process**, not +per workspace, so the key itself does not travel — an export of one tenant containing the key that +decrypts every tenant would be the opposite of isolation. Instead the document carries a SHA-256 of +the *already-encrypted* wrapped key, and **import refuses 409 when it differs**. Without that +check the rows import perfectly and then every secret in them fails at first use, one integration +at a time, long after anyone is watching. Moving a workspace means moving +`CALNODE_ENCRYPTION_KEY` with it. + +**Erasure** (`DELETE …/attendees?email=`) removes the attendee rows for that address in that +workspace and returns counts. It cancels nothing: the bookings, the host's calendar and the other +attendees' records are not the erased person's data. Answers are keyed `(booking_id, question_id)` +and carry no attendee, so they are erased only for bookings where that person was the **only** +attendee — with anyone else on the booking, deleting them would erase a third party's data. + +## Vendor webhooks + +LiveKit and Stripe call in with their own signature and no tenant Host, so both routes are platform +routes that resolve the workspace from **our** row: the egress id or room on a recordings row, the +booking a room name encodes, or `bookings.stripe_session_id`. No resolver consults a `workspace_id` +in a vendor payload. An event no row owns is **2xx and ignored** — a 4xx would make the vendor retry +for days and no retry can make the row exist. + +⚠️ In multi-tenant mode the resolve necessarily precedes the signature check, because the signing +credentials are per workspace: there is no instance-wide secret to verify against, and verifying +against an arbitrary tenant's is not verification. Nothing is written before the signature verifies +and nothing is disclosed either way. Single-tenant keeps the original verify-then-act order. + +## What is NOT per tenant + +| thing | why, and what it costs | +|---|---| +| **the data encryption key** | one wrapped DEK per process. An operator who can read the database can decrypt every workspace, and a workspace cannot move between instances without its key. The import fingerprint check makes the coupling loud rather than silent | +| **the OAuth app credentials** | Google/Microsoft client id and secret identify the *instance* to the provider, not the tenant | +| **rate-limit windows** | keyed `(workspace, client IP)`, but the counters live in one process | +| **retention sweeps** | expired sessions, tokens and deliveries are purged globally: they are retention rules, not tenant logic | + +## Operator checklist + +1. PostgreSQL 16+ with two roles: an owner (`BYPASSRLS`) and an application role (`NOBYPASSRLS`, + owning nothing, granted DML on the schema's tables). +2. Set `MULTI_TENANT`, both DSNs, `CALNODE_PLATFORM_TOKEN`, `CALNODE_SSO_SHARED_SECRET` (if social + login is configured), `TRUSTED_PROXY_CIDRS` (if behind a proxy), and `BASE_URL`. +3. Start. Boot order is: migrate on the platform handle → enable RLS → suspend the seeded `default` + workspace → verify both roles. The first, second and fourth are fatal; the third is logged. +4. Point DNS for each tenant's `public_host` at the instance and terminate TLS for it. +5. Provision each workspace through `POST /v1/platform/workspaces`; store the `api_key` and + `webhook_secret` from the response, which are shown once. +6. Verify with a request to each tenant's own host, and confirm an unknown host 404s. +7. Before moving a workspace between instances, move `CALNODE_ENCRYPTION_KEY` too, or import will + refuse the document. + +## Cost + +Measured with 200 workspaces provisioned through the API in one process: RSS 28.9 MB → 35.9 MB, +i.e. **~35 KB per tenant**, with the connection pool unchanged at one connection per role. +`ForWorkspace` returns a value over a shared pool, so tenants cost cache entries and rows, not +connections. diff --git a/frontend/src/routes/webhooks/+page.svelte b/frontend/src/routes/webhooks/+page.svelte index ed4eb83..0ed317e 100644 --- a/frontend/src/routes/webhooks/+page.svelte +++ b/frontend/src/routes/webhooks/+page.svelte @@ -18,6 +18,7 @@ 'booking.created', 'booking.cancelled', 'booking.rescheduled', + 'booking.reminder', 'recording.completed', 'transcript.ready', 'notes.ready' @@ -37,6 +38,7 @@ { key: 'cancellation_reason', label: 'Cancellation reason' }, { key: 'previous_start_at', label: 'Previous start (reschedule)' }, { key: 'previous_end_at', label: 'Previous end (reschedule)' }, + { key: 'hours_before', label: 'Hours before (reminder)' }, ] }, { group: 'Payment', fields: [ { key: 'payment_status', label: 'Payment status' }, diff --git a/go.mod b/go.mod index 8b8a9a9..73b76fd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.26.6 require ( github.com/disintegration/imaging v1.6.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/pressly/goose/v3 v3.27.1 @@ -22,6 +23,9 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index 5dc4a22..73978d1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= @@ -18,6 +19,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= @@ -40,6 +49,9 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -66,6 +78,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.28.1 h1:XpLbkYVQ24E8tX5u8+yWGvaxerxkR/S4zqxI8ZoSBuc= diff --git a/internal/booking/concurrency_test.go b/internal/booking/concurrency_test.go new file mode 100644 index 0000000..075cf64 --- /dev/null +++ b/internal/booking/concurrency_test.go @@ -0,0 +1,93 @@ +package booking_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/calnode/calnode/internal/booking" + "github.com/calnode/calnode/internal/dbtest" +) + +// TestCreate_concurrentPartialOverlap_postgres is the test for the guarantee +// pg_advisory_xact_lock replaces. +// +// The two slots deliberately OVERLAP without SHARING a start time. That is the case +// no index catches: idx_bookings_no_double is UNIQUE(host_id, start_at), so 10:00 +// and 10:15 are two distinct keys and both inserts satisfy it. The only thing +// standing between them is hostBusy's read, and on a multi-connection pool two +// transactions can both take that read before either writes. If the lock is not +// held, this test double-books. +// +// It is skipped on SQLite, where the race is not reachable: db.SetMaxOpenConns(1) +// means the second transaction cannot begin until the first has committed. +func TestCreate_concurrentPartialOverlap_postgres(t *testing.T) { + database := dbtest.RequirePostgres(t) + svc := booking.New(database) + hostID := seedHost(t, database) + etID := seedEventType(t, database, hostID) + + // Enough rounds that a lost race is very unlikely to go unseen. One round is + // not evidence: two goroutines miss each other's window often enough that an + // unlocked build passes a single round most of the time. + const rounds = 40 + + for round := 0; round < rounds; round++ { + // A fresh, non-overlapping window per round, so a round is independent of + // every earlier round's surviving booking. + base := slot(0, 0).Add(time.Duration(round) * time.Hour) + first := [2]time.Time{base, base.Add(30 * time.Minute)} + second := [2]time.Time{base.Add(15 * time.Minute), base.Add(45 * time.Minute)} + + var wg sync.WaitGroup + errs := make([]error, 2) + start := make(chan struct{}) + for i, window := range [2][2]time.Time{first, second} { + wg.Add(1) + go func(i int, from, to time.Time) { + defer wg.Done() + <-start // line both up so the transactions truly overlap + _, errs[i] = svc.Create(context.Background(), booking.CreateParams{ + EventTypeID: etID, + HostIDs: []string{hostID}, + StartAt: from, + EndAt: to, + Organizer: booking.Attendee{ + Name: "Alice", + Email: "alice@example.com", + }, + }) + }(i, window[0], window[1]) + } + close(start) + wg.Wait() + + var created int + for i, err := range errs { + switch { + case err == nil: + created++ + case errors.Is(err, booking.ErrDoubleBooked): + // the expected loser + default: + t.Fatalf("round %d: booking %d: unexpected error: %v", round, i, err) + } + } + if created != 1 { + t.Fatalf("round %d: %d of 2 overlapping bookings were created; want exactly 1 (errors: %v, %v)", + round, created, errs[0], errs[1]) + } + } + + // And the database agrees: one booking per round, never two in a window. + var n int + if err := database.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE host_id = ? AND status != 'cancelled'`, hostID).Scan(&n); err != nil { + t.Fatalf("count bookings: %v", err) + } + if n != rounds { + t.Errorf("bookings for host = %d; want %d (one per round)", n, rounds) + } +} diff --git a/internal/booking/hostlock.go b/internal/booking/hostlock.go new file mode 100644 index 0000000..678cc10 --- /dev/null +++ b/internal/booking/hostlock.go @@ -0,0 +1,78 @@ +package booking + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "slices" + + "github.com/calnode/calnode/internal/db" +) + +// hostLockDomain prefixes every id fed to the hash below, so a future advisory +// lock on some other entity cannot collide with a host's key by hashing the same +// raw string. +const hostLockDomain = "calnode:booking:host:" + +// lockHosts serialises the check-then-write window for a set of hosts, for the +// remainder of tx. +// +// Create, Reschedule and ReassignHost all read "is this host busy at this time?" +// and then write on the answer. On SQLite that is free of TOCTOU races without any +// locking, because the pool is a single connection (db.SetMaxOpenConns(1), see +// internal/db/db.go) and transactions therefore cannot interleave at all. A +// Postgres pool has many connections: two overlapping bookings can both read +// "free" and both insert, and the partial unique index +// idx_bookings_no_double(host_id, start_at) only catches an identical start time, +// not a partial overlap. That is exactly the gap the app-level check was never +// asked to close on its own. +// +// pg_advisory_xact_lock is held until the transaction commits or rolls back, so +// there is no unlock call to forget and no leak when a caller returns early — which +// every guard in these functions does. Locking per host rather than once globally +// keeps bookings for different hosts concurrent, which is the whole point of moving +// off the single connection. +// +// On SQLite this is a no-op and the existing guarantee stands unchanged. +func lockHosts(ctx context.Context, tx *db.Tx, hostIDs ...string) error { + if tx.Dialect() != db.DialectPostgres { + return nil + } + + // Sorted and deduplicated. Two transactions that needed the same two hosts in + // opposite orders would otherwise deadlock, and Postgres resolves a deadlock by + // killing one of them — a 500 on a booking that should have been a 409 or a + // success. Sorting is done on a copy: Create's HostIDs arrive in round-robin + // priority order and that order decides who gets the booking. + ids := slices.Clone(hostIDs) + slices.Sort(ids) + ids = slices.Compact(ids) + + for _, id := range ids { + if id == "" { + continue + } + if _, err := tx.ExecContext(ctx, + `SELECT pg_advisory_xact_lock(?)`, hostLockKey(id)); err != nil { + return fmt.Errorf("booking: lock host %s: %w", id, err) + } + } + return nil +} + +// hostLockKey maps a host id onto the single int64 key pg_advisory_xact_lock takes. +// +// SHA-256 of the domain-separated id, with the first eight bytes read big-endian as +// a signed integer. Deriving it in Go rather than with the engine's hashtext() keeps +// the derivation readable and testable from Go, and means the value arrives as an +// ordinary bound parameter. +// +// Two distinct hosts landing on the same key would cost an unnecessary +// serialisation between two unrelated bookings, never a wrong answer, so what this +// needs is stability and a good spread rather than cryptographic strength. SHA-256 +// is used because this package already imports it for manage tokens. +func hostLockKey(hostID string) int64 { + sum := sha256.Sum256([]byte(hostLockDomain + hostID)) + return int64(binary.BigEndian.Uint64(sum[:8])) +} diff --git a/internal/booking/hostlock_internal_test.go b/internal/booking/hostlock_internal_test.go new file mode 100644 index 0000000..eb8fce0 --- /dev/null +++ b/internal/booking/hostlock_internal_test.go @@ -0,0 +1,49 @@ +package booking + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestHostLockKey pins the derivation. The key must not move between processes or +// releases: two Calnode instances sharing one Postgres have to derive the same key +// for the same host, or they take different locks and serialise against nothing. +// +// The expected values were computed independently of this code (SHA-256 of +// "calnode:booking:host:" + id, first eight bytes big-endian as a signed integer), +// so this fails if the implementation drifts rather than agreeing with itself. +func TestHostLockKey(t *testing.T) { + cases := map[string]int64{ + "host-42": 7118598067704648523, + "host-43": 2949734679630933584, + } + for id, want := range cases { + if got := hostLockKey(id); got != want { + t.Errorf("hostLockKey(%q) = %d; want %d — the derivation changed, which desynchronises running instances", + id, got, want) + } + } +} + +// TestLockHosts_sqliteIsNoOp records the other half of the design: SQLite already +// has the guarantee, so the lock must not be attempted there. pg_advisory_xact_lock +// does not exist in SQLite, so if this ever started issuing the statement the error +// would be immediate. +func TestLockHosts_sqliteIsNoOp(t *testing.T) { + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + defer h.Close() + + tx, err := h.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck + + if err := lockHosts(t.Context(), tx, "a", "b", "a", ""); err != nil { + t.Errorf("lockHosts on SQLite returned %v; want nil (it must be a no-op there)", err) + } +} diff --git a/internal/booking/service.go b/internal/booking/service.go index da604f9..3ef8fe2 100644 --- a/internal/booking/service.go +++ b/internal/booking/service.go @@ -13,19 +13,29 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) // Service handles booking creation and lifecycle. type Service struct { - db *sql.DB + db *db.DB } // New returns a Service backed by db. -func New(db *sql.DB) *Service { +func New(db *db.DB) *Service { return &Service{db: db} } +// ForDB returns a copy of s backed by handle. It is how a per-request handler +// gets a booking service bound to its workspace: the Service is a struct over a +// pool, so this costs one allocation and pins nothing. +func (s *Service) ForDB(handle *db.DB) *Service { + copied := *s + copied.db = handle + return &copied +} + // Create inserts a new confirmed booking inside a transaction. // It checks for overlapping bookings for every host in p.HostIDs before // inserting, satisfying the double-booking guard described in §6.4. @@ -45,6 +55,17 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) } defer tx.Rollback() //nolint:errcheck + // Before the first overlap read: every host whose availability this + // transaction is about to decide on. See lockHosts — on SQLite it does + // nothing, on Postgres it is what makes the checks below race-free. + lockIDs := make([]string, 0, len(p.HostIDs)+len(p.RequiredHosts)+len(p.OptionalHosts)) + lockIDs = append(lockIDs, p.HostIDs...) + lockIDs = append(lockIDs, p.RequiredHosts...) + lockIDs = append(lockIDs, p.OptionalHosts...) + if err := lockHosts(ctx, tx, lockIDs...); err != nil { + return nil, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) // Select hosts. Round-robin picks one *free* candidate from the rotation pool @@ -124,7 +145,7 @@ func (s *Service) Create(ctx context.Context, p CreateParams) (*Booking, error) SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 WHERE b.event_type_id = ? AND b.status != 'cancelled' - AND b.end_at > ? AND a.email = ? COLLATE NOCASE`, + AND b.end_at > ? AND LOWER(a.email) = LOWER(?)`, p.EventTypeID, now, p.Organizer.Email).Scan(&active); err != nil { return nil, fmt.Errorf("booking: active-limit check: %w", err) } @@ -279,7 +300,7 @@ const bookingColumns = `id, event_type_id, host_id, start_at, end_at, status, // than matching bookings.host_id (which would miss a Group/fixed-host attendee). // excludeBookingID excludes the booking being modified from its own overlap check // (Reschedule/ReassignHost); pass "" when there's no booking yet to exclude (Create). -func hostBusy(ctx context.Context, tx *sql.Tx, hostID, start, end, excludeBookingID string) (bool, error) { +func hostBusy(ctx context.Context, tx *db.Tx, hostID, start, end, excludeBookingID string) (bool, error) { var n int err := tx.QueryRowContext(ctx, ` SELECT COUNT(*) FROM bookings b @@ -377,7 +398,7 @@ func (s *Service) ValidateManageToken(ctx context.Context, rawToken string) (*Bo // be in priority order (lowest priority number first). "priority" takes the first // free host; "even" (and "soonest", which has no meaning once the slot is fixed) // take the least-loaded one. -func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { +func pickRotationHost(ctx context.Context, tx *db.Tx, eventTypeID, strategy string, free []string, now string) (string, error) { if strategy == "priority" { return free[0], nil } @@ -387,7 +408,7 @@ func pickRotationHost(ctx context.Context, tx *sql.Tx, eventTypeID, strategy str // leastLoadedHost returns the candidate with the fewest upcoming (non-cancelled, // not-yet-ended) bookings for this event type — even-distribution round-robin. // Ties are broken by the order of candidates (caller passes them in priority order). -func leastLoadedHost(ctx context.Context, tx *sql.Tx, eventTypeID string, candidates []string, now string) (string, error) { +func leastLoadedHost(ctx context.Context, tx *db.Tx, eventTypeID string, candidates []string, now string) (string, error) { ph := make([]string, len(candidates)) args := make([]any, 0, len(candidates)+2) args = append(args, eventTypeID, now) @@ -449,6 +470,15 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne return nil, ErrAlreadyCancelled } + // The primary host is locked before the host list is read, not after. A + // concurrent ReassignHost locks the booking's current primary too, so holding it + // here is what stops that reassignment committing between this read of + // booking_hosts and the UPDATE below — which would move the booking to a host + // whose availability was never checked. + if err := lockHosts(ctx, tx, b.HostID); err != nil { + return nil, err + } + // Every host on this booking keeps their seat through a reschedule, so each // must be free at the new time — not just the primary. Read the host list // fully before the per-host overlap queries (single-connection pool). @@ -469,6 +499,12 @@ func (s *Service) Reschedule(ctx context.Context, bookingID string, newStart, ne if len(hostIDs) == 0 { // legacy booking with no booking_hosts rows hostIDs = []string{b.HostID} } + // Now the rest of the seat holders, still before any overlap check. Re-locking + // the primary is free: an advisory lock already held by this transaction is + // re-entrant and is released once, at commit. + if err := lockHosts(ctx, tx, hostIDs...); err != nil { + return nil, err + } for _, hid := range hostIDs { busy, err := hostBusy(ctx, tx, hid, startStr, endStr, bookingID) if err != nil { @@ -527,6 +563,13 @@ func (s *Service) ReassignHost(ctx context.Context, bookingID, newHostID string) startStr := b.StartAt.UTC().Format(time.RFC3339Nano) endStr := b.EndAt.UTC().Format(time.RFC3339Nano) + // Both hosts: the new one because its availability is being decided, the old + // one because a concurrent Reschedule of this booking holds that same key and + // must not interleave with the host change. + if err := lockHosts(ctx, tx, b.HostID, newHostID); err != nil { + return nil, err + } + // The new host must be free at this time across everything they attend. busy, err := hostBusy(ctx, tx, newHostID, startStr, endStr, bookingID) if err != nil { @@ -652,7 +695,11 @@ func scanBooking(s scanner) (*Booking, error) { return &b, nil } -// isUniqueViolation reports whether err is a SQLite UNIQUE constraint failure. -func isUniqueViolation(err error) bool { - return strings.Contains(err.Error(), "UNIQUE constraint failed") -} +// isUniqueViolation reports whether err is a unique-constraint violation — on this +// booking path, idx_bookings_no_double rejecting an exact start-time collision that +// the app-level overlap check did not catch. +// +// A thin wrapper over db.IsUniqueViolation, kept only because three call sites read +// better with the local name and the doc comment above belongs to this path rather +// than to the shared helper. +func isUniqueViolation(err error) bool { return db.IsUniqueViolation(err) } diff --git a/internal/booking/service_test.go b/internal/booking/service_test.go index 9f656a0..18618a5 100644 --- a/internal/booking/service_test.go +++ b/internal/booking/service_test.go @@ -2,29 +2,22 @@ package booking_test import ( "context" - "database/sql" "testing" "time" "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/uid" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open test db: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate test db: %v", err) - } + database := dbtest.Open(t) return database } -func seedHost(t *testing.T, database *sql.DB) string { +func seedHost(t *testing.T, database *db.DB) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` @@ -37,7 +30,7 @@ func seedHost(t *testing.T, database *sql.DB) string { return id } -func seedEventType(t *testing.T, database *sql.DB, userID string) string { +func seedEventType(t *testing.T, database *db.DB, userID string) string { t.Helper() id := uid.New() _, err := database.ExecContext(context.Background(), ` diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go index cf143e7..661fc2d 100644 --- a/internal/caldav/caldav.go +++ b/internal/caldav/caldav.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/netutil" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -37,7 +38,7 @@ var _ calendar.Provider = (*Client)(nil) // Client manages CalDAV connections (encrypted app-password credentials) and access. type Client struct { - db *sql.DB + db *db.DB key [32]byte logger *slog.Logger hc *http.Client @@ -45,7 +46,7 @@ type Client struct { // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key (the same // instance key used to encrypt the other providers' tokens). -func New(db *sql.DB, encKeyHex string) (*Client, error) { +func New(db *db.DB, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("caldav: invalid encryption key") @@ -325,3 +326,13 @@ func (c *Client) decryptEncoding(ciphertext string, enc *base64.Encoding) ([]byt } return plain, nil } + +// ForDB returns a copy of c reading and writing through handle, so a per-request +// handler can bind CalDAV to its workspace. The OAuth app configuration and +// the encryption key are instance-level (D7) and are carried over unchanged; only +// the database handle differs. +func (c *Client) ForDB(handle *db.DB) calendar.Provider { + copied := *c + copied.db = handle + return &copied +} diff --git a/internal/caldav/caldav_test.go b/internal/caldav/caldav_test.go index ab24ed7..eba7a93 100644 --- a/internal/caldav/caldav_test.go +++ b/internal/caldav/caldav_test.go @@ -2,7 +2,6 @@ package caldav import ( "context" - "database/sql" "io" "net/http" "net/http/httptest" @@ -12,19 +11,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -267,7 +261,7 @@ func TestInvitesGuests_false(t *testing.T) { // ----- helpers ----- -func destEmail(t *testing.T, database *sql.DB, userID string) string { +func destEmail(t *testing.T, database *db.DB, userID string) string { t.Helper() var e string err := database.QueryRowContext(context.Background(), @@ -278,7 +272,7 @@ func destEmail(t *testing.T, database *sql.DB, userID string) string { return e } -func countConns(t *testing.T, database *sql.DB, userID string) int { +func countConns(t *testing.T, database *db.DB, userID string) int { t.Helper() var n int if err := database.QueryRowContext(context.Background(), diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index afaade9..9c807a4 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -10,6 +10,7 @@ import ( "sort" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/slots" ) @@ -50,6 +51,21 @@ type Provider interface { Name() string // "google" | "microsoft" InvitesGuests() bool // provider emails guests itself → suppress our own .ics + // ForDB returns a copy of this provider reading and writing through handle. + // + // ⛔ Every provider captures a *db.DB at construction, and every one of its + // operations reads calendar_connections / connection_calendars — which are + // TENANT tables. On a multi-tenant instance a provider built at boot holds the + // UNBOUND application handle, which matches no row, so the whole integration + // would be silently inert. This is how a per-request handler gets providers + // bound to its workspace. + // + // The OAuth APP configuration — client id, secret, redirect, encryption key — + // is deliberately NOT per workspace (D7): it identifies the Calnode instance to + // Google or Microsoft, not the tenant. So this is a shallow copy: same config, + // different handle. + ForDB(handle *db.DB) Provider + // OAuth AuthURL(state string) string EncryptState(userID string) (string, error) @@ -82,16 +98,30 @@ type Provider interface { // Service holds the configured providers and dispatches per-user operations to // whichever provider that user has connected. type Service struct { - db *sql.DB + db *db.DB providers map[string]Provider primary string // default provider for new connections (first registered) } // NewService returns an empty Service. Register one provider per configured backend. -func NewService(db *sql.DB) *Service { +func NewService(db *db.DB) *Service { return &Service{db: db, providers: map[string]Provider{}} } +// ForDB returns a copy of s, and of every provider registered in it, bound to +// handle. +// +// The provider map is rebuilt rather than shared, because a Provider owns its own +// handle. primary is carried over, so which backend claims a new connection does +// not change per workspace. +func (s *Service) ForDB(handle *db.DB) *Service { + bound := &Service{db: handle, providers: make(map[string]Provider, len(s.providers)), primary: s.primary} + for name, p := range s.providers { + bound.providers[name] = p.ForDB(handle) + } + return bound +} + // Register adds a provider (keyed by Name()); the first registered becomes primary. func (s *Service) Register(p Provider) { s.providers[p.Name()] = p diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index 8f778e5..b066708 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -4,18 +4,11 @@ import ( "context" "testing" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) func TestCanAutoGenerate(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() seed := func(userID, provider, kind string) { @@ -66,14 +59,7 @@ func TestCanAutoGenerate(t *testing.T) { // TestConnectionManagement covers the multi-calendar Service helpers: listing connections, // switching the single destination, and promoting a survivor when the destination is removed. func TestConnectionManagement(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) ctx := context.Background() if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/calendar/conflicts.go b/internal/calendar/conflicts.go index 7da2998..946ece1 100644 --- a/internal/calendar/conflicts.go +++ b/internal/calendar/conflicts.go @@ -2,7 +2,8 @@ package calendar import ( "context" - "database/sql" + + "github.com/calnode/calnode/internal/db" ) // ConflictCalendarIDs resolves which calendar IDs of one connected account must be checked for @@ -17,7 +18,7 @@ import ( // // The caller must have no open rows cursor on the shared DB pool when calling this (the pool is // single-connection): drain and Close() any cursor first. -func ConflictCalendarIDs(ctx context.Context, db *sql.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { +func ConflictCalendarIDs(ctx context.Context, db *db.DB, provider, userID, accountEmail, fallbackCalID string) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT calendar_id, check_conflicts FROM connection_calendars WHERE user_id = ? AND provider = ? AND account_email = ?`, diff --git a/internal/calendar/connid_staleness_test.go b/internal/calendar/connid_staleness_test.go index 5264e20..484dffe 100644 --- a/internal/calendar/connid_staleness_test.go +++ b/internal/calendar/connid_staleness_test.go @@ -7,19 +7,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // newTestDB opens a migrated in-memory DB with one user. -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) if _, err := database.Exec( `INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) VALUES ('u1','u1@x.test','U','UTC',0,'2026-01-01T00:00:00Z')`); err != nil { @@ -28,7 +22,7 @@ func newTestDB(t *testing.T) *sql.DB { return database } -func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string, check, dest int) { +func seedConn(t *testing.T, database *db.DB, id, userID, provider, email string, check, dest int) { t.Helper() if _, err := database.Exec( `INSERT INTO calendar_connections @@ -50,7 +44,7 @@ func seedConn(t *testing.T, database *sql.DB, id, userID, provider, email string // refreshToken reproduces what a provider does on token refresh: same account, brand new // row id. -func refreshToken(t *testing.T, db *sql.DB, userID, provider, email, newID string) { +func refreshToken(t *testing.T, db *db.DB, userID, provider, email, newID string) { t.Helper() var dest, check int if err := db.QueryRow( diff --git a/internal/calendar/microsoft/microsoft.go b/internal/calendar/microsoft/microsoft.go index 6194e0a..fc77649 100644 --- a/internal/calendar/microsoft/microsoft.go +++ b/internal/calendar/microsoft/microsoft.go @@ -27,6 +27,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -41,14 +42,14 @@ var _ calendar.Provider = (*Client)(nil) type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Graph API; overridable in tests } // New creates a Client. tenant defaults to "common" (any Microsoft account). // encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, tenant, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("microsoft: invalid encryption key") @@ -460,3 +461,13 @@ func (c *Client) decryptEncoding(ciphertext string, enc *base64.Encoding) ([]byt } return plain, nil } + +// ForDB returns a copy of c reading and writing through handle, so a per-request +// handler can bind Microsoft Graph to its workspace. The OAuth app configuration and +// the encryption key are instance-level (D7) and are carried over unchanged; only +// the database handle differs. +func (c *Client) ForDB(handle *db.DB) calendar.Provider { + copied := *c + copied.db = handle + return &copied +} diff --git a/internal/calendar/microsoft/microsoft_test.go b/internal/calendar/microsoft/microsoft_test.go index 30088fa..53a0856 100644 --- a/internal/calendar/microsoft/microsoft_test.go +++ b/internal/calendar/microsoft/microsoft_test.go @@ -2,7 +2,6 @@ package microsoft import ( "context" - "database/sql" "encoding/base64" "io" "net/http" @@ -15,19 +14,14 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -41,7 +35,7 @@ func newTestClient(t *testing.T) *Client { return c } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) diff --git a/internal/config/config.go b/internal/config/config.go index 60fb580..3dee93d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,10 @@ package config import ( + "errors" + "fmt" "log/slog" + "net/url" "os" "strconv" "strings" @@ -17,6 +20,32 @@ type Config struct { PublicBaseURL string // booker-facing host: booking links, emails; defaults to BaseURL LogLevel slog.Level + // MultiTenant serves many isolated workspaces from one process: the tenant of + // a request is resolved from its Host or from the credential it carries, and + // PostgreSQL row-level security — not the query author — is what keeps one + // workspace out of another's rows. + // + // Unset is the default and changes nothing: one workspace with the literal id + // "default", row-level security never enabled, SQLite still supported. + MultiTenant bool + + // DatabaseAdminURL is the PLATFORM role's DSN: the owner of the schema, with + // BYPASSRLS, which runs migrations, the worker's cross-tenant claim loop, the + // reconciler's workspace enumeration and the platform API. DatabaseURL is then + // the APPLICATION role, which must be NOBYPASSRLS and must not own the tables — + // that is the whole of the isolation guarantee, since a role that owns a table + // or bypasses RLS is not constrained by its policy. + // + // Required when MultiTenant is set, ignored otherwise: in single-tenant mode + // both handles are the same one. + DatabaseAdminURL string + + // PlatformToken authenticates the platform API (workspace provisioning, + // export/import, erasure) on the identity host. Empty means the platform API + // is not mounted at all — a 404, not a 401, so an instance that does not + // provision workspaces does not advertise that it could. + PlatformToken string + // Email / SMTP SMTPHost string SMTPPort string @@ -40,6 +69,32 @@ type Config struct { ZoomClientID string ZoomClientSecret string + // SSOSharedSecret is the HMAC key for the signed session hand-off + // (GET /v1/auth/sso). Empty ⇒ that endpoint is off and 404s. Env-only and + // deliberately not settable from the admin UI: it can create users and mint + // sessions, so it belongs with the platform secrets rather than in a settings + // page an admin session can reach. + SSOSharedSecret string + + // MetricsToken is the bearer token that authorises GET /metrics. Empty ⇒ that + // endpoint 404s, so an instance never publishes its request volume, booking rate or + // queue depth by accident. Env-only, for the same reason as SSOSharedSecret. + MetricsToken string + + // STTBaseURL overrides the speech-to-text endpoint host for the notetaker, e.g. a + // regional endpoint so recordings are transcribed inside one jurisdiction. Empty ⇒ + // stt.DefaultBaseURL. Only the host is configurable; the path, model and options are + // Calnode's. Surfaced read-only in GET /v1/settings/notetaker so an operator can see + // where audio is being sent without reading the environment of a running container. + STTBaseURL string + + // DataDir is where uploaded files (avatars, branding assets) are written. + // Defaults to the relative directory "data", which is what every existing + // deployment has always used; set DATA_DIR when the process runs somewhere + // its working directory is not writable, such as a read-only container image + // that mounts a volume elsewhere. + DataDir string + // CookieSecure sets the Secure flag on session cookies. Defaults to true // when BASE_URL starts with https://, but can be overridden explicitly via // COOKIE_SECURE=false for HTTPS-terminated-at-proxy setups where the binary @@ -52,6 +107,26 @@ type Config struct { // the public endpoints are rate-limited regardless. Comma-separated. EmbedAllowedOrigins []string + // TrustedProxyCIDRs lists the networks whose forwarded headers are believed when + // resolving the client IP for per-IP rate limiting. Empty (the default) ⇒ the limit + // keys on the TCP peer and CF-Connecting-IP / X-Forwarded-For are ignored entirely, + // because a header from an unvetted peer is a client-chosen value. Comma-separated + // CIDRs; a bare address is taken as a single host. + TrustedProxyCIDRs []string + + // FrameAncestors lists the origins allowed to embed the admin SPA in a frame, as a + // Content-Security-Policy frame-ancestors source list. Space-separated, matching the + // CSP syntax it becomes. Empty (the default) ⇒ nothing is sent and /admin/ behaves + // exactly as it did. Each entry must be `https://host[:port]` or `'self'`; anything + // else fails Validate and the process refuses to start, because a directive the + // browser cannot parse is a directive that silently allows everything. + // + // Only the admin SPA is affected. The public booking pages keep their + // `frame-ancestors 'none'` + `X-Frame-Options: DENY` unconditionally — those are + // unauthenticated pages that take payment details, and no operator convenience is + // worth making them embeddable. + FrameAncestors []string + // DemoMode turns this instance into a public, self-resetting demo: seeds sample // data on every boot (there's no persistent volume, so every boot is a fresh DB), // disables calendar/Zoom connect, serves a disallow-all robots.txt, and exposes @@ -61,6 +136,18 @@ type Config struct { // DemoResetInterval is how often DemoMode wipes and re-seeds the DB. Configurable // (not hardcoded to 30m) so local verification doesn't require waiting half an hour. DemoResetInterval time.Duration + + // DBMaxOpenConns / DBMaxIdleConns size the PostgreSQL connection pool. + // PostgreSQL's own max_connections is the thing they have to fit inside, and + // that is a property of the server a self-hoster runs, not of Calnode — a + // small instance behind a PgBouncer wants a different number from one talking + // to a 200-connection server directly. + // + // They do NOT apply to SQLite, which is pinned at 1/1 in internal/db because + // the single writer connection is a correctness guarantee (ARCHITECTURE §17), + // not a tuning choice. + DBMaxOpenConns int + DBMaxIdleConns int } func Load() *Config { @@ -89,10 +176,18 @@ func Load() *Config { ZoomClientSecret: getEnv("ZOOM_CLIENT_SECRET", ""), EmbedAllowedOrigins: splitCSV(getEnv("EMBED_ALLOWED_ORIGINS", "")), + TrustedProxyCIDRs: splitCSV(getEnv("TRUSTED_PROXY_CIDRS", "")), + STTBaseURL: getEnv("STT_BASE_URL", ""), + DataDir: getEnv("DATA_DIR", "data"), + // Space-separated, not comma: the value goes into a CSP source list verbatim, so + // it reads the same in the env var as it does in the header. + FrameAncestors: strings.Fields(getEnv("FRAME_ANCESTORS", "")), } cfg.EncryptionKey = os.Getenv("CALNODE_ENCRYPTION_KEY") cfg.RecoverySecret = os.Getenv("CALNODE_RECOVERY_SECRET") + cfg.SSOSharedSecret = os.Getenv("CALNODE_SSO_SHARED_SECRET") + cfg.MetricsToken = os.Getenv("METRICS_TOKEN") // PUBLIC_BASE_URL overrides the booker-facing host (custom/vanity domain). // Unset → inherits BASE_URL, so single-domain deploys need only set BASE_URL. cfg.PublicBaseURL = getEnv("PUBLIC_BASE_URL", cfg.BaseURL) @@ -100,10 +195,168 @@ func Load() *Config { cfg.CookieSecure = getBool("COOKIE_SECURE", strings.HasPrefix(cfg.BaseURL, "https://")) cfg.DemoMode = getBool("DEMO_MODE", false) cfg.DemoResetInterval = getDuration("DEMO_RESET_INTERVAL", 30*time.Minute) + cfg.DBMaxOpenConns, cfg.DBMaxIdleConns = PoolFromEnv() + + cfg.MultiTenant = getBool("MULTI_TENANT", false) + cfg.DatabaseAdminURL = os.Getenv("DATABASE_ADMIN_URL") + cfg.PlatformToken = os.Getenv("CALNODE_PLATFORM_TOKEN") return cfg } +// Validate reports the configuration errors an operator has to fix before the process +// can safely serve traffic. Called from main after Load; a non-nil error is fatal. +// +// It is separate from Load because Load has no error return and every optional knob in +// it deliberately falls back on a typo rather than refusing to boot (see PoolFromEnv). +// Nothing here is a typo. It holds two families, and both share the property that a +// wrong value is worse than an absent one: +// +// - settings whose malformed value weakens a defence silently. A bad CSP directive is +// the example: browsers drop a source list they cannot parse, so the admin UI would +// end up MORE embeddable than with the setting unset, and nothing in the response +// would say so. +// - multi-tenant combinations whose only possible outcome is silent data loss or +// silent cross-tenant exposure. +func (c *Config) Validate() error { + for _, origin := range c.FrameAncestors { + if err := validFrameAncestor(origin); err != nil { + return fmt.Errorf("FRAME_ANCESTORS: %w", err) + } + } + + if !c.MultiTenant { + return nil + } + + // SQLite has no row-level security, so there is nothing to enforce isolation + // with. Refusing is the only honest answer: the alternative is an instance + // that looks multi-tenant and separates nothing. + if !isPostgresURL(c.DatabaseURL) { + return errors.New("MULTI_TENANT requires a postgres:// DATABASE_URL — " + + "tenant isolation is PostgreSQL row-level security, which SQLite has no equivalent of") + } + if c.DatabaseAdminURL == "" { + return errors.New("MULTI_TENANT requires DATABASE_ADMIN_URL — " + + "the platform role that owns the schema and runs migrations, distinct from the application role in DATABASE_URL") + } + if !isPostgresURL(c.DatabaseAdminURL) { + return errors.New("DATABASE_ADMIN_URL must be a postgres:// DSN") + } + // Same DSN means one role, which means the application role owns the schema + // and every policy is inert against it. This is the misconfiguration that + // would be hardest to notice, because everything works — including reading + // other tenants' rows. + if c.DatabaseAdminURL == c.DatabaseURL { + return errors.New("DATABASE_ADMIN_URL must differ from DATABASE_URL — " + + "the application role must not own the tables or its row-level-security policies do not apply to it") + } + // Demo mode wipes and re-seeds the whole database every DemoResetInterval. + // Against a multi-tenant database that is every tenant's data. + if c.DemoMode { + return errors.New("DEMO_MODE and MULTI_TENANT are mutually exclusive — " + + "demo mode periodically wipes the entire database") + } + return nil +} + +// isPostgresURL mirrors the classification internal/db does on the same string. +// Duplicated rather than imported because db imports config, and one three-line +// prefix check is a smaller price than a cycle. +func isPostgresURL(u string) bool { + l := strings.ToLower(u) + return strings.HasPrefix(l, "postgres://") || strings.HasPrefix(l, "postgresql://") +} + +// Pool defaults. Deliberately modest: one Calnode instance is one small process, +// and a self-hoster's PostgreSQL is usually sized to match. +const ( + DefaultDBMaxOpenConns = 10 + DefaultDBMaxIdleConns = 5 +) + +// PoolFromEnv reads DB_MAX_OPEN_CONNS and DB_MAX_IDLE_CONNS. +// +// It is exported separately from Load because internal/db calls it directly: +// OpenDB has to know the pool size, every entry point that opens a database +// would otherwise have to remember to pass it, and forgetting would silently +// give that entry point the defaults. config imports nothing from the app, so +// db → config is not a cycle. +// +// Validation, rather than handing database/sql whatever the environment said: +// +// - unset, unparsable or not positive → the default, with a warning. This +// matches getBool/getDuration above, which also fall back rather than +// failing a boot over a typo in an optional knob. +// - idle > open → idle is clamped to open. database/sql silently reduces the +// idle limit to the open limit in that case, so the pair is the honest +// description of what the pool will do. +func PoolFromEnv() (maxOpen, maxIdle int) { + maxOpen = getPositiveInt("DB_MAX_OPEN_CONNS", DefaultDBMaxOpenConns) + maxIdle = getPositiveInt("DB_MAX_IDLE_CONNS", DefaultDBMaxIdleConns) + if maxIdle > maxOpen { + slog.Warn("DB_MAX_IDLE_CONNS exceeds DB_MAX_OPEN_CONNS; clamping", + "idle", maxIdle, "open", maxOpen) + maxIdle = maxOpen + } + return maxOpen, maxIdle +} + +func getPositiveInt(key string, def int) int { + v := os.Getenv(key) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + slog.Warn("ignoring unparsable integer environment variable", "key", key, "value", v, "default", def) + return def + } + if n < 1 { + slog.Warn("ignoring non-positive environment variable", "key", key, "value", n, "default", def) + return def + } + return n +} + +// validFrameAncestor accepts 'self' or an https origin with no path, credentials, query +// or fragment. +// +// Wildcards are deliberately refused even though CSP allows them. `https://*.example.com` +// trusts every host any subdomain of that name ever points at, including one taken over +// later; an operator who needs two hosts can name two hosts. Plain http is refused for +// the same reason the admin session cookie is Secure — the framing page would be able to +// read nothing, but its own compromise becomes a foothold. +func validFrameAncestor(origin string) error { + if origin == "'self'" { + return nil + } + if strings.HasPrefix(origin, "'") { + // 'none', 'unsafe-inline' and friends are keywords this setting has no use for: + // 'none' is not "unset" (see FrameAncestors) and the rest are not source + // expressions at all. Refusing them keeps the accepted grammar one line long. + return fmt.Errorf("%q is not a supported keyword; use 'self' or an https:// origin", origin) + } + u, err := url.Parse(origin) + switch { + case err != nil: + return fmt.Errorf("%q is not a URL: %w", origin, err) + case u.Scheme != "https": + return fmt.Errorf("%q must use https://", origin) + case u.Host == "": + return fmt.Errorf("%q has no host", origin) + case strings.Contains(u.Host, "*"): + return fmt.Errorf("%q must name one host, not a wildcard", origin) + case u.User != nil: + return fmt.Errorf("%q must not carry credentials", origin) + case u.Path != "" && u.Path != "/": + return fmt.Errorf("%q must be an origin, with no path", origin) + case u.RawQuery != "" || u.Fragment != "": + return fmt.Errorf("%q must be an origin, with no query or fragment", origin) + } + return nil +} + func parseLogLevel(s string) slog.Level { switch s { case "debug": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a3a9962..33aa069 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -121,3 +121,163 @@ func TestLoad_demoResetIntervalInvalidFallsBackToDefault(t *testing.T) { t.Errorf("DemoResetInterval = %v; want 30m default on invalid input", cfg.DemoResetInterval) } } + +func TestLoad_poolDefaults(t *testing.T) { + os.Unsetenv("DB_MAX_OPEN_CONNS") + os.Unsetenv("DB_MAX_IDLE_CONNS") + cfg := config.Load() + if cfg.DBMaxOpenConns != 10 { + t.Errorf("DBMaxOpenConns = %d; want 10", cfg.DBMaxOpenConns) + } + if cfg.DBMaxIdleConns != 5 { + t.Errorf("DBMaxIdleConns = %d; want 5", cfg.DBMaxIdleConns) + } + // The exported constants are what internal/db falls back to, so they must be + // the same numbers Load reports rather than a second opinion. + if config.DefaultDBMaxOpenConns != 10 || config.DefaultDBMaxIdleConns != 5 { + t.Errorf("defaults = %d/%d; want 10/5", + config.DefaultDBMaxOpenConns, config.DefaultDBMaxIdleConns) + } +} + +func TestPoolFromEnv(t *testing.T) { + tests := []struct { + name string + open, idle string + wantOpen, wantIdle int + }{ + {name: "unset", wantOpen: 10, wantIdle: 5}, + {name: "both set", open: "40", idle: "12", wantOpen: 40, wantIdle: 12}, + {name: "idle above open is clamped to open", open: "6", idle: "99", wantOpen: 6, wantIdle: 6}, + {name: "zero open is not positive", open: "0", idle: "2", wantOpen: 10, wantIdle: 2}, + {name: "negative idle is not positive", open: "8", idle: "-1", wantOpen: 8, wantIdle: 5}, + {name: "unparsable open", open: "many", idle: "3", wantOpen: 10, wantIdle: 3}, + {name: "one and one", open: "1", idle: "1", wantOpen: 1, wantIdle: 1}, + // The default idle (5) is above an explicitly small open limit, so the + // clamp has to apply to the DEFAULT too, not only to a value someone set. + {name: "small open clamps the default idle", open: "2", wantOpen: 2, wantIdle: 2}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setEnvOrUnset(t, "DB_MAX_OPEN_CONNS", tc.open) + setEnvOrUnset(t, "DB_MAX_IDLE_CONNS", tc.idle) + + gotOpen, gotIdle := config.PoolFromEnv() + if gotOpen != tc.wantOpen || gotIdle != tc.wantIdle { + t.Errorf("PoolFromEnv() = %d/%d; want %d/%d", gotOpen, gotIdle, tc.wantOpen, tc.wantIdle) + } + if gotIdle > gotOpen { + t.Errorf("idle %d exceeds open %d; the pair must always satisfy idle <= open", gotIdle, gotOpen) + } + + cfg := config.Load() + if cfg.DBMaxOpenConns != gotOpen || cfg.DBMaxIdleConns != gotIdle { + t.Errorf("Load() reported %d/%d; want the same %d/%d PoolFromEnv gives", + cfg.DBMaxOpenConns, cfg.DBMaxIdleConns, gotOpen, gotIdle) + } + }) + } +} + +// --------------------------------------------------------------------------- +// FRAME_ANCESTORS +// --------------------------------------------------------------------------- + +func TestLoad_frameAncestorsIsSpaceSeparated(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", " https://console.example.test 'self' ") + + cfg := config.Load() + + if len(cfg.FrameAncestors) != 2 { + t.Fatalf("FrameAncestors = %#v; want 2 entries", cfg.FrameAncestors) + } + if cfg.FrameAncestors[0] != "https://console.example.test" || cfg.FrameAncestors[1] != "'self'" { + t.Errorf("FrameAncestors = %#v; want the two sources unchanged", cfg.FrameAncestors) + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} + +func TestLoad_frameAncestorsDefaultsToEmpty(t *testing.T) { + os.Unsetenv("FRAME_ANCESTORS") + + cfg := config.Load() + + if len(cfg.FrameAncestors) != 0 { + t.Errorf("FrameAncestors = %#v; want empty", cfg.FrameAncestors) + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} + +// A directive the browser cannot parse is dropped whole, which would leave the admin SPA +// more embeddable than with the setting unset. Refusing to start is the only outcome that +// cannot be missed. +func TestValidate_rejectsBadFrameAncestors(t *testing.T) { + cases := map[string]string{ + "plain http": "http://console.example.test", + "no scheme": "console.example.test", + "wildcard host": "https://*.example.test", + "with a path": "https://console.example.test/admin", + "with a query": "https://console.example.test?x=1", + "credentials": "https://user:pw@console.example.test", + "none keyword": "'none'", + "unsafe keyword": "'unsafe-inline'", + "scheme only": "https://", + } + for name, value := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", value) + if err := config.Load().Validate(); err == nil { + t.Errorf("Validate() = nil for %q; want an error", value) + } + }) + } +} + +func setEnvOrUnset(t *testing.T, key, value string) { + t.Helper() + if value == "" { + previous, had := os.LookupEnv(key) + os.Unsetenv(key) + t.Cleanup(func() { + if had { + os.Setenv(key, previous) + } + }) + return + } + t.Setenv(key, value) +} + +// One bad entry beside a good one still fails: a half-applied source list is a policy +// nobody wrote. +func TestValidate_rejectsAListWithOneBadEntry(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", "https://good.example.test http://bad.example.test") + if err := config.Load().Validate(); err == nil { + t.Error("Validate() = nil; want an error naming the http entry") + } +} + +func TestValidate_acceptsAPortAndATrailingSlash(t *testing.T) { + t.Setenv("FRAME_ANCESTORS", "https://console.example.test:8443 https://other.example.test/") + if err := config.Load().Validate(); err != nil { + t.Errorf("Validate() = %v; want nil", err) + } +} + +// DATA_DIR moves the upload directory; unset, it is the relative "data" every +// existing deployment writes to, so nothing moves for anyone who never set it. +func TestLoad_dataDir(t *testing.T) { + t.Setenv("DATA_DIR", "") + if cfg := config.Load(); cfg.DataDir != "data" { + t.Errorf("DataDir default = %q; want data", cfg.DataDir) + } + t.Setenv("DATA_DIR", "/var/lib/calnode") + if cfg := config.Load(); cfg.DataDir != "/var/lib/calnode" { + t.Errorf("DataDir = %q; want /var/lib/calnode", cfg.DataDir) + } +} diff --git a/internal/config/tenancy_test.go b/internal/config/tenancy_test.go new file mode 100644 index 0000000..30dc2f0 --- /dev/null +++ b/internal/config/tenancy_test.go @@ -0,0 +1,143 @@ +package config_test + +import ( + "os" + "strings" + "testing" + + "github.com/calnode/calnode/internal/config" +) + +const ( + appDSN = "postgres://calnode_app:pw@127.0.0.1:5432/calnode?sslmode=disable" + adminDSN = "postgres://calnode_platform:pw@127.0.0.1:5432/calnode?sslmode=disable" +) + +func TestLoad_multiTenantDefaultsOff(t *testing.T) { + os.Unsetenv("MULTI_TENANT") + os.Unsetenv("DATABASE_ADMIN_URL") + os.Unsetenv("CALNODE_PLATFORM_TOKEN") + + cfg := config.Load() + + if cfg.MultiTenant { + t.Error("MultiTenant should default to false") + } + if cfg.DatabaseAdminURL != "" { + t.Errorf("DatabaseAdminURL = %q; want empty", cfg.DatabaseAdminURL) + } + if cfg.PlatformToken != "" { + t.Errorf("PlatformToken = %q; want empty", cfg.PlatformToken) + } + // The default configuration is the one every existing deployment has, so it + // has to validate. + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() on the default configuration: %v", err) + } +} + +func TestLoad_multiTenantEnv(t *testing.T) { + t.Setenv("MULTI_TENANT", "1") + t.Setenv("DATABASE_URL", appDSN) + t.Setenv("DATABASE_ADMIN_URL", adminDSN) + t.Setenv("CALNODE_PLATFORM_TOKEN", "tok") + + cfg := config.Load() + + if !cfg.MultiTenant { + t.Error("MultiTenant = false; want true") + } + if cfg.DatabaseAdminURL != adminDSN { + t.Errorf("DatabaseAdminURL = %q; want %q", cfg.DatabaseAdminURL, adminDSN) + } + if cfg.PlatformToken != "tok" { + t.Errorf("PlatformToken = %q; want tok", cfg.PlatformToken) + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() on a well-formed multi-tenant configuration: %v", err) + } +} + +// TestValidate_multiTenantRefusals covers every combination that cannot work. +// Each one is a refusal rather than a warning because its only other outcome is +// silent: a tenant reading another tenant's rows, or a demo reset wiping a fleet. +func TestValidate_multiTenantRefusals(t *testing.T) { + cases := []struct { + name string + mutate func(*config.Config) + wantSub string + }{ + { + name: "sqlite has no row-level security", + mutate: func(c *config.Config) { c.DatabaseURL = "sqlite://./data/calnode.db" }, + wantSub: "postgres:// DATABASE_URL", + }, + { + name: "no platform DSN", + mutate: func(c *config.Config) { c.DatabaseAdminURL = "" }, + wantSub: "DATABASE_ADMIN_URL", + }, + { + name: "platform DSN is not postgres", + mutate: func(c *config.Config) { c.DatabaseAdminURL = "sqlite://./data/calnode.db" }, + wantSub: "must be a postgres:// DSN", + }, + { + // The dangerous one: everything works, including reading other + // tenants' rows, because a role that owns a table is not + // constrained by that table's policy. + name: "one role for both handles", + mutate: func(c *config.Config) { c.DatabaseAdminURL = c.DatabaseURL }, + wantSub: "must differ from DATABASE_URL", + }, + { + name: "demo mode wipes every tenant", + mutate: func(c *config.Config) { c.DemoMode = true }, + wantSub: "mutually exclusive", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + MultiTenant: true, + DatabaseURL: appDSN, + DatabaseAdminURL: adminDSN, + } + tc.mutate(cfg) + + err := cfg.Validate() + if err == nil { + t.Fatalf("Validate() = nil; want a refusal mentioning %q", tc.wantSub) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("Validate() = %q; want it to mention %q", err, tc.wantSub) + } + }) + } +} + +// TestValidate_singleTenantIgnoresTheRest is the byte-identical promise at the +// config layer: with MULTI_TENANT unset, none of the combinations above is an +// error, because none of the machinery they guard is running. +func TestValidate_singleTenantIgnoresTheRest(t *testing.T) { + cfg := &config.Config{ + DatabaseURL: "sqlite://./data/calnode.db", + DatabaseAdminURL: "sqlite://./data/calnode.db", + DemoMode: true, + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; single-tenant mode should not police the multi-tenant knobs", err) + } +} + +func TestValidate_postgresqlSchemeAccepted(t *testing.T) { + cfg := &config.Config{ + MultiTenant: true, + DatabaseURL: "postgresql://app:pw@h/db", + DatabaseAdminURL: "postgresql://platform:pw@h/db", + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() = %v; postgresql:// is the same engine as postgres://", err) + } +} diff --git a/internal/connstore/connstore.go b/internal/connstore/connstore.go index 1191e5d..8f2a0a4 100644 --- a/internal/connstore/connstore.go +++ b/internal/connstore/connstore.go @@ -11,12 +11,20 @@ import ( "context" "database/sql" "fmt" + + "github.com/calnode/calnode/internal/db" ) -// Execer is satisfied by both *sql.DB and *sql.Tx — ResolveFlags runs inside whichever +// Execer is satisfied by both *db.DB and *db.Tx — ResolveFlags runs inside whichever // transaction the caller already opened for its own upsert. +// +// It returns *db.Row rather than *sql.Row because that is what the wrapper hands +// back: on a tenant-bound handle the row owns the pooled connection its tenant was +// bound on, and releases it when the caller Scans. A bare *sql.DB therefore no +// longer satisfies this — which is correct, since a statement issued through one +// is neither rebound nor tenant-bound. type Execer interface { - QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + QueryRowContext(ctx context.Context, query string, args ...any) *db.Row } // WhereClause builds the "AND check_conflicts = ? AND is_destination = ?" fragment diff --git a/internal/connstore/connstore_test.go b/internal/connstore/connstore_test.go index fe6e3e5..bbbeed3 100644 --- a/internal/connstore/connstore_test.go +++ b/internal/connstore/connstore_test.go @@ -2,26 +2,20 @@ package connstore import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } -func seedUser(t *testing.T, database *sql.DB, userID string) { +func seedUser(t *testing.T, database *db.DB, userID string) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -31,7 +25,7 @@ func seedUser(t *testing.T, database *sql.DB, userID string) { } } -func seedConnection(t *testing.T, database *sql.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { +func seedConnection(t *testing.T, database *db.DB, userID, provider, accountEmail string, checkConflicts, isDestination int) { t.Helper() if _, err := database.ExecContext(context.Background(), ` INSERT INTO calendar_connections diff --git a/internal/connstore/destination_test.go b/internal/connstore/destination_test.go index 3762dec..d5b86b4 100644 --- a/internal/connstore/destination_test.go +++ b/internal/connstore/destination_test.go @@ -2,16 +2,21 @@ package connstore import ( "context" - "database/sql" "testing" - _ "modernc.org/sqlite" + calnodedb "github.com/calnode/calnode/internal/db" ) // newDestDB builds just enough schema to exercise the destination lookup. -func newDestDB(t *testing.T) *sql.DB { +// +// A *db.DB rather than a bare *sql.DB, even though nothing here is migrated and +// the schema is a hand-written fragment: Execer takes *db.Row, because on a +// tenant-bound handle a row owns the connection its tenant was bound on. A bare +// *sql.DB does not rebind placeholders either, so it was never the right shape to +// hold up as "Execer accepts this too". +func newDestDB(t *testing.T) *calnodedb.DB { t.Helper() - db, err := sql.Open("sqlite", ":memory:") + db, err := calnodedb.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("open: %v", err) } @@ -32,7 +37,7 @@ func newDestDB(t *testing.T) *sql.DB { return db } -func insertCal(t *testing.T, db *sql.DB, id, user, provider, email, calID string, conflicts, dest int) { +func insertCal(t *testing.T, db *calnodedb.DB, id, user, provider, email, calID string, conflicts, dest int) { t.Helper() if _, err := db.Exec( `INSERT INTO connection_calendars (id, user_id, provider, account_email, calendar_id, check_conflicts, is_destination) diff --git a/internal/db/collation_test.go b/internal/db/collation_test.go new file mode 100644 index 0000000..7ad419e --- /dev/null +++ b/internal/db/collation_test.go @@ -0,0 +1,286 @@ +package db_test + +import ( + "slices" + "sort" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// Calnode stores timestamps as TEXT and compares them lexicographically: the +// worker claims with `run_at <= ?`, sessions and tokens expire on +// `expires_at > ?`, the consent window brackets `decided_at`, booking overlap is +// `start_at`/`end_at` against bound strings, and several lists are +// `ORDER BY created_at`. On SQLite that is memcmp, always. On PostgreSQL it is a +// comparison under the column's collation, so migration 00059 pins every one of +// those columns to COLLATE "C". +// +// These tests hold that pin. They are Postgres-only because SQLite has no +// collation to get wrong. + +// timestampColumnPredicate selects the columns migration 00059 covers, by name. +// +// `_at`/`_until` catches every timestamp column in the schema. The three +// remaining names are the availability columns, which hold 'HH:MM' and +// 'YYYY-MM-DD' and are ordered as times too (`ORDER BY day_of_week, start_time` +// in internal/handler/availability.go, `ORDER BY date` in override.go). Matching +// by name rather than by a committed list is the point: a timestamp column added +// by a later migration is caught here without anyone remembering to add it. +const timestampColumnPredicate = `(c.column_name ~ '_(at|until)$' + OR c.column_name IN ('date', 'start_time', 'end_time'))` + +// wantTimestampColumns is the number of columns 00059 altered, plus the two +// workspaces timestamps 00060 added and sso_nonces.expires_at from 00061. A +// floor, not an equality: the assertion that matters is "every match is C", and a +// query that silently stopped matching anything would satisfy that vacuously. +const wantTimestampColumns = 57 + +func TestPostgres_timestampColumnsCollateC(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query(` + SELECT c.table_name, c.column_name, COALESCE(c.collation_name, '') + FROM information_schema.columns c + JOIN information_schema.tables t + ON t.table_schema = c.table_schema AND t.table_name = c.table_name + WHERE c.table_schema = current_schema() + AND t.table_type = 'BASE TABLE' + AND c.data_type = 'text' + AND ` + timestampColumnPredicate + ` + ORDER BY c.table_name, c.column_name`) + if err != nil { + t.Fatalf("read information_schema.columns: %v", err) + } + defer rows.Close() + + var checked int + var offenders []string + for rows.Next() { + var table, column, collation string + if err := rows.Scan(&table, &column, &collation); err != nil { + t.Fatalf("scan column row: %v", err) + } + checked++ + if collation != "C" { + offenders = append(offenders, table+"."+column+" = "+collation) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate columns: %v", err) + } + + if checked < wantTimestampColumns { + t.Errorf("audited %d timestamp columns; want at least %d — did the predicate stop matching?", + checked, wantTimestampColumns) + } + if len(offenders) > 0 { + t.Errorf("%d of %d timestamp columns are not COLLATE \"C\":\n\t%s", + len(offenders), checked, strings.Join(offenders, "\n\t")) + } + t.Logf("audited %d TEXT timestamp columns, %d not C", checked, len(offenders)) +} + +// collationProbeValues are timestamp-shaped strings whose byte order and +// linguistic order disagree. +// +// The first two are the shapes the schema really stores (internal/dbtime: a +// space-separated `datetime('now')` and an RFC 3339 `strftime`). ⚠️ Measured on +// the PostgreSQL 17 this branch is developed against, those two do NOT flip +// under the database's en_US.utf8 default, nor under any of the other 878 +// collations installed on that server: glibc ignores the space at the primary +// level but still sorts a digit before 'T', which happens to agree with +// memcmp. So they cannot carry the control on their own — a test built only on +// them would pass with or without migration 00059. +// +// The third is the same instant with RFC 3339's lower-case 't' and 'z', which +// §5.6 of the RFC explicitly permits and which an importer or a third-party API +// can therefore hand us. Case is a tertiary-level difference: en_US.utf8 puts +// lower case first, memcmp puts upper case first ('T' is 0x54, 't' is 0x74). +// That is the pair that makes this control able to fail. +var collationProbeValues = []string{ + "2026-01-01 10:00:00", + "2026-01-01T10:00:00.000Z", + "2026-01-01t10:00:00z", + "2026-01-01T10:00:00Z", +} + +// TestPostgres_collationControl is the positive control: it proves the audit +// above is testing something, by showing that a plain TEXT column on this server +// really does order these values differently from a COLLATE "C" one. +// +// If the server's own default already behaves byte-wise (a C or C.UTF-8 +// database), there is nothing to control against and the test SKIPS naming the +// collation, rather than passing vacuously — a green run on such a server says +// nothing about a deployment on a linguistic one. +func TestPostgres_collationControl(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + // The server's collation, for the skip message. lc_collate stopped being a + // GUC in PostgreSQL 16 (SHOW lc_collate errors with "unrecognized + // configuration parameter"), so it is read from the catalog, which is also + // where the per-database value has always actually lived. + var collate, ctype, provider string + if err := handle.QueryRow(` + SELECT datcollate, datctype, datlocprovider + FROM pg_database WHERE datname = current_database()`).Scan(&collate, &ctype, &provider); err != nil { + t.Fatalf("read pg_database locale: %v", err) + } + serverCollation := collate + " (ctype " + ctype + ", provider " + provider + ")" + + if _, err := handle.Exec(` + CREATE TABLE collation_control ( + plain TEXT NOT NULL, + cee TEXT COLLATE "C" NOT NULL + )`); err != nil { + t.Fatalf("create control table: %v", err) + } + + for _, v := range collationProbeValues { + if _, err := handle.Exec(`INSERT INTO collation_control (plain, cee) VALUES (?, ?)`, v, v); err != nil { + t.Fatalf("insert %q: %v", v, err) + } + } + + plainOrder := orderedColumn(t, handle, "plain") + ceeOrder := orderedColumn(t, handle, "cee") + + wantBytes := slices.Clone(collationProbeValues) + sort.Strings(wantBytes) // Go's sort on strings is byte-wise, i.e. what SQLite does + + if !slices.Equal(ceeOrder, wantBytes) { + t.Errorf("COLLATE \"C\" column ordered\n\t%v\nwant byte order\n\t%v", ceeOrder, wantBytes) + } + + if slices.Equal(plainOrder, ceeOrder) { + t.Skipf("server default collation %s orders these values byte-wise too, "+ + "so this control cannot distinguish a collated column from a C one; "+ + "the audit is unproven on this server", serverCollation) + } + + t.Logf("control fired: server default collation is %s\n\tplain: %v\n\tC : %v", + serverCollation, plainOrder, ceeOrder) +} + +func orderedColumn(t *testing.T, handle *db.DB, column string) []string { + t.Helper() + + // column is one of two literals above, never input. + rows, err := handle.Query(`SELECT ` + column + ` FROM collation_control ORDER BY ` + column) + if err != nil { + t.Fatalf("order by %s: %v", column, err) + } + defer rows.Close() + + var got []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + t.Fatalf("scan %s: %v", column, err) + } + got = append(got, v) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate %s: %v", column, err) + } + return got +} + +// TestPostgres_jobsRunAtOrdering holds the ordering on the column the worker +// actually polls. +// +// Two things are asserted, and they are not the same thing: +// +// 1. `ORDER BY run_at` matches Go's byte-wise sort of the same values. This is +// the discriminating half: the lower-case RFC 3339 value in the set orders +// differently under a linguistic collation, so this fails without 00059. +// 2. The real claim predicate — `WHERE status = 'pending' AND run_at <= ?` from +// internal/worker/worker.go, with an RFC 3339 `now` — sees the +// space-separated shape as due and a future T-shape as not. internal/handler/ +// notetaker.go depends on exactly that: it writes `datetime('now')`'s space +// form *because* it sorts before any T-separated stamp, which is what makes a +// notetaker job due immediately. Both collations happen to agree here, so +// this half is a regression pin rather than a discriminator. +func TestPostgres_jobsRunAtOrdering(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + // Same instant in the two shapes the tree writes, plus the lower-case RFC + // 3339 spelling, all in the past relative to `now` below. + runAts := []string{ + "2026-01-01 10:00:00", + "2026-01-01T10:00:00.000Z", + "2026-01-01t10:00:00z", + "2026-01-01T10:00:00Z", + } + for i, runAt := range runAts { + insertJob(t, handle, "job-past-"+string(rune('a'+i)), runAt) + } + insertJob(t, handle, "job-future", "2027-01-01T00:00:00Z") + + rows, err := handle.Query(`SELECT run_at FROM jobs ORDER BY run_at`) + if err != nil { + t.Fatalf("order by run_at: %v", err) + } + var order []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + rows.Close() + t.Fatalf("scan run_at: %v", err) + } + order = append(order, v) + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatalf("iterate run_at: %v", err) + } + + want := append(slices.Clone(runAts), "2027-01-01T00:00:00Z") + sort.Strings(want) + if !slices.Equal(order, want) { + t.Errorf("ORDER BY run_at =\n\t%v\nwant byte order\n\t%v", order, want) + } + + // The worker's own predicate, verbatim from internal/worker/worker.go, with + // the RFC 3339 `now` it binds. + const now = "2026-06-01T12:00:00Z" + claimed, err := handle.Query(` + SELECT id, type, payload, attempts, max_attempts + FROM jobs + WHERE status = 'pending' AND run_at <= ? + LIMIT 10`, now) + if err != nil { + t.Fatalf("claim query: %v", err) + } + defer claimed.Close() + + var ids []string + for claimed.Next() { + var id, typ, payload string + var attempts, maxAttempts int + if err := claimed.Scan(&id, &typ, &payload, &attempts, &maxAttempts); err != nil { + t.Fatalf("scan claimed job: %v", err) + } + ids = append(ids, id) + } + if err := claimed.Err(); err != nil { + t.Fatalf("iterate claimed jobs: %v", err) + } + sort.Strings(ids) + + wantIDs := []string{"job-past-a", "job-past-b", "job-past-c", "job-past-d"} + if !slices.Equal(ids, wantIDs) { + t.Errorf("claimed %v; want %v (the four past shapes, not the future one)", ids, wantIDs) + } +} + +func insertJob(t *testing.T, handle *db.DB, id, runAt string) { + t.Helper() + if _, err := handle.Exec(` + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, id, `{"id":"`+id+`"}`, runAt); err != nil { + t.Fatalf("insert job %s: %v", id, err) + } +} diff --git a/internal/db/constraint.go b/internal/db/constraint.go new file mode 100644 index 0000000..6b44e13 --- /dev/null +++ b/internal/db/constraint.go @@ -0,0 +1,104 @@ +package db + +import ( + "errors" + "slices" + "strings" + + "github.com/jackc/pgx/v5/pgconn" + "modernc.org/sqlite" +) + +// Constraint violations are the one class of database error Calnode routinely acts +// on rather than just reporting: a duplicate slug is a 409, an out-of-range value is +// a 400, a dangling reference is a 404. Deciding which is which used to be a +// substring match on SQLite's English message, which is invisible to every gate and +// degraded silently to a 500 on PostgreSQL. +// +// Both engines are matched on their error codes. Codes rather than text because the +// text is not a contract: PostgreSQL localises its messages by the server's +// lc_messages, so a server running in German defeats any text match no matter how +// carefully written. +const ( + pgUniqueViolation = "23505" // unique_violation, and PostgreSQL's code for a primary-key collision too + pgCheckViolation = "23514" // check_violation + pgForeignKeyViolation = "23503" // foreign_key_violation +) + +// SQLite's extended result codes, as reported by (*sqlite.Error).Code(). +// +// ⛔ SQLITE_CONSTRAINT_PRIMARYKEY is a SEPARATE code from +// SQLITE_CONSTRAINT_UNIQUE even though both carry the message "UNIQUE constraint +// failed". Matching only 2067 would silently stop recognising primary-key +// collisions, and Calnode has one that matters: idempotency_keys.idempotency_key is +// a bare PRIMARY KEY, so every idempotent replay arrives as 1555. Both belong to +// IsUniqueViolation. PostgreSQL has no such split — a primary-key collision is +// 23505 like any other unique violation — which is why the trap only exists on one +// side. +const ( + sqliteConstraintCheck = 275 // SQLITE_CONSTRAINT_CHECK + sqliteConstraintForeignKey = 787 // SQLITE_CONSTRAINT_FOREIGNKEY + sqliteConstraintPrimaryKey = 1555 // SQLITE_CONSTRAINT_PRIMARYKEY + sqliteConstraintUnique = 2067 // SQLITE_CONSTRAINT_UNIQUE +) + +// SQLite's message fragments, used only as a fallback — see violates. +const ( + sqliteUniqueText = "UNIQUE constraint failed" + sqliteCheckText = "CHECK constraint failed" + sqliteForeignKeyText = "FOREIGN KEY constraint failed" +) + +// IsUniqueViolation reports whether err is a unique-constraint violation — a +// duplicate slug, a replayed idempotency key, a second booking at one host's exact +// start time. A primary-key collision counts, on both engines. +func IsUniqueViolation(err error) bool { + return violates(err, pgUniqueViolation, sqliteUniqueText, + sqliteConstraintUnique, sqliteConstraintPrimaryKey) +} + +// IsCheckViolation reports whether err is a CHECK-constraint violation, i.e. a value +// outside the set the column allows. Callers turn this into a 400, since the only way +// to reach it is a request carrying a value the handler did not validate. +func IsCheckViolation(err error) bool { + return violates(err, pgCheckViolation, sqliteCheckText, sqliteConstraintCheck) +} + +// IsForeignKeyViolation reports whether err is a foreign-key violation — a reference +// to a row that does not exist, or a delete that would orphan one. +func IsForeignKeyViolation(err error) bool { + return violates(err, pgForeignKeyViolation, sqliteForeignKeyText, sqliteConstraintForeignKey) +} + +// violates classifies err: the driver's own error code when one is available, the +// message only when it is not. +// +// A driver error is a DEFINITE answer in both directions. A *pgconn.PgError or a +// *sqlite.Error whose code does not match returns false and does not fall through to +// the text comparison — falling through would classify an error by whether its +// message happened to contain an English phrase, which is the fragility being +// removed. It would also reintroduce the primary-key trap in reverse: a 1555 error +// excluded by code would be readmitted by its "UNIQUE constraint failed" message. +// +// The text fallback is deliberate rather than vestigial. It covers an error that +// reaches here without the concrete driver type still attached — a driver release +// that changes its error type, a layer that reformats an error into a plain one +// instead of wrapping it. In that case the message is the only signal left, and +// answering from it beats answering "not a constraint violation" and returning a 500. +func violates(err error, sqlstate, sqliteText string, sqliteCodes ...int) bool { + if err == nil { + return false + } + + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == sqlstate + } + + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + return slices.Contains(sqliteCodes, sqliteErr.Code()) + } + + return strings.Contains(err.Error(), sqliteText) +} diff --git a/internal/db/constraint_test.go b/internal/db/constraint_test.go new file mode 100644 index 0000000..151c570 --- /dev/null +++ b/internal/db/constraint_test.go @@ -0,0 +1,176 @@ +package db_test + +import ( + "errors" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" +) + +// TestConstraintPredicates provokes a real violation of each class against the real +// schema, on whichever engine dbtest is configured for, and asserts the predicate +// recognises it. +// +// Provoked rather than constructed: a hand-built error would only prove the +// predicate agrees with whatever the test author believed the driver returns, and +// the whole reason these helpers exist is that that belief was wrong on PostgreSQL. +// Run it twice — once bare, once with CALNODE_TEST_POSTGRES_DSN — and both engines +// are covered. +func TestConstraintPredicates(t *testing.T) { + h := dbtest.Open(t) + + // A user and an event type to hang the booking constraints off. + userID := uid.New() + if _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Host', 'UTC')`, + userID, userID+"@example.com"); err != nil { + t.Fatalf("seed user: %v", err) + } + etID := uid.New() + if _, err := h.Exec( + `INSERT INTO event_types (id, user_id, slug, name, duration_minutes) + VALUES (?, ?, ?, 'Call', 30)`, etID, userID, etID); err != nil { + t.Fatalf("seed event type: %v", err) + } + + t.Run("unique", func(t *testing.T) { + // users.email is UNIQUE in both migration sets. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, 'Clash', 'UTC')`, + uid.New(), userID+"@example.com") + assertOnly(t, err, "unique", db.IsUniqueViolation) + }) + + // ⛔ The case that distinguishes a correct implementation from a plausible one. + // + // SQLite reports a PRIMARY KEY collision as SQLITE_CONSTRAINT_PRIMARYKEY (1555), + // NOT as SQLITE_CONSTRAINT_UNIQUE (2067) — while still saying "UNIQUE constraint + // failed" in the message. So a predicate matching only 2067 passes the subtest + // above and fails here, and the old text match passed both by accident. + // + // This is not a theoretical shape: idempotency_keys.idempotency_key is a bare + // PRIMARY KEY, so claimIdempotencyKey's entire replay path depends on 1555 being + // classified as a unique violation. PostgreSQL reports 23505 for both, so this + // subtest is redundant there — and it runs there anyway, because "redundant on + // one engine" is exactly the assumption worth re-checking after a driver bump. + t.Run("unique via primary key", func(t *testing.T) { + key := uid.New() + if _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z"); err != nil { + t.Fatalf("seed idempotency key: %v", err) + } + _, err := h.Exec( + `INSERT INTO idempotency_keys (idempotency_key, request_hash, created_at) VALUES (?, 'h', ?)`, + key, "2026-06-01T00:00:00Z") + assertOnly(t, err, "primary key", db.IsUniqueViolation) + }) + + t.Run("check", func(t *testing.T) { + // bookings.status has CHECK (status IN ('confirmed','cancelled')). + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, ?, ?, '2026-06-15T09:00:00Z', '2026-06-15T09:30:00Z', 'not-a-status', ?, ?)`, + uid.New(), etID, userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "check", db.IsCheckViolation) + }) + + t.Run("foreign key", func(t *testing.T) { + // event_type_id references event_types(id). SQLite needs foreign_keys=ON, + // which OpenDB sets. + _, err := h.Exec(` + INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status, created_at, updated_at) + VALUES (?, 'no-such-event-type', ?, '2026-06-15T10:00:00Z', '2026-06-15T10:30:00Z', 'confirmed', ?, ?)`, + uid.New(), userID, "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z") + assertOnly(t, err, "foreign key", db.IsForeignKeyViolation) + }) + + t.Run("unrelated error", func(t *testing.T) { + // A predicate that answered true for everything would satisfy every caller + // above and be badly wrong, so the negative cases carry as much weight. + _, err := h.Exec(`SELECT * FROM a_table_that_does_not_exist`) + if err == nil { + t.Fatal("expected an error from a missing table") + } + assertNone(t, err, "missing table") + assertNone(t, errors.New("some unrelated failure"), "plain error") + assertNone(t, nil, "nil") + }) + + t.Run("unhandled constraint class", func(t *testing.T) { + // A NOT NULL violation is a constraint violation of a class nothing here + // classifies (SQLite 1299, PostgreSQL 23502). It must match none of the + // three, so a caller cannot turn it into a 409 by accident. + _, err := h.Exec( + `INSERT INTO users (id, email, name, iana_timezone) VALUES (?, ?, NULL, 'UTC')`, + uid.New(), uid.New()+"@example.com") + if err == nil { + t.Fatal("expected a NOT NULL violation") + } + assertNone(t, err, "not-null violation") + }) +} + +// TestConstraintTextFallback covers the branch no live engine reaches: an error that +// arrives without its driver type still attached. +// +// It is engine-independent, so it needs no database. The branch exists for a driver +// release that changes its error type, or a layer that reformats an error into a +// plain one rather than wrapping it — in which case the message is the only signal +// left, and answering from it beats returning a 500. Without this test the fallback +// would be unexecuted code that reads like an accident. +func TestConstraintTextFallback(t *testing.T) { + cases := []struct { + text string + want func(error) bool + name string + }{ + {"boom: UNIQUE constraint failed: t.a", db.IsUniqueViolation, "unique"}, + {"boom: CHECK constraint failed: n > 0", db.IsCheckViolation, "check"}, + {"boom: FOREIGN KEY constraint failed", db.IsForeignKeyViolation, "foreign key"}, + } + for _, c := range cases { + err := errors.New(c.text) + if !c.want(err) { + t.Errorf("%s: fallback did not recognise %q", c.name, c.text) + } + } + // And the fallback is still discriminating, not a catch-all. + assertNone(t, errors.New("NOT NULL constraint failed: t.a"), "not-null text") +} + +// assertOnly checks that want recognises err and the other two predicates do not: +// the classes have to be distinguishable, or a CHECK violation becomes a 409. +func assertOnly(t *testing.T, err error, class string, want func(error) bool) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected a constraint violation, got nil", class) + } + if !want(err) { + t.Errorf("%s: predicate did not recognise %v", class, err) + } + matches := 0 + for _, p := range []func(error) bool{db.IsUniqueViolation, db.IsCheckViolation, db.IsForeignKeyViolation} { + if p(err) { + matches++ + } + } + if matches != 1 { + t.Errorf("%s: %d of 3 predicates matched %v; want exactly 1", class, matches, err) + } +} + +func assertNone(t *testing.T, err error, what string) { + t.Helper() + for name, p := range map[string]func(error) bool{ + "IsUniqueViolation": db.IsUniqueViolation, + "IsCheckViolation": db.IsCheckViolation, + "IsForeignKeyViolation": db.IsForeignKeyViolation, + } { + if p(err) { + t.Errorf("%s returned true for %s (%v)", name, what, err) + } + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 21c6240..b9b77a7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -13,17 +13,83 @@ import ( "github.com/pressly/goose/v3" _ "modernc.org/sqlite" + + "github.com/calnode/calnode/internal/config" ) -//go:embed migrations/*.sql +//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql var migrations embed.FS -// Open connects to SQLite at the given URL and configures pragmas. -// URL format: sqlite://./path/to/db or sqlite:///absolute/path or just a file path. -func Open(databaseURL string) (*sql.DB, error) { +// Option configures OpenDB. +type Option func(*openOptions) + +type openOptions struct { + maxOpen, maxIdle int +} + +// WithPool sets the PostgreSQL pool sizes explicitly, bypassing +// DB_MAX_OPEN_CONNS / DB_MAX_IDLE_CONNS. For a caller that must not follow the +// environment — a one-shot CLI, or a test pinning the numbers it asserts. +// +// Values are sanity-checked the same way config does it, because this is the +// function that hands them to database/sql: a non-positive size falls back to +// the default, and an idle limit above the open limit is clamped. +func WithPool(maxOpen, maxIdle int) Option { + return func(o *openOptions) { + if maxOpen > 0 { + o.maxOpen = maxOpen + } + if maxIdle > 0 { + o.maxIdle = maxIdle + } + if o.maxIdle > o.maxOpen { + o.maxIdle = o.maxOpen + } + } +} + +// OpenDB connects to the database named by databaseURL and configures the pool +// for the engine it names. +// +// There is deliberately no bare-handle sibling. An Open returning *sql.DB +// existed through the port "for callers that have not moved over yet", and it +// was a foot-gun with no upside: statements issued through it are not rebound, +// so every ? in them is a syntax error on Postgres, found at runtime and far +// from the call. Anything that genuinely needs the bare pool (goose, Litestream) +// reaches it as handle.DB, which at least says so at the call site. +// +// Pool sizing comes from the environment (config.PoolFromEnv) unless a WithPool +// option overrides it, so every entry point picks up DB_MAX_OPEN_CONNS / +// DB_MAX_IDLE_CONNS without each one having to remember to pass them. It is +// ignored entirely on SQLite — see openSQLite. +// +// URL formats: +// +// sqlite://./path/to/db, sqlite:///absolute/path, or a bare file path +// postgres://user:pass@host:port/dbname (postgresql:// is accepted too) +func OpenDB(databaseURL string, opts ...Option) (*DB, error) { + if dialectFromURL(databaseURL) == DialectPostgres { + o := openOptions{} + o.maxOpen, o.maxIdle = config.PoolFromEnv() + for _, opt := range opts { + opt(&o) + } + return openPostgres(databaseURL, o) + } + return openSQLite(databaseURL) +} + +// openSQLite opens SQLite and configures pragmas. +// +// It takes no pool options on purpose. DB_MAX_OPEN_CONNS is meaningless here and +// honouring it would be a correctness bug, not a tuning choice: the single +// connection is what serialises write transactions and what keeps the +// booking-overlap check free of TOCTOU races (ARCHITECTURE §17), and the pragmas +// below are connection-scoped, so a second connection would not have them. +func openSQLite(databaseURL string) (*DB, error) { dsn := parseDSN(databaseURL) - db, err := sql.Open("sqlite", dsn) + db, err := sql.Open(DialectSQLite.driverName(), dsn) if err != nil { return nil, fmt.Errorf("open database: %w", err) } @@ -47,18 +113,66 @@ func Open(databaseURL string) (*sql.DB, error) { return nil, fmt.Errorf("set busy timeout: %w", err) } - return db, nil + return &DB{DB: db, dialect: DialectSQLite}, nil +} + +// openPostgres opens a PostgreSQL pool. +// +// The one-connection pool of openSQLite is a SQLite constraint, not a Calnode +// design choice, and carrying it over would serialise the whole instance on a +// database that has its own concurrency control. Sizes come from the caller +// (ultimately DB_MAX_OPEN_CONNS / DB_MAX_IDLE_CONNS, defaulting to 10/5) +// because the number that fits is a property of the server: PostgreSQL's +// max_connections is shared with every other client, and an instance behind +// PgBouncer wants a different figure from one talking to the server directly. +// +// The pool does cost one property the SQLite path gets by accident: the +// booking-overlap check (ARCHITECTURE §17) is free of TOCTOU races there only +// because every transaction queues on that single connection. Here two +// overlapping bookings can clear the check concurrently, which is what +// booking.lockHosts' advisory lock closes. +func openPostgres(databaseURL string, o openOptions) (*DB, error) { + // pgx parses the DSN here, so a malformed URL fails at Open. Reachability is + // not probed: Migrate runs immediately after Open in every entry point and + // reports an unreachable server with the same context a probe would. + db, err := sql.Open(DialectPostgres.driverName(), databaseURL) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(o.maxOpen) + db.SetMaxIdleConns(o.maxIdle) + + return &DB{DB: db, dialect: DialectPostgres}, nil } -// Migrate runs any pending Goose migrations embedded in migrations/*.sql. +// Migrate runs any pending Goose migrations embedded for this handle's dialect. +func (h *DB) Migrate() error { + return migrate(h.DB, h.dialect) +} + +// Migrate runs any pending Goose migrations embedded for db's engine, which is +// recovered from its driver. func Migrate(db *sql.DB) error { + return migrate(db, dialectOf(db)) +} + +// gooseMu guards goose's package-level dialect and base FS. A running Calnode +// only ever uses one engine, but the tests migrate both in one process and the +// two settings must not interleave. +var gooseMu sync.Mutex + +func migrate(db *sql.DB, dialect Dialect) error { + gooseMu.Lock() + defer gooseMu.Unlock() + goose.SetBaseFS(migrations) - if err := goose.SetDialect("sqlite3"); err != nil { + if err := goose.SetDialect(dialect.gooseDialect()); err != nil { return fmt.Errorf("set goose dialect: %w", err) } - if err := goose.Up(db, "migrations"); err != nil { + if err := goose.Up(db, dialect.migrationsDir()); err != nil { return fmt.Errorf("run migrations: %w", err) } @@ -73,39 +187,54 @@ var ( // TargetVersion returns the highest migration version embedded in the binary — // i.e. the schema version a fully-migrated database should report. +// +// It is dialect-independent: the per-dialect directories are two spellings of +// one schema and carry the same version numbers, which TestMigrationDirs_parity +// enforces. func TargetVersion() (int64, error) { targetVersionOnce.Do(func() { - entries, err := fs.ReadDir(migrations, "migrations") + targetVersion, targetVersionErr = maxVersion(DialectSQLite.migrationsDir()) + }) + return targetVersion, targetVersionErr +} + +// maxVersion returns the highest goose version number in an embedded migrations +// directory. +func maxVersion(dir string) (int64, error) { + entries, err := fs.ReadDir(migrations, dir) + if err != nil { + return 0, fmt.Errorf("read embedded migrations: %w", err) + } + var highest int64 + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + // Filenames are "NNNNN_description.sql"; the leading number is the version. + name := path.Base(e.Name()) + numPart, _, _ := strings.Cut(name, "_") + v, err := strconv.ParseInt(numPart, 10, 64) if err != nil { - targetVersionErr = fmt.Errorf("read embedded migrations: %w", err) - return + continue // ignore files that don't follow the goose naming convention } - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { - continue - } - // Filenames are "NNNNN_description.sql"; the leading number is the version. - name := path.Base(e.Name()) - numPart, _, _ := strings.Cut(name, "_") - v, err := strconv.ParseInt(numPart, 10, 64) - if err != nil { - continue // ignore files that don't follow the goose naming convention - } - if v > targetVersion { - targetVersion = v - } + if v > highest { + highest = v } - }) - return targetVersion, targetVersionErr + } + return highest, nil } // AppliedVersion returns the schema version currently applied to db by reading // goose's bookkeeping table directly (no goose global state). A missing // goose_db_version table returns an error, which callers treat as "not migrated". +// +// is_applied is tested for truth rather than compared to 1: goose stores it as an +// INTEGER on SQLite and a BOOLEAN on Postgres, and the bare column is the one +// spelling both engines accept. func AppliedVersion(ctx context.Context, db *sql.DB) (int64, error) { var v sql.NullInt64 err := db.QueryRowContext(ctx, - `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied = 1`).Scan(&v) + `SELECT MAX(version_id) FROM goose_db_version WHERE is_applied`).Scan(&v) if err != nil { return 0, err } @@ -130,6 +259,21 @@ func SchemaReady(ctx context.Context, db *sql.DB) (bool, error) { return applied >= target, nil } +// SchemaReady is the handle-level spelling, for callers that hold a *DB — which +// is every caller in the tree. The package-level functions above stay for the +// bare-pool cases (goose's own bookkeeping, the tests that open an unmigrated +// pool), but a handler reaching into h.db.DB to answer a readiness probe was one +// more place where the exported embedded field looked like the normal way to do +// things. +func (h *DB) SchemaReady(ctx context.Context) (bool, error) { + return SchemaReady(ctx, h.DB) +} + +// AppliedVersion is the handle-level spelling of the package function. +func (h *DB) AppliedVersion(ctx context.Context) (int64, error) { + return AppliedVersion(ctx, h.DB) +} + func parseDSN(url string) string { // Strip scheme prefix: sqlite:// → remainder dsn := strings.TrimPrefix(url, "sqlite://") diff --git a/internal/db/db_test.go b/internal/db/db_test.go index e07bf0b..21f9933 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -2,15 +2,16 @@ package db_test import ( "context" + "path/filepath" "testing" "github.com/calnode/calnode/internal/db" ) -func TestOpen_inMemory(t *testing.T) { - database, err := db.Open("sqlite://:memory:") +func TestOpenDB_inMemory(t *testing.T) { + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() @@ -20,40 +21,40 @@ func TestOpen_inMemory(t *testing.T) { } func TestMigrate_runsClean(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } } func TestMigrate_idempotent(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() // Running twice should not error (goose is idempotent). for range 2 { - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate (run 2): %v", err) } } } func TestMigrate_tablesExist(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } @@ -79,24 +80,24 @@ func TestMigrate_tablesExist(t *testing.T) { } func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() ctx := context.Background() // Before migrating, the goose bookkeeping table is absent → not ready. - if ready, _ := db.SchemaReady(ctx, database); ready { + if ready, _ := db.SchemaReady(ctx, database.DB); ready { t.Error("SchemaReady = true before migrations ran; want false") } - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } - ready, err := db.SchemaReady(ctx, database) + ready, err := db.SchemaReady(ctx, database.DB) if err != nil { t.Fatalf("SchemaReady after migrate: %v", err) } @@ -109,7 +110,7 @@ func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { if err != nil { t.Fatalf("TargetVersion: %v", err) } - applied, err := db.AppliedVersion(ctx, database) + applied, err := db.AppliedVersion(ctx, database.DB) if err != nil { t.Fatalf("AppliedVersion: %v", err) } @@ -119,16 +120,34 @@ func TestSchemaReady_falseBeforeMigrate_trueAfter(t *testing.T) { if target < 17 { t.Errorf("target version = %d; want >= 17 (sanity check against known migrations)", target) } + + // The handle-level spellings are what the tree uses now that there is no bare + // Open; they must agree with the package-level ones they delegate to. This is + // the path /readyz takes (internal/handler/health.go). + handleReady, err := database.SchemaReady(ctx) + if err != nil { + t.Fatalf("(*DB).SchemaReady: %v", err) + } + if handleReady != ready { + t.Errorf("(*DB).SchemaReady = %v; want %v (same as the package function)", handleReady, ready) + } + handleApplied, err := database.AppliedVersion(ctx) + if err != nil { + t.Fatalf("(*DB).AppliedVersion: %v", err) + } + if handleApplied != applied { + t.Errorf("(*DB).AppliedVersion = %d; want %d", handleApplied, applied) + } } func TestDoubleBookingIndex_exists(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { - t.Fatalf("db.Open: %v", err) + t.Fatalf("db.OpenDB: %v", err) } defer database.Close() - if err := db.Migrate(database); err != nil { + if err := db.Migrate(database.DB); err != nil { t.Fatalf("db.Migrate: %v", err) } @@ -140,3 +159,121 @@ func TestDoubleBookingIndex_exists(t *testing.T) { t.Errorf("double-booking guard index not found: %v", err) } } + +// TestOpenDB_sqlitePragmasAndPool pins the SQLite path against accidental +// change: the single connection is a correctness guarantee (ARCHITECTURE §17), +// not a tuning choice, and the pragmas are connection-scoped so losing the +// connection loses them. A file database is used because :memory: cannot be in +// WAL mode. +func TestOpenDB_sqlitePragmasAndPool(t *testing.T) { + handle, err := db.OpenDB("sqlite://" + filepath.Join(t.TempDir(), "calnode.db")) + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Dialect(); got != db.DialectSQLite { + t.Errorf("dialect = %v; want %v", got, db.DialectSQLite) + } + if got := handle.Stats().MaxOpenConnections; got != 1 { + t.Errorf("MaxOpenConnections = %d; want 1", got) + } + + pragmas := []struct{ name, want string }{ + {"journal_mode", "wal"}, + {"foreign_keys", "1"}, + {"busy_timeout", "5000"}, + } + for _, p := range pragmas { + var got string + if err := handle.QueryRow(`PRAGMA ` + p.name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", p.name, err) + } + if got != p.want { + t.Errorf("PRAGMA %s = %q; want %q", p.name, got, p.want) + } + } +} + +// TestOpenDB_wrapperRoundTrip exercises the method set the rest of the codebase +// uses, on the dialect where rebinding is a no-op, so a mistake in the wrapper +// itself cannot hide behind a missing Postgres server. +func TestOpenDB_wrapperRoundTrip(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + defer handle.Close() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + ctx := context.Background() + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext insert: %v", err) + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if tx.Dialect() != handle.Dialect() { + t.Errorf("tx dialect = %v; want %v", tx.Dialect(), handle.Dialect()) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} diff --git a/internal/db/default_workspace_test.go b/internal/db/default_workspace_test.go new file mode 100644 index 0000000..72ce3a8 --- /dev/null +++ b/internal/db/default_workspace_test.go @@ -0,0 +1,83 @@ +package db_test + +import ( + "context" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// The seeded `default` workspace on a MULTI_TENANT instance. +// +// Migration 00060 seeds it because it is the workspace every single-tenant row belongs to +// and the one the SQLite column default names. On a multi-tenant instance it is a tenant +// nobody owns: no public_host (so no Host resolves to it), no users, no settings. Left +// active, every background sweep still enumerates it — activeWorkspaceIDs filters on +// status = 'active' — so the instance keeps doing work on behalf of a tenant that cannot +// receive it. +// +// ⛔ The decision: suspend it at multi-tenant BOOT, not in the migration. A migration +// cannot see MULTI_TENANT, and in single-tenant mode `default` IS the workspace, so a +// suspended row there would make Scoped answer 503 to every request on the instance. One +// status flip removes it from every sweep at once, reusing D12's suspended semantics +// rather than inventing a second exclusion rule that a new loop could forget. +func TestPostgres_defaultWorkspaceIsSuspendedAtMultiTenantBoot(t *testing.T) { + handle := dbtest.RequirePostgres(t) + ctx := context.Background() + + // Freshly migrated, it is active — which is what single-tenant needs. + var status string + if err := handle.QueryRow( + `SELECT status FROM workspaces WHERE id = ?`, db.DefaultWorkspaceID).Scan(&status); err != nil { + t.Fatalf("read the default workspace: %v", err) + } + if status != "active" { + t.Fatalf("straight after migrating, the default workspace is %q; want active — "+ + "single-tenant mode runs as this workspace and a suspended one answers 503", status) + } + + if err := handle.SuspendDefaultWorkspace(ctx); err != nil { + t.Fatalf("SuspendDefaultWorkspace: %v", err) + } + if err := handle.QueryRow( + `SELECT status FROM workspaces WHERE id = ?`, db.DefaultWorkspaceID).Scan(&status); err != nil { + t.Fatalf("read back: %v", err) + } + if status != "suspended" { + t.Errorf("after SuspendDefaultWorkspace the default workspace is %q; want suspended", status) + } + + // Idempotent: boot happens more than once. + if err := handle.SuspendDefaultWorkspace(ctx); err != nil { + t.Errorf("second SuspendDefaultWorkspace: %v", err) + } + + // The consequence that matters — it is no longer an active workspace, which is what + // every sweep enumerates. + var active int + if err := handle.QueryRow( + `SELECT COUNT(*) FROM workspaces WHERE status = 'active' AND id = ?`, + db.DefaultWorkspaceID).Scan(&active); err != nil { + t.Fatalf("count active default: %v", err) + } + if active != 0 { + t.Errorf("the default workspace is still counted active; every background sweep " + + "would keep taking a pass over it") + } + + // A workspace that is not `default` is untouched: this is one row, not a policy. + if _, err := handle.Exec( + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES ('acme', 'acme', 'book.acme.test', 'us', 'active')`); err != nil { + t.Fatalf("seed acme: %v", err) + } + if err := handle.SuspendDefaultWorkspace(ctx); err != nil { + t.Fatalf("third SuspendDefaultWorkspace: %v", err) + } + if err := handle.QueryRow(`SELECT status FROM workspaces WHERE id = 'acme'`).Scan(&status); err != nil { + t.Fatalf("read acme: %v", err) + } + if status != "active" { + t.Errorf("acme is %q after suspending the default workspace; want active", status) + } +} diff --git a/internal/db/dialect.go b/internal/db/dialect.go new file mode 100644 index 0000000..e3881c2 --- /dev/null +++ b/internal/db/dialect.go @@ -0,0 +1,106 @@ +package db + +import ( + "database/sql" + "strings" + + "github.com/jackc/pgx/v5/stdlib" +) + +// Dialect names the SQL engine behind a handle. +// +// Calnode's SQL is hand-written and almost entirely portable. Two things are +// not: placeholder syntax (? versus $n), which the DB/Tx wrapper hides by +// rebinding every statement on its way through, and the handful of statements +// that use engine-specific functions, which callers resolve with Dialect.SQL. +type Dialect int + +const ( + // DialectSQLite is the zero value deliberately: a handle whose dialect + // could not be determined behaves exactly as it did before this package + // knew about Postgres. + DialectSQLite Dialect = iota + DialectPostgres +) + +// String returns the dialect's canonical lower-case name. +func (d Dialect) String() string { + switch d { + case DialectPostgres: + return "postgres" + default: + return "sqlite" + } +} + +// SQL picks between two hand-written statements. +// +// Reach for this only when one portable statement is genuinely impossible — +// engine-specific functions (datetime('now'), strftime), upsert spelling, a +// PRAGMA. Differing placeholders are not a reason: the wrapper rebinds those, +// and duplicating a statement to change ? to $1 doubles the maintenance for no +// gain. +func (d Dialect) SQL(sqlite, postgres string) string { + if d == DialectPostgres { + return postgres + } + return sqlite +} + +// Rebind converts a portable ?-placeholder statement into this dialect's form. +// SQLite takes ? natively, so its statements are returned untouched with no +// allocation. +func (d Dialect) Rebind(query string) string { + if d != DialectPostgres { + return query + } + return Rebind(query) +} + +// driverName is the database/sql driver this dialect opens with. +func (d Dialect) driverName() string { + if d == DialectPostgres { + return "pgx" + } + return "sqlite" +} + +// gooseDialect is goose's name for this engine. +func (d Dialect) gooseDialect() string { + if d == DialectPostgres { + return "postgres" + } + return "sqlite3" +} + +// migrationsDir is the embedded directory holding this dialect's migrations. +// The two sets carry the same version numbers by construction — one schema, two +// spellings — which is what lets TargetVersion stay dialect-independent. +func (d Dialect) migrationsDir() string { + if d == DialectPostgres { + return "migrations/postgres" + } + return "migrations/sqlite" +} + +// dialectFromURL classifies a DATABASE_URL. Only an explicit postgres URL +// selects Postgres, so every form that worked before still works unchanged: +// sqlite://./rel, sqlite:///abs, :memory:, and a bare file path. +func dialectFromURL(databaseURL string) Dialect { + scheme := strings.ToLower(databaseURL) + if strings.HasPrefix(scheme, "postgres://") || strings.HasPrefix(scheme, "postgresql://") { + return DialectPostgres + } + return DialectSQLite +} + +// dialectOf recovers the dialect from an already-open handle, for the +// package-level helpers that still take a bare *sql.DB. An unrecognised driver +// reads as SQLite: that is what every caller of those helpers was before, so an +// unknown driver degrades to the old behaviour rather than to an error. +func dialectOf(db *sql.DB) Dialect { + if _, ok := db.Driver().(*stdlib.Driver); ok { + return DialectPostgres + } + return DialectSQLite +} diff --git a/internal/db/dialect_internal_test.go b/internal/db/dialect_internal_test.go new file mode 100644 index 0000000..19d7588 --- /dev/null +++ b/internal/db/dialect_internal_test.go @@ -0,0 +1,99 @@ +package db + +import ( + "database/sql" + "testing" +) + +func TestDialectFromURL(t *testing.T) { + tests := []struct { + url string + want Dialect + }{ + {"sqlite://./data/calnode.db", DialectSQLite}, + {"sqlite:///var/lib/calnode/calnode.db", DialectSQLite}, + {"sqlite://:memory:", DialectSQLite}, + {"sqlite://file::memory:?cache=shared&_fk=1", DialectSQLite}, + {"./data/calnode.db", DialectSQLite}, + {"/var/lib/calnode/calnode.db", DialectSQLite}, + {":memory:", DialectSQLite}, + {"", DialectSQLite}, + {"postgres://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgresql://calnode@localhost:5432/calnode", DialectPostgres}, + {"postgres://u:p@h:5432/d?sslmode=require", DialectPostgres}, + {"POSTGRES://u:p@h:5432/d", DialectPostgres}, + // A path that merely mentions postgres is still a SQLite file. + {"./postgres-backup/calnode.db", DialectSQLite}, + } + + for _, tt := range tests { + if got := dialectFromURL(tt.url); got != tt.want { + t.Errorf("dialectFromURL(%q) = %v; want %v", tt.url, got, tt.want) + } + } +} + +func TestParseDSN(t *testing.T) { + tests := []struct{ url, want string }{ + {"sqlite://./data/calnode.db", "./data/calnode.db"}, + {"sqlite:///var/lib/calnode/calnode.db", "/var/lib/calnode/calnode.db"}, + {"sqlite://:memory:", ":memory:"}, + {"sqlite://file::memory:?cache=shared&_fk=1", "file::memory:?cache=shared&_fk=1"}, + {"./data/calnode.db", "./data/calnode.db"}, + // Windows: sqlite:///C:/path/db → C:/path/db. + {"sqlite:///C:/calnode/calnode.db", "C:/calnode/calnode.db"}, + } + + for _, tt := range tests { + if got := parseDSN(tt.url); got != tt.want { + t.Errorf("parseDSN(%q) = %q; want %q", tt.url, got, tt.want) + } + } +} + +// TestDialectOf covers the driver sniffing the package-level Migrate and the +// version helpers rely on. It needs no server: database/sql is lazy, so both +// handles exist without a connection. +func TestDialectOf(t *testing.T) { + sqlite, err := sql.Open(DialectSQLite.driverName(), ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlite.Close() + + postgres, err := sql.Open(DialectPostgres.driverName(), "postgres://u:p@127.0.0.1:5432/d") + if err != nil { + t.Fatalf("open postgres: %v", err) + } + defer postgres.Close() + + if got := dialectOf(sqlite); got != DialectSQLite { + t.Errorf("dialectOf(sqlite handle) = %v; want %v", got, DialectSQLite) + } + if got := dialectOf(postgres); got != DialectPostgres { + t.Errorf("dialectOf(postgres handle) = %v; want %v", got, DialectPostgres) + } +} + +func TestDialectNames(t *testing.T) { + tests := []struct { + dialect Dialect + driver, goose string + migrationsPath string + }{ + {DialectSQLite, "sqlite", "sqlite3", "migrations/sqlite"}, + {DialectPostgres, "pgx", "postgres", "migrations/postgres"}, + } + + for _, tt := range tests { + if got := tt.dialect.driverName(); got != tt.driver { + t.Errorf("%v.driverName() = %q; want %q", tt.dialect, got, tt.driver) + } + if got := tt.dialect.gooseDialect(); got != tt.goose { + t.Errorf("%v.gooseDialect() = %q; want %q", tt.dialect, got, tt.goose) + } + if got := tt.dialect.migrationsDir(); got != tt.migrationsPath { + t.Errorf("%v.migrationsDir() = %q; want %q", tt.dialect, got, tt.migrationsPath) + } + } +} diff --git a/internal/db/handle.go b/internal/db/handle.go new file mode 100644 index 0000000..54832b5 --- /dev/null +++ b/internal/db/handle.go @@ -0,0 +1,434 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" +) + +// DB is a *sql.DB that knows its dialect, rebinds placeholders, and — in +// multi-tenant mode — binds the tenant of every statement it runs. +// +// Every query method takes the portable ? form and rewrites it for the engine in +// use, so the rest of Calnode writes one statement per query no matter which +// database it runs on. Behaviour on SQLite is identical to using *sql.DB +// directly: Rebind is a no-op there. +// +// # Tenant binding +// +// A handle from OpenPair carries a workspace. Before each statement it acquires a +// pooled connection, runs SELECT set_config('app.workspace_id', …), runs the +// statement on that connection, and releases the connection when the statement is +// finished — Exec immediately, Row on Scan/Err, Rows on Close, Tx on +// Commit/Rollback. Nothing is pinned between statements, which is what makes a +// handle safe to copy into a fire-and-forget goroutine: it holds a pool and a +// string, not a session. +// +// Because every statement sets the parameter itself, a value left behind on a +// pooled connection can never leak into a later statement. The unbound handle — +// the platform handle, and the pair's base app handle — binds the empty string, +// which no workspace id can equal, so it matches no row under the policies. +// +// A handle from OpenDB carries no workspace and binds nothing, so single-tenant +// SQLite and single-tenant PostgreSQL run exactly the statements they ran before. +// +// The embedded *sql.DB is exported on purpose — pool tuning, Ping, Close and +// anything that must hand a plain *sql.DB to a library (goose here, Litestream in +// DEPLOY.md) reach it as h.DB. ⛔ Statements issued through that field are NOT +// rebound AND NOT TENANT-BOUND: on a multi-tenant instance they will see nothing +// and write nothing, because an unbound session matches no row. Use the wrapper's +// own methods unless you are deliberately writing engine-specific DDL. +type DB struct { + *sql.DB + dialect Dialect + + // multiTenant is set only by OpenPair. It is what turns the binding on; a + // single handle from OpenDB leaves it false and behaves as it always did. + multiTenant bool + + // workspace is the tenant this handle is bound to. "" is the unbound handle, + // which binds '' and therefore matches no row. + workspace string + + // platform is the paired owner handle. nil on a single handle, where + // Platform() answers with the handle itself. + platform *DB + + // err poisons a handle built from a workspace id that failed validation. + // Every statement method returns it rather than running unbound, because an + // unbound statement on a multi-tenant database is silently empty. + err error +} + +// ErrInvalidWorkspace is returned by every statement on a handle built from a +// workspace id that is not [a-z0-9_-]{1,64}. +var ErrInvalidWorkspace = errors.New("db: invalid workspace id") + +// workspaceIDPattern is the shape of a workspace id. The id is never +// interpolated into SQL — it is a bind parameter to set_config — so this is not +// an injection defence; it is a guard against a caller passing something that is +// not an id at all (an email, a URL, a whole request path) and getting a handle +// that silently matches nothing. +var workspaceIDPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`) + +// ValidWorkspaceID reports whether id is a well-formed workspace id. +func ValidWorkspaceID(id string) bool { return workspaceIDPattern.MatchString(id) } + +// Dialect reports which engine this handle is talking to, for the few callers +// that must branch on it. +func (h *DB) Dialect() Dialect { return h.dialect } + +// MultiTenant reports whether this handle binds a tenant per statement. +func (h *DB) MultiTenant() bool { return h.multiTenant } + +// Workspace returns the workspace this handle is bound to, or "" for an unbound +// handle (single-tenant, the platform handle, or a pair's base app handle). +func (h *DB) Workspace() string { return h.workspace } + +// Err returns the error that poisoned this handle, or nil. +func (h *DB) Err() error { return h.err } + +// ForWorkspace returns a handle that binds id before every statement. +// +// It is a cheap value: the returned handle shares this one's pool and adds a +// string, so it may be copied freely and handed to a goroutine that outlives the +// request that made it. Nothing is pinned. +// +// On a single handle — SQLite, or single-tenant PostgreSQL — it is the identity +// function and does not even validate, because there is exactly one workspace and +// no parameter to bind. +func (h *DB) ForWorkspace(id string) *DB { + if !h.binds() { + return h + } + scoped := *h + if !ValidWorkspaceID(id) { + scoped.err = fmt.Errorf("%w: %q", ErrInvalidWorkspace, id) + return &scoped + } + scoped.workspace = id + scoped.err = nil + return &scoped +} + +// Platform returns the handle that owns the schema and bypasses row-level +// security: migrations, the worker's cross-tenant claim loop, the reconciler's +// workspace enumeration, and the credential lookups that must resolve a tenant +// before one is known. +// +// On a single handle it returns the handle itself, so a caller needs no branch: +// in single-tenant mode the application role and the platform role are one role. +func (h *DB) Platform() *DB { + if h.platform != nil { + return h.platform + } + return h +} + +// binds reports whether this handle sets app.workspace_id per statement. +func (h *DB) binds() bool { return h.multiTenant && h.dialect == DialectPostgres } + +// bindConn takes a connection out of the pool and sets app.workspace_id on it. +// It returns (nil, nil) for a handle that does not bind, which every caller reads +// as "use the pool directly, as before". +// +// The statement is written in PostgreSQL's own $n form because a *sql.Conn does +// not go through the wrapper and is therefore not rebound. This path is +// PostgreSQL-only by construction. +func (h *DB) bindConn(ctx context.Context) (*sql.Conn, error) { + if !h.binds() { + return nil, nil + } + conn, err := h.DB.Conn(ctx) + if err != nil { + return nil, err + } + if _, err := conn.ExecContext(ctx, + `SELECT set_config('app.workspace_id', $1, false)`, h.workspace); err != nil { + conn.Close() //nolint:errcheck // the bind error is the useful one + return nil, fmt.Errorf("bind workspace %q: %w", h.workspace, err) + } + return conn, nil +} + +// Rebind converts a ?-placeholder statement for this handle's dialect. Useful +// when a caller builds SQL dynamically and hands it to something other than the +// methods below. +func (h *DB) Rebind(query string) string { return h.dialect.Rebind(query) } + +func (h *DB) Query(query string, args ...any) (*Rows, error) { + return h.QueryContext(context.Background(), query, args...) +} + +func (h *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) { + if h.err != nil { + return nil, h.err + } + conn, err := h.bindConn(ctx) + if err != nil { + return nil, err + } + if conn == nil { + rows, err := h.DB.QueryContext(ctx, h.dialect.Rebind(query), args...) + if err != nil { + return nil, err + } + return &Rows{Rows: rows}, nil + } + rows, err := conn.QueryContext(ctx, h.dialect.Rebind(query), args...) + if err != nil { + conn.Close() //nolint:errcheck // the query error is the useful one + return nil, err + } + return &Rows{Rows: rows, conn: conn}, nil +} + +func (h *DB) QueryRow(query string, args ...any) *Row { + return h.QueryRowContext(context.Background(), query, args...) +} + +func (h *DB) QueryRowContext(ctx context.Context, query string, args ...any) *Row { + if h.err != nil { + return &Row{err: h.err} + } + conn, err := h.bindConn(ctx) + if err != nil { + return &Row{err: err} + } + if conn == nil { + return &Row{Row: h.DB.QueryRowContext(ctx, h.dialect.Rebind(query), args...)} + } + return &Row{Row: conn.QueryRowContext(ctx, h.dialect.Rebind(query), args...), conn: conn} +} + +func (h *DB) Exec(query string, args ...any) (sql.Result, error) { + return h.ExecContext(context.Background(), query, args...) +} + +func (h *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + if h.err != nil { + return nil, h.err + } + conn, err := h.bindConn(ctx) + if err != nil { + return nil, err + } + if conn == nil { + return h.DB.ExecContext(ctx, h.dialect.Rebind(query), args...) + } + defer conn.Close() //nolint:errcheck // the statement's error is the useful one + return conn.ExecContext(ctx, h.dialect.Rebind(query), args...) +} + +// Prepare and PrepareContext rebind at prepare time, so the returned *sql.Stmt +// needs no wrapper of its own. +// +// ⛔ They are refused on a handle that binds a tenant. A *sql.Stmt is re-prepared +// on whatever connection the pool hands it, and there is no hook to set +// app.workspace_id on that connection first — so a prepared statement on a +// multi-tenant handle would run unbound and silently see nothing. Nothing in the +// tree prepares a statement; if something needs to, it should take a transaction, +// where the binding is a property of the connection for the whole tx. +func (h *DB) Prepare(query string) (*sql.Stmt, error) { + return h.PrepareContext(context.Background(), query) +} + +func (h *DB) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + if h.err != nil { + return nil, h.err + } + if h.binds() { + return nil, errors.New("db: Prepare is not available on a tenant-bound handle — " + + "a *sql.Stmt is re-prepared on an arbitrary pooled connection, which would run unbound; use a transaction") + } + return h.DB.PrepareContext(ctx, h.dialect.Rebind(query)) +} + +// Begin and BeginTx return a *Tx rather than a *sql.Tx: a transaction that +// silently stopped rebinding would be the easiest way to reintroduce ? into a +// Postgres statement. +func (h *DB) Begin() (*Tx, error) { + return h.BeginTx(context.Background(), nil) +} + +func (h *DB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) { + if h.err != nil { + return nil, h.err + } + if !h.binds() { + tx, err := h.DB.BeginTx(ctx, opts) + if err != nil { + return nil, err + } + return &Tx{Tx: tx, dialect: h.dialect}, nil + } + + // A transaction owns its connection for its whole life, so the binding is set + // once, inside the transaction, with SET LOCAL semantics: it reverts when the + // transaction ends, which means the connection goes back to the pool carrying + // nothing. Every statement on the pool sets the parameter itself anyway, so + // this is belt and braces rather than the guarantee. + conn, err := h.DB.Conn(ctx) + if err != nil { + return nil, err + } + tx, err := conn.BeginTx(ctx, opts) + if err != nil { + conn.Close() //nolint:errcheck + return nil, err + } + if _, err := tx.ExecContext(ctx, + `SELECT set_config('app.workspace_id', $1, true)`, h.workspace); err != nil { + tx.Rollback() //nolint:errcheck + conn.Close() //nolint:errcheck + return nil, fmt.Errorf("bind workspace %q on transaction: %w", h.workspace, err) + } + return &Tx{Tx: tx, dialect: h.dialect, conn: conn, workspace: h.workspace}, nil +} + +// Rows is a *sql.Rows that releases the connection its statement was bound on. +// +// Close does both, and is what every caller already defers. A caller that never +// closes leaks a connection out of the pool on a multi-tenant instance, which is +// the same bug as never closing a *sql.Rows, only with a pool of ten rather than +// a cursor. +type Rows struct { + *sql.Rows + conn *sql.Conn +} + +// Close closes the cursor and releases the bound connection. It returns the +// cursor's error in preference to the release's, because the cursor's is the one +// that says something about the query. +func (r *Rows) Close() error { + err := r.Rows.Close() + if r.conn != nil { + cerr := r.conn.Close() + r.conn = nil + if err == nil { + err = cerr + } + } + return err +} + +// Row is a *sql.Row that releases the connection its statement was bound on. +// +// Scan and Err both release, because those are the only two things a caller can +// do with a Row and exactly one of them always happens. errors.Is against +// sql.ErrNoRows works unchanged: Scan returns the underlying error verbatim. +type Row struct { + *sql.Row + conn *sql.Conn + err error +} + +func (r *Row) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + err := r.Row.Scan(dest...) + r.release() + return err +} + +func (r *Row) Err() error { + if r.err != nil { + return r.err + } + err := r.Row.Err() + r.release() + return err +} + +func (r *Row) release() { + if r.conn != nil { + r.conn.Close() //nolint:errcheck // nothing useful to do with it here + r.conn = nil + } +} + +// Tx is a *sql.Tx with the same rebinding behaviour as DB, and — when it came +// from a tenant-bound handle — ownership of the connection the tenant was bound +// on. Commit and Rollback release it. +type Tx struct { + *sql.Tx + dialect Dialect + conn *sql.Conn + workspace string +} + +// Dialect reports which engine this transaction is running against. +func (t *Tx) Dialect() Dialect { return t.dialect } + +// Workspace returns the workspace this transaction is bound to, or "". +func (t *Tx) Workspace() string { return t.workspace } + +// Rebind converts a ?-placeholder statement for this transaction's dialect. +func (t *Tx) Rebind(query string) string { return t.dialect.Rebind(query) } + +// Commit and Rollback release the pinned connection. +// +// Both are idempotent about the release, because `defer tx.Rollback()` after a +// successful Commit is the standard pattern in this tree: the second call gets +// sql.ErrTxDone from database/sql, and must not double-close the connection. +func (t *Tx) Commit() error { + err := t.Tx.Commit() + t.release() + return err +} + +func (t *Tx) Rollback() error { + err := t.Tx.Rollback() + t.release() + return err +} + +func (t *Tx) release() { + if t.conn != nil { + t.conn.Close() //nolint:errcheck + t.conn = nil + } +} + +func (t *Tx) Query(query string, args ...any) (*Rows, error) { + rows, err := t.Tx.Query(t.dialect.Rebind(query), args...) + if err != nil { + return nil, err + } + return &Rows{Rows: rows}, nil +} + +func (t *Tx) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) { + rows, err := t.Tx.QueryContext(ctx, t.dialect.Rebind(query), args...) + if err != nil { + return nil, err + } + return &Rows{Rows: rows}, nil +} + +func (t *Tx) QueryRow(query string, args ...any) *Row { + return &Row{Row: t.Tx.QueryRow(t.dialect.Rebind(query), args...)} +} + +func (t *Tx) QueryRowContext(ctx context.Context, query string, args ...any) *Row { + return &Row{Row: t.Tx.QueryRowContext(ctx, t.dialect.Rebind(query), args...)} +} + +func (t *Tx) Exec(query string, args ...any) (sql.Result, error) { + return t.Tx.Exec(t.dialect.Rebind(query), args...) +} + +func (t *Tx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return t.Tx.ExecContext(ctx, t.dialect.Rebind(query), args...) +} + +func (t *Tx) Prepare(query string) (*sql.Stmt, error) { + return t.Tx.Prepare(t.dialect.Rebind(query)) +} + +func (t *Tx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { + return t.Tx.PrepareContext(ctx, t.dialect.Rebind(query)) +} diff --git a/internal/db/migrations/postgres/00001_initial_schema.sql b/internal/db/migrations/postgres/00001_initial_schema.sql new file mode 100644 index 0000000..1b453e0 --- /dev/null +++ b/internal/db/migrations/postgres/00001_initial_schema.sql @@ -0,0 +1,210 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + avatar_url TEXT, + is_admin SMALLINT NOT NULL DEFAULT 0, -- first user bootstraps as admin + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, -- stored hashed; shown once on creation + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS team_members ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')), + routing_priority INTEGER NOT NULL DEFAULT 0, + UNIQUE (team_id, user_id) +); + +CREATE TABLE IF NOT EXISTS event_types ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + slug TEXT NOT NULL UNIQUE, -- unique within workspace (§5) + name TEXT NOT NULL, + description TEXT, + duration_minutes INTEGER NOT NULL, + slot_interval_minutes INTEGER NOT NULL DEFAULT 30, + location_type TEXT NOT NULL DEFAULT 'link' + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link')), + location_value TEXT, + routing_mode TEXT NOT NULL DEFAULT 'fixed' + CHECK (routing_mode IN ('fixed', 'round_robin', 'collective', 'priority')), + buffer_before_minutes INTEGER NOT NULL DEFAULT 0, + buffer_after_minutes INTEGER NOT NULL DEFAULT 0, + min_notice_minutes INTEGER NOT NULL DEFAULT 0, + max_future_days INTEGER NOT NULL DEFAULT 60, + seat_limit INTEGER NOT NULL DEFAULT 1, + is_active SMALLINT NOT NULL DEFAULT 1, + is_public SMALLINT NOT NULL DEFAULT 1, -- false = bookable only via direct link + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS event_type_questions ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + label TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('text', 'select', 'checkbox')), + options TEXT, -- JSON array; used for type='select' + required SMALLINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS availability_rules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type_id TEXT REFERENCES event_types(id) ON DELETE CASCADE, -- NULL = global default + day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sun … 6=Sat + start_time TEXT NOT NULL, -- HH:MM host-local wall-clock (§6.3) + end_time TEXT NOT NULL, + UNIQUE (user_id, event_type_id, day_of_week, start_time, end_time) +); + +CREATE TABLE IF NOT EXISTS availability_overrides ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, -- YYYY-MM-DD + is_available SMALLINT NOT NULL DEFAULT 0, + start_time TEXT, -- HH:MM; only when is_available=1 + end_time TEXT +); + +CREATE TABLE IF NOT EXISTS calendar_connections ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('google', 'microsoft', 'caldav')), + access_token_enc TEXT NOT NULL, -- AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + refresh_token_enc TEXT, + calendar_id TEXT NOT NULL, + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include in free/busy checks (§8.3) + is_destination SMALLINT NOT NULL DEFAULT 0, -- write bookings to this calendar + sync_token TEXT, -- incremental sync cursor + channel_expires_at TEXT, -- push-notification channel renewal (§13) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS bookings ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE RESTRICT, -- explicit: historical bookings block event-type deletion + host_id TEXT NOT NULL REFERENCES users(id), + start_at TEXT NOT NULL, -- UTC ISO 8601 (§6.3) + end_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('confirmed', 'cancelled')), + cancellation_reason TEXT, + location_value TEXT, + meeting_link TEXT, + external_event_id TEXT, -- calendar event id we created (for own-event exclusion §6.2) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +-- Double-booking guard §6.4 — FIRST-LINE ONLY. +-- This index blocks exact-start-time collisions, but does NOT prevent overlapping bookings +-- with different start times (e.g. a 09:45 booking and a 10:00 booking on the same host). +-- The booking handler MUST also run an overlap check inside BEGIN IMMEDIATE: +-- SELECT 1 FROM bookings +-- WHERE host_id = :host_id AND status != 'cancelled' +-- AND start_at < :new_end_at AND end_at > :new_start_at +-- Fail with 409 if any row is returned before inserting. +CREATE UNIQUE INDEX IF NOT EXISTS idx_bookings_no_double + ON bookings (host_id, start_at) WHERE status != 'cancelled'; + +CREATE INDEX IF NOT EXISTS idx_bookings_host_time + ON bookings (host_id, start_at, end_at) WHERE status = 'confirmed'; + +CREATE TABLE IF NOT EXISTS booking_attendees ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL, + iana_timezone TEXT NOT NULL DEFAULT 'UTC', + is_organizer SMALLINT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS booking_answers ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + question_id TEXT NOT NULL REFERENCES event_type_questions(id), + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + url TEXT NOT NULL, + events TEXT NOT NULL, -- JSON array: ["booking.created","booking.cancelled",...] + secret_enc TEXT NOT NULL, -- HMAC signing secret, AES-GCM encrypted with CALNODE_ENCRYPTION_KEY (§15) + is_active SMALLINT NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, + booking_id TEXT REFERENCES bookings(id), + event TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'success', 'failed')), + response_status INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempted_at TEXT +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT NOT NULL, -- JSON + run_at TEXT NOT NULL, -- UTC ISO 8601; worker polls WHERE run_at <= now + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'running', 'done', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_jobs_pending + ON jobs (run_at) WHERE status = 'pending'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_pending; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS webhook_deliveries; +DROP TABLE IF EXISTS webhooks; +DROP TABLE IF EXISTS booking_answers; +DROP TABLE IF EXISTS booking_attendees; +DROP INDEX IF EXISTS idx_bookings_host_time; +DROP INDEX IF EXISTS idx_bookings_no_double; +DROP TABLE IF EXISTS bookings; +DROP TABLE IF EXISTS calendar_connections; +DROP TABLE IF EXISTS availability_overrides; +DROP TABLE IF EXISTS availability_rules; +DROP TABLE IF EXISTS event_type_questions; +DROP TABLE IF EXISTS event_types; +DROP TABLE IF EXISTS team_members; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS users; diff --git a/internal/db/migrations/postgres/00002_manage_tokens.sql b/internal/db/migrations/postgres/00002_manage_tokens.sql new file mode 100644 index 0000000..1a65543 --- /dev/null +++ b/internal/db/migrations/postgres/00002_manage_tokens.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS booking_manage_tokens ( + token_hash TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_manage_tokens_booking + ON booking_manage_tokens (booking_id); + +-- +goose Down +DROP INDEX IF EXISTS idx_manage_tokens_booking; +DROP TABLE IF EXISTS booking_manage_tokens; diff --git a/internal/db/migrations/00003_job_lock_timeout.sql b/internal/db/migrations/postgres/00003_job_lock_timeout.sql similarity index 100% rename from internal/db/migrations/00003_job_lock_timeout.sql rename to internal/db/migrations/postgres/00003_job_lock_timeout.sql diff --git a/internal/db/migrations/postgres/00004_sessions.sql b/internal/db/migrations/postgres/00004_sessions.sql new file mode 100644 index 0000000..122a6e5 --- /dev/null +++ b/internal/db/migrations/postgres/00004_sessions.sql @@ -0,0 +1,17 @@ +-- +goose Up + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, -- 32-byte crypto-random hex; the cookie value + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +-- +goose Down + +DROP INDEX IF EXISTS idx_sessions_expires; +DROP INDEX IF EXISTS idx_sessions_user_id; +DROP TABLE IF EXISTS sessions; diff --git a/internal/db/migrations/00005_override_unique.sql b/internal/db/migrations/postgres/00005_override_unique.sql similarity index 100% rename from internal/db/migrations/00005_override_unique.sql rename to internal/db/migrations/postgres/00005_override_unique.sql diff --git a/internal/db/migrations/00006_jobs_type_payload_unique.sql b/internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql similarity index 100% rename from internal/db/migrations/00006_jobs_type_payload_unique.sql rename to internal/db/migrations/postgres/00006_jobs_type_payload_unique.sql diff --git a/internal/db/migrations/postgres/00007_user_prefs.sql b/internal/db/migrations/postgres/00007_user_prefs.sql new file mode 100644 index 0000000..011a07e --- /dev/null +++ b/internal/db/migrations/postgres/00007_user_prefs.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN time_format TEXT NOT NULL DEFAULT '12h'; +ALTER TABLE users ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1; -- 1=Monday, 0=Sunday +-- The two UPDATEs are kept in step with the SQLite migration; they are no-ops +-- here, because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET time_format = '12h' WHERE time_format IS NULL; +UPDATE users SET week_start = 1 WHERE week_start IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00008_date_format.sql b/internal/db/migrations/postgres/00008_date_format.sql new file mode 100644 index 0000000..9b5ab47 --- /dev/null +++ b/internal/db/migrations/postgres/00008_date_format.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN date_format TEXT NOT NULL DEFAULT 'dmy'; +-- The UPDATE is kept in step with the SQLite migration; it is a no-op here, +-- because ADD COLUMN with a NOT NULL DEFAULT backfills every existing row. +UPDATE users SET date_format = 'dmy' WHERE date_format IS NULL; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00009_override_reason.sql b/internal/db/migrations/postgres/00009_override_reason.sql new file mode 100644 index 0000000..c3b8fab --- /dev/null +++ b/internal/db/migrations/postgres/00009_override_reason.sql @@ -0,0 +1,10 @@ +-- +goose Up +ALTER TABLE availability_overrides ADD COLUMN reason TEXT NOT NULL DEFAULT 'day_off'; +-- Back-fill existing rows: custom hours rows get 'custom_hours', unavailable rows keep 'day_off'. +UPDATE availability_overrides SET reason = 'custom_hours' WHERE is_available = 1; +UPDATE availability_overrides SET reason = 'day_off' WHERE is_available = 0; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00010_messaging_prefs.sql b/internal/db/migrations/postgres/00010_messaging_prefs.sql new file mode 100644 index 0000000..dfdcc22 --- /dev/null +++ b/internal/db/migrations/postgres/00010_messaging_prefs.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- User-level notification on/off toggles (all default 1 = on, preserving existing behaviour) +ALTER TABLE users ADD COLUMN notify_confirmation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_cancellation SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reschedule SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_reminder SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_booking SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_cancel SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN notify_host_reschedule SMALLINT NOT NULL DEFAULT 1; + +-- Per-event-type custom notes appended to each email type +ALTER TABLE event_types ADD COLUMN msg_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN msg_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN msg_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN msg_reminder TEXT; + +-- Per-event-type reminder timing list (replaces the hardcoded 24h) +CREATE TABLE event_type_reminders ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + hours_before INTEGER NOT NULL CHECK(hours_before > 0), + UNIQUE(event_type_id, hours_before) +); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. +DROP TABLE IF EXISTS event_type_reminders; diff --git a/internal/db/migrations/postgres/00011_server_settings.sql b/internal/db/migrations/postgres/00011_server_settings.sql new file mode 100644 index 0000000..c85ba6f --- /dev/null +++ b/internal/db/migrations/postgres/00011_server_settings.sql @@ -0,0 +1,20 @@ +-- +goose Up +CREATE TABLE server_settings ( + -- Not an identity column: the row is seeded below with an explicit id and the + -- CHECK makes 1 the only legal value, so a sequence would only be misleading. + id INTEGER PRIMARY KEY CHECK(id = 1), + smtp_host TEXT NOT NULL DEFAULT '', + smtp_port TEXT NOT NULL DEFAULT '587', + smtp_user TEXT NOT NULL DEFAULT '', + smtp_pass_enc TEXT NOT NULL DEFAULT '', + smtp_tls SMALLINT NOT NULL DEFAULT 0, + smtp_starttls SMALLINT NOT NULL DEFAULT 1, + email_from TEXT NOT NULL DEFAULT '', + email_from_name TEXT NOT NULL DEFAULT 'Calnode', + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); +-- Seed the single row so UPDATE statements always find it. +INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS server_settings; diff --git a/internal/db/migrations/postgres/00012_auth_providers.sql b/internal/db/migrations/postgres/00012_auth_providers.sql new file mode 100644 index 0000000..11c19ca --- /dev/null +++ b/internal/db/migrations/postgres/00012_auth_providers.sql @@ -0,0 +1,33 @@ +-- +goose Up + +-- Email/password login and OAuth provider columns on users. +-- email_login=1 means the user can authenticate with email+password. +-- provider/provider_id store the OAuth identity (only one provider per user). +ALTER TABLE users ADD COLUMN email_login SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN password_hash TEXT; +ALTER TABLE users ADD COLUMN provider TEXT; -- 'google', 'microsoft', etc. +ALTER TABLE users ADD COLUMN provider_id TEXT; -- provider's opaque user ID + +-- Invite tokens: single-use, locked to a specific email, 7-day expiry. +CREATE TABLE invite_tokens ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE INDEX idx_invite_tokens_email ON invite_tokens(email); + +-- +goose Down + +DROP INDEX IF EXISTS idx_invite_tokens_email; +DROP TABLE IF EXISTS invite_tokens; + +-- The SQLite migration rebuilds users here because it cannot drop a column; +-- Postgres drops the four directly, which reaches the same schema. +ALTER TABLE users DROP COLUMN provider_id; +ALTER TABLE users DROP COLUMN provider; +ALTER TABLE users DROP COLUMN password_hash; +ALTER TABLE users DROP COLUMN email_login; diff --git a/internal/db/migrations/postgres/00013_google_oauth_settings.sql b/internal/db/migrations/postgres/00013_google_oauth_settings.sql new file mode 100644 index 0000000..4e89f5f --- /dev/null +++ b/internal/db/migrations/postgres/00013_google_oauth_settings.sql @@ -0,0 +1,8 @@ +-- +goose Up +ALTER TABLE server_settings ADD COLUMN google_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN google_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql new file mode 100644 index 0000000..119af9a --- /dev/null +++ b/internal/db/migrations/postgres/00014_booking_answers_question_cascade.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- booking_answers.question_id referenced event_type_questions(id) with no +-- ON DELETE rule, so deleting an intake question that already had responses +-- (or deleting an event type that owns such questions) failed with a foreign-key +-- violation surfaced as a 500. The SQLite migration recreates the table because it +-- cannot alter a constraint; Postgres replaces the constraint in place. +-- +-- booking_answers_question_id_fkey is the name Postgres gave the inline REFERENCES +-- in 00001: __fkey. A Postgres install can only have reached this +-- version through that migration, so the name is not a guess. +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id) ON DELETE CASCADE; + +-- +goose Down +ALTER TABLE booking_answers + DROP CONSTRAINT booking_answers_question_id_fkey, + ADD CONSTRAINT booking_answers_question_id_fkey + FOREIGN KEY (question_id) REFERENCES event_type_questions(id); diff --git a/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql new file mode 100644 index 0000000..29c5d09 --- /dev/null +++ b/internal/db/migrations/postgres/00015_calendar_connections_expiry.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE calendar_connections ADD COLUMN expiry_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql new file mode 100644 index 0000000..8eeb56b --- /dev/null +++ b/internal/db/migrations/postgres/00016_event_types_max_active_bookings.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Cap how many active (upcoming, non-cancelled) bookings a single invitee may +-- hold for an event type, keyed by their email. 1 = one at a time (default); +-- 0 = unlimited. Existing rows adopt the default of 1. +ALTER TABLE event_types ADD COLUMN max_active_bookings INTEGER NOT NULL DEFAULT 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00017_crypto_keystore.sql b/internal/db/migrations/postgres/00017_crypto_keystore.sql new file mode 100644 index 0000000..d9253a0 --- /dev/null +++ b/internal/db/migrations/postgres/00017_crypto_keystore.sql @@ -0,0 +1,18 @@ +-- +goose Up +CREATE TABLE crypto_keystore ( + -- SQLite's INTEGER PRIMARY KEY is a rowid alias, and keyvault.go inserts + -- without an id and lets it be assigned. Identity reproduces that; BY DEFAULT + -- rather than ALWAYS so an explicit id still inserts (key recovery/rotation). + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + label TEXT NOT NULL UNIQUE, -- 'primary' | 'recovery' + wrapped_dek BYTEA NOT NULL, -- DEK encrypted under this entry's KEK + kdf TEXT NOT NULL, -- 'argon2id' + kdf_salt BYTEA NOT NULL, -- 16 random bytes + kdf_params TEXT NOT NULL, -- JSON: {"m":65536,"t":3,"p":2} + dek_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS crypto_keystore; diff --git a/internal/db/migrations/postgres/00018_user_roles.sql b/internal/db/migrations/postgres/00018_user_roles.sql new file mode 100644 index 0000000..5ec5ffa --- /dev/null +++ b/internal/db/migrations/postgres/00018_user_roles.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- Workspace roles are Member / Admin / Owner (PRD §8.10). is_admin already +-- distinguishes member vs admin; is_owner adds the single-owner tier on top. +-- Owner implies admin. Exactly one owner exists at any time (enforced in app). +ALTER TABLE users ADD COLUMN is_owner SMALLINT NOT NULL DEFAULT 0; + +-- Backfill: the bootstrap user (earliest created) becomes the owner on upgrade. +-- Fresh installs set is_owner at Setup time instead; this no-ops when empty. +UPDATE users SET is_owner = 1, is_admin = 1 +WHERE id = (SELECT id FROM users ORDER BY created_at ASC, id ASC LIMIT 1); + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00019_user_archived.sql b/internal/db/migrations/postgres/00019_user_archived.sql new file mode 100644 index 0000000..fea30d3 --- /dev/null +++ b/internal/db/migrations/postgres/00019_user_archived.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Member offboarding is archiving (soft-delete), not hard delete: the row and +-- all its links (past bookings, event types, team memberships) are preserved. +-- archived_at NULL = active; a timestamp = archived (login blocked, hidden from +-- default lists, skipped in routing). +ALTER TABLE users ADD COLUMN archived_at TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00020_user_archived_by.sql b/internal/db/migrations/postgres/00020_user_archived_by.sql new file mode 100644 index 0000000..733b5ae --- /dev/null +++ b/internal/db/migrations/postgres/00020_user_archived_by.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Track who archived a member so restore can be gated: the owner can restore +-- anyone; an admin can restore only members they archived themselves. +ALTER TABLE users ADD COLUMN archived_by TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00021_event_type_hosts.sql b/internal/db/migrations/postgres/00021_event_type_hosts.sql new file mode 100644 index 0000000..09123ea --- /dev/null +++ b/internal/db/migrations/postgres/00021_event_type_hosts.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Routing model: an event type owns a host list. Each host has a role — +-- required (always attends), rotation (one is picked per booking), or optional +-- (joins if free). The three UI modes (Normal/Round-robin/Group) are presets +-- over these roles. See docs/teams-and-routing.md. +CREATE TABLE event_type_hosts ( + id TEXT PRIMARY KEY, + event_type_id TEXT NOT NULL REFERENCES event_types(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'required' + CHECK (role IN ('required', 'rotation', 'optional')), + priority INTEGER NOT NULL DEFAULT 0, + UNIQUE(event_type_id, user_id) +); + +CREATE INDEX idx_event_type_hosts_event ON event_type_hosts(event_type_id); + +-- Round-robin selection strategy for round_robin event types. +ALTER TABLE event_types ADD COLUMN rr_strategy TEXT NOT NULL DEFAULT 'even' + CHECK (rr_strategy IN ('even', 'soonest', 'priority')); + +-- Backfill: every existing event type gets its owner as the single required +-- host, so today's solo events become Normal with the owner as the one host and +-- host resolution is uniform from day one. +-- +-- The id matches what SQLite's lower(hex(randomblob(16))) produces — 32 lowercase +-- hex characters — using gen_random_uuid, which is core since PostgreSQL 13 and so +-- needs no extension. +INSERT INTO event_type_hosts (id, event_type_id, user_id, role, priority) +SELECT replace(gen_random_uuid()::text, '-', ''), id, user_id, 'required', 0 FROM event_types; + +-- +goose Down +DROP TABLE IF EXISTS event_type_hosts; +-- rr_strategy is deliberately left in place, mirroring the SQLite migration: +-- Postgres could drop it, but a down that lands on a different schema per engine +-- is worse than one that leaves a harmless column behind. diff --git a/internal/db/migrations/postgres/00022_booking_hosts.sql b/internal/db/migrations/postgres/00022_booking_hosts.sql new file mode 100644 index 0000000..aef70bb --- /dev/null +++ b/internal/db/migrations/postgres/00022_booking_hosts.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- Multi-host bookings: a booking can have several hosts (Group/collective, or a +-- round-robin pick plus fixed hosts). bookings.host_id stays the *primary* host +-- (for the double-book guard + back-compat); booking_hosts records everyone who +-- attends. See docs/teams-and-routing.md. +CREATE TABLE booking_hosts ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id), + is_primary SMALLINT NOT NULL DEFAULT 0, + UNIQUE(booking_id, user_id) +); + +CREATE INDEX idx_booking_hosts_booking ON booking_hosts(booking_id); +CREATE INDEX idx_booking_hosts_user ON booking_hosts(user_id); + +-- Backfill: every existing booking gets its host as the single primary host row, +-- so host resolution is uniform from day one. The id matches the shape SQLite's +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO booking_hosts (id, booking_id, user_id, is_primary) +SELECT replace(gen_random_uuid()::text, '-', ''), id, host_id, 1 FROM bookings; + +-- +goose Down +DROP TABLE IF EXISTS booking_hosts; diff --git a/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql new file mode 100644 index 0000000..67a58fe --- /dev/null +++ b/internal/db/migrations/postgres/00023_booking_hosts_event_id.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Per-host external calendar event ID. Multi-host bookings (Group) create a +-- calendar event on each assigned host's calendar; we store each one here so it +-- can be moved/cancelled later. The primary host's id also lives in +-- bookings.external_event_id (kept for back-compat); this backfills its row. +ALTER TABLE booking_hosts ADD COLUMN external_event_id TEXT; + +UPDATE booking_hosts +SET external_event_id = ( + SELECT b.external_event_id FROM bookings b WHERE b.id = booking_hosts.booking_id +) +WHERE is_primary = 1; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00024_idempotency_keys.sql b/internal/db/migrations/postgres/00024_idempotency_keys.sql similarity index 100% rename from internal/db/migrations/00024_idempotency_keys.sql rename to internal/db/migrations/postgres/00024_idempotency_keys.sql diff --git a/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql new file mode 100644 index 0000000..53741e6 --- /dev/null +++ b/internal/db/migrations/postgres/00025_booking_hosts_needs_sync.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- needs_sync flags a booking_hosts row whose calendar event is known to be at the +-- WRONG time: set when an inline reschedule move (gcal UpdateEvent) fails, cleared +-- when it succeeds (inline or via the reconciler). The reconciler re-applies the +-- move for flagged rows — closing the one gap the presence/absence passes can't see, +-- since drift can't be inferred from booking state alone. 0 = in sync. +ALTER TABLE booking_hosts ADD COLUMN needs_sync SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE booking_hosts DROP COLUMN needs_sync; diff --git a/internal/db/migrations/00026_event_type_subjects.sql b/internal/db/migrations/postgres/00026_event_type_subjects.sql similarity index 100% rename from internal/db/migrations/00026_event_type_subjects.sql rename to internal/db/migrations/postgres/00026_event_type_subjects.sql diff --git a/internal/db/migrations/00027_webhook_fields.sql b/internal/db/migrations/postgres/00027_webhook_fields.sql similarity index 100% rename from internal/db/migrations/00027_webhook_fields.sql rename to internal/db/migrations/postgres/00027_webhook_fields.sql diff --git a/internal/db/migrations/postgres/00028_tracking_settings.sql b/internal/db/migrations/postgres/00028_tracking_settings.sql new file mode 100644 index 0000000..d7e469e --- /dev/null +++ b/internal/db/migrations/postgres/00028_tracking_settings.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Tracking / analytics settings (instance-wide, on the singleton row): +-- head_html raw HTML/JS injected into the of the public booking +-- and manage pages (GTM/GA4/Pixel snippets, etc.). +-- tracking_csp_allow optional space-separated CSP source allowlist; when set, the +-- relaxed public-page CSP is tightened to just these origins. +-- datalayer_enabled push booking/cancel/reschedule events into window.dataLayer. +-- datalayer_fields JSON array of field keys to include in those pushes. +ALTER TABLE server_settings ADD COLUMN head_html TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN tracking_csp_allow TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN datalayer_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN datalayer_fields TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN head_html; +ALTER TABLE server_settings DROP COLUMN tracking_csp_allow; +ALTER TABLE server_settings DROP COLUMN datalayer_enabled; +ALTER TABLE server_settings DROP COLUMN datalayer_fields; diff --git a/internal/db/migrations/00029_branding_settings.sql b/internal/db/migrations/postgres/00029_branding_settings.sql similarity index 100% rename from internal/db/migrations/00029_branding_settings.sql rename to internal/db/migrations/postgres/00029_branding_settings.sql diff --git a/internal/db/migrations/00030_logo_height.sql b/internal/db/migrations/postgres/00030_logo_height.sql similarity index 100% rename from internal/db/migrations/00030_logo_height.sql rename to internal/db/migrations/postgres/00030_logo_height.sql diff --git a/internal/db/migrations/00031_logo_opacity.sql b/internal/db/migrations/postgres/00031_logo_opacity.sql similarity index 100% rename from internal/db/migrations/00031_logo_opacity.sql rename to internal/db/migrations/postgres/00031_logo_opacity.sql diff --git a/internal/db/migrations/00032_calendar_account_kind.sql b/internal/db/migrations/postgres/00032_calendar_account_kind.sql similarity index 100% rename from internal/db/migrations/00032_calendar_account_kind.sql rename to internal/db/migrations/postgres/00032_calendar_account_kind.sql diff --git a/internal/db/migrations/00033_oauth_mcp.sql b/internal/db/migrations/postgres/00033_oauth_mcp.sql similarity index 100% rename from internal/db/migrations/00033_oauth_mcp.sql rename to internal/db/migrations/postgres/00033_oauth_mcp.sql diff --git a/internal/db/migrations/postgres/00034_llm_settings.sql b/internal/db/migrations/postgres/00034_llm_settings.sql new file mode 100644 index 0000000..443df0a --- /dev/null +++ b/internal/db/migrations/postgres/00034_llm_settings.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Optional LLM layer config (PRD §8.11), stored like SMTP/Google settings on the +-- single server_settings row. Provider-agnostic: any OpenAI-compatible chat-completions +-- endpoint. Off by default; api key encrypted at rest (CALNODE_ENCRYPTION_KEY). +ALTER TABLE server_settings ADD COLUMN llm_endpoint TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_model TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_api_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN llm_enabled SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_enabled; +ALTER TABLE server_settings DROP COLUMN llm_api_key_enc; +ALTER TABLE server_settings DROP COLUMN llm_model; +ALTER TABLE server_settings DROP COLUMN llm_endpoint; diff --git a/internal/db/migrations/00035_llm_instructions.sql b/internal/db/migrations/postgres/00035_llm_instructions.sql similarity index 100% rename from internal/db/migrations/00035_llm_instructions.sql rename to internal/db/migrations/postgres/00035_llm_instructions.sql diff --git a/internal/db/migrations/postgres/00036_zoom_integration.sql b/internal/db/migrations/postgres/00036_zoom_integration.sql new file mode 100644 index 0000000..82e790e --- /dev/null +++ b/internal/db/migrations/postgres/00036_zoom_integration.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Zoom OAuth app credentials (one app per instance; each host then connects their +-- own Zoom account via OAuth). +ALTER TABLE server_settings ADD COLUMN zoom_client_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN zoom_client_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-host Zoom OAuth tokens. Zoom is a meeting-link provider, not a calendar, so it gets +-- its own table (calendar_connections.provider has a CHECK constraint for calendar kinds). +-- One connection per user (user_id PK). +CREATE TABLE zoom_connections ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_token_enc TEXT NOT NULL, + refresh_token_enc TEXT NOT NULL DEFAULT '', + expiry_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- The Zoom meeting id minted for a booking (used to update/delete the meeting on +-- reschedule/cancel). Empty for non-Zoom bookings or manual links. +ALTER TABLE bookings ADD COLUMN zoom_meeting_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00037_stripe_payments.sql b/internal/db/migrations/postgres/00037_stripe_payments.sql new file mode 100644 index 0000000..533473d --- /dev/null +++ b/internal/db/migrations/postgres/00037_stripe_payments.sql @@ -0,0 +1,26 @@ +-- +goose Up +-- Stripe API credentials (one Stripe account per instance; admin configures in Settings). +ALTER TABLE server_settings ADD COLUMN stripe_secret_key_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_publishable_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stripe_webhook_secret_enc TEXT NOT NULL DEFAULT ''; + +-- Per-event-type price. 0 = free (the default; today's flow is unchanged). +ALTER TABLE event_types ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE event_types ADD COLUMN currency TEXT NOT NULL DEFAULT 'usd'; + +-- Payment state, separate from booking status so a paid hold can occupy the slot +-- (status='confirmed', so the double-booking guard reserves it) while still awaiting +-- payment (payment_status='pending'); side-effects are deferred until 'paid'. A new +-- booking-status value would have required a SQLite table rebuild (CHECK constraint). +-- none → free booking, no payment involved (default) +-- pending → awaiting Stripe Checkout completion (slot held) +-- paid → payment captured; confirmation side-effects have run +-- refunded → payment refunded (on cancel) +ALTER TABLE bookings ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'none'; +ALTER TABLE bookings ADD COLUMN stripe_session_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN stripe_payment_intent_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00038_booking_amount_paid.sql b/internal/db/migrations/postgres/00038_booking_amount_paid.sql new file mode 100644 index 0000000..1201282 --- /dev/null +++ b/internal/db/migrations/postgres/00038_booking_amount_paid.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Record what was actually charged on the booking (immutable payment record), independent +-- of the event type's current price_cents which can change later. Set from the Stripe +-- Checkout session's amount_total/currency at confirmation. 0/'' for free bookings. +ALTER TABLE bookings ADD COLUMN amount_paid_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE bookings ADD COLUMN amount_paid_currency TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00039_calendar_account_email.sql b/internal/db/migrations/postgres/00039_calendar_account_email.sql new file mode 100644 index 0000000..714719c --- /dev/null +++ b/internal/db/migrations/postgres/00039_calendar_account_email.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Identify each connected calendar account so a user can connect several (e.g. work Google +-- + personal Gmail): re-auth of the same account upserts its row, a new account inserts a new +-- row. Used for dedup + display. Existing single connections backfill to '' and keep working +-- (free/busy doesn't need the email); they get a real value on next re-connect. +ALTER TABLE calendar_connections ADD COLUMN account_email TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00040_magic_link_tokens.sql b/internal/db/migrations/postgres/00040_magic_link_tokens.sql new file mode 100644 index 0000000..b871afc --- /dev/null +++ b/internal/db/migrations/postgres/00040_magic_link_tokens.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- One-time, short-lived login links emailed to a user. We store only the SHA-256 of the +-- token (never the raw value); single-use is enforced by used_at. +CREATE TABLE magic_link_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')) +); + +-- +goose Down +DROP TABLE magic_link_tokens; diff --git a/internal/db/migrations/postgres/00041_native_analytics.sql b/internal/db/migrations/postgres/00041_native_analytics.sql new file mode 100644 index 0000000..e8ba9fd --- /dev/null +++ b/internal/db/migrations/postgres/00041_native_analytics.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Native GA4 / GTM: store just the ID; the booking page renders the official loader snippet +-- (no need to paste the whole script). Empty = that tag is off. Validated to the ID format on +-- write so the value is safe to interpolate into a script. +ALTER TABLE server_settings ADD COLUMN gtm_container_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN ga4_measurement_id TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00042_livekit.sql b/internal/db/migrations/postgres/00042_livekit.sql new file mode 100644 index 0000000..bb5d4b4 --- /dev/null +++ b/internal/db/migrations/postgres/00042_livekit.sql @@ -0,0 +1,26 @@ +-- LiveKit (self-hostable WebRTC video) as a built-in meeting location. +-- +-- Two parts: +-- 1. Instance-level config columns (server URL + API key/secret, secret encrypted) on +-- server_settings, plus a livekit_room column on bookings — like Zoom/Stripe. +-- 2. Widen the event_types.location_type CHECK to allow 'livekit'. +-- +-- The SQLite migration rebuilds event_types for part 2, and needs NO TRANSACTION plus +-- PRAGMA foreign_keys=OFF to do it without cascade-deleting the child rows. Postgres +-- replaces the constraint in place, so none of that applies: this file runs in goose's +-- transaction like every other one. event_types_location_type_check is the name Postgres +-- gave the inline CHECK in 00001 (
__check). + +-- +goose Up +ALTER TABLE server_settings ADD COLUMN livekit_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN livekit_api_secret_enc TEXT NOT NULL DEFAULT ''; +ALTER TABLE bookings ADD COLUMN livekit_room TEXT NOT NULL DEFAULT ''; + +ALTER TABLE event_types + DROP CONSTRAINT event_types_location_type_check, + ADD CONSTRAINT event_types_location_type_check + CHECK (location_type IN ('zoom','google_meet','teams','custom_video','phone','in_person','link','livekit')); + +-- +goose Down +-- Irreversible widening of a CHECK; the added columns are harmless. No-op down. diff --git a/internal/db/migrations/postgres/00043_livekit_recording.sql b/internal/db/migrations/postgres/00043_livekit_recording.sql new file mode 100644 index 0000000..07a6edb --- /dev/null +++ b/internal/db/migrations/postgres/00043_livekit_recording.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- Meeting recording (LiveKit Egress). Off unless enabled; recordings upload to the same +-- S3 bucket Litestream backs up to (LITESTREAM_* env), under a recordings/ prefix. +ALTER TABLE server_settings ADD COLUMN recordings_enabled SMALLINT NOT NULL DEFAULT 0; + +CREATE TABLE recordings ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- derived from room "booking-"; nullable + room TEXT NOT NULL, + egress_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', -- active | complete | failed + object_key TEXT NOT NULL DEFAULT '', -- S3 key of the finished file + duration_s INTEGER NOT NULL DEFAULT 0, + started_by TEXT NOT NULL DEFAULT '', -- host participant identity + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_recordings_room ON recordings(room); +CREATE INDEX idx_recordings_egress ON recordings(egress_id); + +-- +goose Down +-- Leave the column; drop the table. +DROP TABLE IF EXISTS recordings; diff --git a/internal/db/migrations/postgres/00044_meeting_consents.sql b/internal/db/migrations/postgres/00044_meeting_consents.sql new file mode 100644 index 0000000..5818d0d --- /dev/null +++ b/internal/db/migrations/postgres/00044_meeting_consents.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- In-meeting recording consent — notice + consent-or-leave (Zoom/Teams/Meet model). This is an +-- AUDIT LOG of who acknowledged the recording notice; it does NOT gate recording (recording +-- starts on the host's click). One row per participant identity per room. +CREATE TABLE meeting_consents ( + room TEXT NOT NULL, + participant_identity TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'continue', -- continue | leave + decided_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + PRIMARY KEY (room, participant_identity) +); + +-- +goose Down +DROP TABLE IF EXISTS meeting_consents; diff --git a/internal/db/migrations/postgres/00045_notetaker.sql b/internal/db/migrations/postgres/00045_notetaker.sql new file mode 100644 index 0000000..45cd309 --- /dev/null +++ b/internal/db/migrations/postgres/00045_notetaker.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Notetaker: transcribe finished recordings (Deepgram) and summarise them (the BYO-LLM layer) +-- into notes attached to the booking. Off unless enabled + a Deepgram key is set. +ALTER TABLE server_settings ADD COLUMN notetaker_enabled SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE server_settings ADD COLUMN stt_api_key_enc TEXT NOT NULL DEFAULT ''; -- Deepgram key (encrypted) + +CREATE TABLE transcripts ( + id TEXT PRIMARY KEY, + booking_id TEXT, -- nullable; grouping key + recording_id TEXT NOT NULL, -- one transcript per recording + room TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + segments TEXT NOT NULL DEFAULT '[]', -- JSON [{speaker,start,end,text}] + status TEXT NOT NULL DEFAULT 'complete', -- complete | failed + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE INDEX idx_transcripts_booking ON transcripts(booking_id); +CREATE INDEX idx_transcripts_recording ON transcripts(recording_id); + +CREATE TABLE notes ( + id TEXT PRIMARY KEY, + booking_id TEXT NOT NULL, -- one notes doc per booking (regenerable) + content TEXT NOT NULL DEFAULT '', -- markdown summary + model TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'complete', + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + updated_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) +); +CREATE UNIQUE INDEX idx_notes_booking ON notes(booking_id); + +-- +goose Down +DROP TABLE IF EXISTS notes; +DROP TABLE IF EXISTS transcripts; diff --git a/internal/db/migrations/00046_legal_links.sql b/internal/db/migrations/postgres/00046_legal_links.sql similarity index 100% rename from internal/db/migrations/00046_legal_links.sql rename to internal/db/migrations/postgres/00046_legal_links.sql diff --git a/internal/db/migrations/00047_event_type_archived.sql b/internal/db/migrations/postgres/00047_event_type_archived.sql similarity index 100% rename from internal/db/migrations/00047_event_type_archived.sql rename to internal/db/migrations/postgres/00047_event_type_archived.sql diff --git a/internal/db/migrations/00048_override_group.sql b/internal/db/migrations/postgres/00048_override_group.sql similarity index 100% rename from internal/db/migrations/00048_override_group.sql rename to internal/db/migrations/postgres/00048_override_group.sql diff --git a/internal/db/migrations/postgres/00049_connection_calendars.sql b/internal/db/migrations/postgres/00049_connection_calendars.sql new file mode 100644 index 0000000..394922e --- /dev/null +++ b/internal/db/migrations/postgres/00049_connection_calendars.sql @@ -0,0 +1,38 @@ +-- +goose Up +-- Per-account calendar selection. A single connected account (calendar_connections, +-- keyed by user_id+provider+account_email) can now expose several calendars, each +-- independently included in free/busy conflict checks and optionally the write target. +-- +-- Deliberately keyed by the STABLE account identity (user_id, provider, account_email), +-- NOT by calendar_connections.id: the connection row is deleted+reinserted (new id) on +-- every OAuth token refresh, so an FK to it would cascade-delete a user's calendar +-- selections on the next hourly refresh. Disconnect flows delete these rows explicitly. +CREATE TABLE IF NOT EXISTS connection_calendars ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + account_email TEXT NOT NULL DEFAULT '', + calendar_id TEXT NOT NULL, -- provider's calendar id ("primary", an address, a URL) + name TEXT NOT NULL DEFAULT '', -- display name (filled from the provider's calendar list) + check_conflicts SMALLINT NOT NULL DEFAULT 1, -- include this calendar in free/busy + is_destination SMALLINT NOT NULL DEFAULT 0, -- write new booking events here (at most one per user) + created_at TEXT NOT NULL DEFAULT (to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), + UNIQUE (user_id, provider, account_email, calendar_id) +); + +CREATE INDEX IF NOT EXISTS idx_connection_calendars_account + ON connection_calendars (user_id, provider, account_email); + +-- Seed from existing connections so current behaviour is preserved on upgrade: each +-- connected account's single calendar becomes one selected calendar with the same flags. +-- ON CONFLICT DO NOTHING is SQLite's INSERT OR IGNORE; the id matches the shape +-- lower(hex(randomblob(16))) produces (32 lowercase hex characters). +INSERT INTO connection_calendars + (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) +SELECT replace(gen_random_uuid()::text, '-', ''), user_id, provider, COALESCE(account_email, ''), + calendar_id, '', check_conflicts, is_destination +FROM calendar_connections +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TABLE IF EXISTS connection_calendars; diff --git a/internal/db/migrations/00050_branding_banner.sql b/internal/db/migrations/postgres/00050_branding_banner.sql similarity index 100% rename from internal/db/migrations/00050_branding_banner.sql rename to internal/db/migrations/postgres/00050_branding_banner.sql diff --git a/internal/db/migrations/postgres/00051_booking_attendee_locale.sql b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql new file mode 100644 index 0000000..fc7492d --- /dev/null +++ b/internal/db/migrations/postgres/00051_booking_attendee_locale.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Captures the attendee's resolved page locale at booking time (mirrors iana_timezone) — +-- this can only be captured now, not reconstructed later, since it needs the actual +-- Accept-Language/cookie/lang= state the visitor saw when they booked. Not yet consumed by +-- emails (mailer has no i18n support yet — see internal-docs/i18n-plan.md); this just +-- captures the data so it exists once that work lands. Existing rows backfill to 'en'. +ALTER TABLE booking_attendees ADD COLUMN locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00052_msg_greeting.sql b/internal/db/migrations/postgres/00052_msg_greeting.sql new file mode 100644 index 0000000..35f76fa --- /dev/null +++ b/internal/db/migrations/postgres/00052_msg_greeting.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- Per-event-type override for the conversational assistant's opening greeting. Unlike +-- msg_confirmation/msg_cancellation/etc. (seeded with an English default at CreateEventType), +-- this is left NULL by default: the assistant falls back to the locale-keyed +-- "assistant_greeting" translation when unset, so translation keeps working automatically +-- for anyone who doesn't touch it. Only set (admin-authored, untranslated) once an operator +-- explicitly customizes it. +ALTER TABLE event_types ADD COLUMN msg_greeting TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00053_fallback_locale.sql b/internal/db/migrations/postgres/00053_fallback_locale.sql new file mode 100644 index 0000000..c7caff0 --- /dev/null +++ b/internal/db/migrations/postgres/00053_fallback_locale.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- What a visitor sees when their browser doesn't ask for any locale Calnode supports +-- (default English) — e.g. an operator serving a mostly Spanish-speaking customer base +-- might want Spanish instead. See internal/i18n.ResolveWithFallback. +ALTER TABLE server_settings ADD COLUMN fallback_locale TEXT NOT NULL DEFAULT 'en'; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00054_resend_api_key.sql b/internal/db/migrations/postgres/00054_resend_api_key.sql new file mode 100644 index 0000000..6c2852c --- /dev/null +++ b/internal/db/migrations/postgres/00054_resend_api_key.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- An optional Resend API key, so Calnode can deliver over Resend's HTTPS API instead of +-- SMTP. Needed because several hosting platforms (Railway below Pro, among others) block +-- outbound SMTP on their cheaper plans by dropping the packets, which is indistinguishable +-- from a misconfiguration and cannot be worked around at the SMTP layer at all. +-- +-- Encrypted at rest with the same envelope scheme as smtp_pass_enc; never returned by the +-- API, which exposes only a resend_api_key_set boolean. +-- +-- Presence of this key is what selects the transport: set means use the HTTPS API, empty +-- means fall back to SMTP. That is deliberate over probing SMTP at startup and switching +-- automatically - a probe tests reachability at boot, not at send time, and a working TCP +-- connection is not the same thing as a working delivery path. See internal/mailer/resend.go. +ALTER TABLE server_settings ADD COLUMN resend_api_key_enc TEXT NOT NULL DEFAULT ''; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql new file mode 100644 index 0000000..a7d2709 --- /dev/null +++ b/internal/db/migrations/postgres/00055_booking_hosts_calendar_id.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Which calendar a booking's event was actually written into. +-- +-- Until now only external_event_id was stored, and reschedule/cancel re-resolved the +-- target calendar from the host's CURRENT destination. That was harmless while the +-- destination was effectively fixed at the account's default calendar, but now that a host +-- can pick any calendar inside a connected account, changing that choice would leave every +-- existing booking's event id pointing at a calendar it does not live in: the update or +-- delete resolves to the new calendar, the provider returns 404, the booking cancels in +-- Calnode, and the meeting silently stays on the host's calendar forever. +-- +-- Deliberately nullable with no backfill. Empty means "resolve the way we always did", +-- which is exactly right for rows created before this column existed - their events do live +-- in whatever the destination was and still is. Only new bookings record the calendar, so +-- the guarantee starts applying from here without rewriting history we cannot verify. +ALTER TABLE booking_hosts ADD COLUMN external_calendar_id TEXT; + +-- +goose Down +-- Deliberately a no-op, mirroring the SQLite migration: Postgres could drop the +-- column, but a down that lands on a different schema per engine is worse than a +-- down that lands on none. diff --git a/internal/db/migrations/00056_bookings_list_indexes.sql b/internal/db/migrations/postgres/00056_bookings_list_indexes.sql similarity index 100% rename from internal/db/migrations/00056_bookings_list_indexes.sql rename to internal/db/migrations/postgres/00056_bookings_list_indexes.sql diff --git a/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql new file mode 100644 index 0000000..6c88e60 --- /dev/null +++ b/internal/db/migrations/postgres/00057_event_type_show_taken_slots.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Whether the booking page shows already-booked times greyed out instead of hiding +-- them. Requested in discussion #14, tracked as issue #19. +-- +-- Default 0, and that default is the point rather than caution. The slots endpoint is +-- public and unauthenticated, so turning this on makes the host's booked hours legible +-- to anyone with the link. That is a fair trade for a public-hours use case (an intro +-- call, a clinic, a tutor), where a visibly busy calendar communicates demand. It is a +-- privacy regression for an instance fronting a team's internal calendars, which is why +-- it must be chosen per event type and never inherited by surprise. +-- +-- Only starts a booking or calendar conflict removed are shown. Times outside the +-- host's working hours are never rendered, so the shape of the working day is not +-- disclosed by the grid itself. +ALTER TABLE event_types ADD COLUMN show_taken_slots SMALLINT NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN show_taken_slots; diff --git a/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..2892396 --- /dev/null +++ b/internal/db/migrations/postgres/00058_webhook_delivery_created_at.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression, matching +-- the SQLite half (whose ALTER TABLE ADD COLUMN forbids a parenthesised DEFAULT) so the +-- two engines backfill existing rows identically. New rows get their value bound by the +-- writer (internal/webhook). Rows that predate this migration keep '', which sorts last +-- under ORDER BY created_at DESC — correct, since they are the oldest deliveries on the +-- instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/postgres/00059_text_timestamp_collation.sql b/internal/db/migrations/postgres/00059_text_timestamp_collation.sql new file mode 100644 index 0000000..2b08c0e --- /dev/null +++ b/internal/db/migrations/postgres/00059_text_timestamp_collation.sql @@ -0,0 +1,203 @@ +-- +goose Up +-- Timestamps in Calnode are TEXT and are compared lexicographically on purpose: +-- the job queue claims with `run_at <= ?`, the recordings consent window is +-- `decided_at BETWEEN`, booking overlap is `start_at`/`end_at` against bound +-- strings, sessions and tokens expire on `expires_at > ?`, and several lists are +-- `ORDER BY created_at`. On SQLite that comparison is a byte comparison, always. +-- On PostgreSQL it is a comparison under the column's collation, which by default +-- is the database's — en_US.utf8 on a typical installation, i.e. a linguistic +-- collation that ignores punctuation and spaces at the primary level and orders +-- case at the tertiary one. +-- +-- The schema stores two timestamp layouts on purpose (internal/dbtime: a +-- space-separated `2026-01-01 10:00:00` and a `2026-01-01T10:00:00.000Z`), and a +-- linguistic collation makes no promise about how those two shapes interleave, +-- nor about a shape some future writer or import adds. Pinning the columns to +-- COLLATE "C" makes the ordering byte ordering on both engines, so a predicate +-- proved correct on SQLite means the same thing on PostgreSQL, and it does so in +-- the schema rather than in 40-odd queries that would each have to remember a +-- COLLATE clause. +-- +-- Scope: every TEXT column the tree compares or orders as a time. That is every +-- column named *_at plus jobs.locked_until, and also the four HH:MM +-- availability columns and availability_overrides.date, which are ordered as +-- times too (`ORDER BY day_of_week, start_time`, `ORDER BY date`). 54 columns +-- across 27 tables. internal/db/collation_test.go enumerates the migrated schema +-- and fails if a matching column is not C, so a timestamp column added later +-- cannot quietly miss this. +-- +-- Cost: ALTER COLUMN TYPE takes ACCESS EXCLUSIVE on the table and rebuilds its +-- indexes. Calnode instances are small and this runs once, at the startup that +-- picks the migration up, but it is not a zero-downtime change on a large +-- database. Grouped one statement per table so each is rewritten once. +-- +-- COLLATE "C" is a deterministic collation, so equality keeps comparing bytes and +-- every existing unique index (booking_manage_tokens' hashed PK, the partial +-- idx_bookings_no_double) keeps its exact current meaning. + +ALTER TABLE api_keys + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_used_at TYPE TEXT COLLATE "C"; +ALTER TABLE availability_overrides + ALTER COLUMN date TYPE TEXT COLLATE "C", + ALTER COLUMN end_time TYPE TEXT COLLATE "C", + ALTER COLUMN start_time TYPE TEXT COLLATE "C"; +ALTER TABLE availability_rules + ALTER COLUMN end_time TYPE TEXT COLLATE "C", + ALTER COLUMN start_time TYPE TEXT COLLATE "C"; +ALTER TABLE booking_manage_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE bookings + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN end_at TYPE TEXT COLLATE "C", + ALTER COLUMN start_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE calendar_connections + ALTER COLUMN channel_expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expiry_at TYPE TEXT COLLATE "C"; +ALTER TABLE connection_calendars + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE crypto_keystore + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE event_types + ALTER COLUMN archived_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE idempotency_keys + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE invite_tokens + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN used_at TYPE TEXT COLLATE "C"; +ALTER TABLE jobs + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN locked_until TYPE TEXT COLLATE "C", + ALTER COLUMN run_at TYPE TEXT COLLATE "C"; +ALTER TABLE magic_link_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN used_at TYPE TEXT COLLATE "C"; +ALTER TABLE meeting_consents + ALTER COLUMN decided_at TYPE TEXT COLLATE "C"; +ALTER TABLE notes + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_access_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_used_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_auth_codes + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE oauth_clients + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE recordings + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE server_settings + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE sessions + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expires_at TYPE TEXT COLLATE "C"; +ALTER TABLE teams + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE transcripts + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN updated_at TYPE TEXT COLLATE "C"; +ALTER TABLE users + ALTER COLUMN archived_at TYPE TEXT COLLATE "C", + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE webhook_deliveries + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN last_attempted_at TYPE TEXT COLLATE "C"; +ALTER TABLE webhooks + ALTER COLUMN created_at TYPE TEXT COLLATE "C"; +ALTER TABLE zoom_connections + ALTER COLUMN created_at TYPE TEXT COLLATE "C", + ALTER COLUMN expiry_at TYPE TEXT COLLATE "C"; + +-- +goose Down +-- Back to the database default collation. pg_catalog."default" is the spelling for +-- "whatever the database was created with"; there is no way to say "unset". +ALTER TABLE api_keys + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE availability_overrides + ALTER COLUMN date TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN end_time TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_time TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE availability_rules + ALTER COLUMN end_time TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_time TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE booking_manage_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE bookings + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN end_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN start_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE calendar_connections + ALTER COLUMN channel_expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expiry_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE connection_calendars + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE crypto_keystore + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE event_types + ALTER COLUMN archived_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE idempotency_keys + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE invite_tokens + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE jobs + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN locked_until TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN run_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE magic_link_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE meeting_consents + ALTER COLUMN decided_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE notes + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_access_tokens + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_used_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_auth_codes + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE oauth_clients + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE recordings + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE server_settings + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE sessions + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expires_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE teams + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE transcripts + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN updated_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE users + ALTER COLUMN archived_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE webhook_deliveries + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN last_attempted_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE webhooks + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default"; +ALTER TABLE zoom_connections + ALTER COLUMN created_at TYPE TEXT COLLATE pg_catalog."default", + ALTER COLUMN expiry_at TYPE TEXT COLLATE pg_catalog."default"; diff --git a/internal/db/migrations/postgres/00060_multi_tenant.sql b/internal/db/migrations/postgres/00060_multi_tenant.sql new file mode 100644 index 0000000..4692b46 --- /dev/null +++ b/internal/db/migrations/postgres/00060_multi_tenant.sql @@ -0,0 +1,322 @@ +-- +goose Up +-- +-- Multi-tenant mode: `workspaces` is the tenant root, every application table +-- gains a `workspace_id`, and a row-level-security policy per table makes the +-- isolation the database's job rather than the query author's. +-- +-- ┌── The table list ────────────────────────────────────────────────────────┐ +-- │ 32 TENANT tables (column + FK + policy), in the order they appear below: │ +-- │ api_keys, availability_overrides, availability_rules, booking_answers, │ +-- │ booking_attendees, booking_hosts, booking_manage_tokens, bookings, │ +-- │ calendar_connections, connection_calendars, event_type_hosts, │ +-- │ event_type_questions, event_type_reminders, event_types, │ +-- │ idempotency_keys, invite_tokens, jobs, magic_link_tokens, │ +-- │ meeting_consents, notes, oauth_access_tokens, oauth_auth_codes, │ +-- │ recordings, server_settings, sessions, team_members, teams, │ +-- │ transcripts, users, webhook_deliveries, webhooks, zoom_connections │ +-- │ │ +-- │ 4 EXEMPT tables (no workspace_id, no policy): │ +-- │ workspaces — the tenant root itself │ +-- │ crypto_keystore — one DEK per process (ARCHITECTURE §5) │ +-- │ goose_db_version — migration bookkeeping │ +-- │ oauth_clients — dynamic client registration is per client APP, │ +-- │ not per tenant: one Claude/ChatGPT connector │ +-- │ registration serves every workspace it is │ +-- │ authorised against. │ +-- └──────────────────────────────────────────────────────────────────────────┘ +-- +-- The same two lists live in Go as db.TenantTables / db.ExemptTables, and +-- TestPostgres_tenantTablesMatchSchema fails if either drifts from what this +-- file produced — so a table added by a later migration has to be classified, +-- and cannot be silently left unprotected. +-- +-- ⚠️ ENABLE / FORCE ROW LEVEL SECURITY is deliberately NOT here. It is applied +-- by db.EnableRLS at boot, and only when MULTI_TENANT is set. The reason is +-- measured, not stylistic: FORCE makes the policy apply to the table OWNER too, +-- and in single-tenant mode DATABASE_URL *is* the owner. A schema migrated with +-- FORCE and no `app.workspace_id` binding returns 0 rows to its owner for every +-- SELECT — verified against PostgreSQL 17.11 with a NOBYPASSRLS owner role. A +-- superuser DSN hides it completely (superusers bypass RLS), which is exactly +-- the kind of green that proves nothing. A policy on a table whose RLS is not +-- enabled is inert, also verified, so single-tenant behaviour is unchanged. + +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + public_host TEXT NOT NULL UNIQUE, + region TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')), + created_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') COLLATE "C", + updated_at TEXT NOT NULL DEFAULT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') COLLATE "C" +); + +-- The single-tenant workspace. It has to exist before the ALTERs below, because +-- each one backfills existing rows with 'default' and then checks the new +-- foreign key. public_host is empty on purpose: no HTTP request can carry an +-- empty Host, so the default workspace is unreachable by host resolution and a +-- multi-tenant instance cannot accidentally route to it. +INSERT INTO workspaces (id, slug, public_host, region, status) +VALUES ('default', 'default', '', '', 'active'); + +-- ── The column (D1) ─────────────────────────────────────────────────────────── +-- +-- COALESCE around current_setting, rather than the bare current_setting the +-- design note spells: the bare two-argument-less form RAISES on an unset +-- parameter, so it would fail every INSERT in single-tenant mode. With the +-- missing_ok form plus COALESCE the column defaults to 'default' when nothing is +-- bound, which is what single-tenant mode wants, and is never reached in +-- multi-tenant mode because the handle binds the parameter before every +-- statement. It also fails CLOSED if a multi-tenant statement ever escapes that +-- binding: the row would be written as 'default' and the policy's WITH CHECK +-- compares it against an unset parameter (NULL), which is not true, so the +-- INSERT is refused with SQLSTATE 42501 rather than landing in the wrong tenant. + +ALTER TABLE api_keys ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE availability_overrides ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE availability_rules ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE booking_answers ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE booking_attendees ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE booking_hosts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE booking_manage_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE bookings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE calendar_connections ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE connection_calendars ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE event_type_hosts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE event_type_questions ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE event_type_reminders ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE event_types ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE idempotency_keys ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE invite_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE jobs ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE magic_link_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE meeting_consents ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE notes ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE oauth_access_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE oauth_auth_codes ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE recordings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE server_settings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE sessions ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE team_members ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE teams ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE transcripts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE users ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE webhook_deliveries ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE webhooks ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; +ALTER TABLE zoom_connections ADD COLUMN workspace_id TEXT NOT NULL DEFAULT COALESCE(current_setting('app.workspace_id', true), 'default') REFERENCES workspaces(id) ON DELETE CASCADE; + +-- ── Uniqueness that was global becomes per workspace (D8, D9) ───────────────── +-- +-- What stays global, and why: api_keys.key_hash, sessions.id, +-- oauth_access_tokens.token_hash / refresh_hash, oauth_auth_codes.code_hash, +-- booking_manage_tokens.token_hash, magic_link_tokens.token_hash and +-- invite_tokens.token_hash are all CREDENTIALS. The tenant of a request that +-- carries one is resolved FROM it, so the lookup has to succeed before any +-- workspace is known — a composite key would make that lookup impossible. + +-- server_settings keeps its id = 1 singleton per workspace, so the ~40 +-- `WHERE id = 1` call sites need no edit: RLS narrows them to the tenant's row. +ALTER TABLE server_settings DROP CONSTRAINT server_settings_pkey; +ALTER TABLE server_settings ADD PRIMARY KEY (workspace_id, id); + +ALTER TABLE idempotency_keys DROP CONSTRAINT idempotency_keys_pkey; +ALTER TABLE idempotency_keys ADD PRIMARY KEY (workspace_id, idempotency_key); + +ALTER TABLE meeting_consents DROP CONSTRAINT meeting_consents_pkey; +ALTER TABLE meeting_consents ADD PRIMARY KEY (workspace_id, room, participant_identity); + +ALTER TABLE users DROP CONSTRAINT users_email_key; +ALTER TABLE users ADD CONSTRAINT users_workspace_id_email_key UNIQUE (workspace_id, email); + +ALTER TABLE event_types DROP CONSTRAINT event_types_slug_key; +ALTER TABLE event_types ADD CONSTRAINT event_types_workspace_id_slug_key UNIQUE (workspace_id, slug); + +ALTER TABLE teams DROP CONSTRAINT teams_slug_key; +ALTER TABLE teams ADD CONSTRAINT teams_workspace_id_slug_key UNIQUE (workspace_id, slug); + +DROP INDEX ux_jobs_type_payload; +CREATE UNIQUE INDEX ux_jobs_type_payload ON jobs (workspace_id, type, payload); + +DROP INDEX idx_notes_booking; +CREATE UNIQUE INDEX idx_notes_booking ON notes (workspace_id, booking_id); + +-- The partial indexes on bookings and jobs lead on workspace_id, because every +-- query that uses them now carries a workspace predicate from the policy. +DROP INDEX idx_bookings_no_double; +CREATE UNIQUE INDEX idx_bookings_no_double ON bookings (workspace_id, host_id, start_at) + WHERE status <> 'cancelled'; + +DROP INDEX idx_bookings_host_time; +CREATE INDEX idx_bookings_host_time ON bookings (workspace_id, host_id, start_at, end_at) + WHERE status = 'confirmed'; + +DROP INDEX idx_jobs_pending; +CREATE INDEX idx_jobs_pending ON jobs (workspace_id, run_at) WHERE status = 'pending'; + +DROP INDEX idx_jobs_running_expired; +CREATE INDEX idx_jobs_running_expired ON jobs (workspace_id, locked_until) WHERE status = 'running'; + +-- ⚠️ jobs is the one table worked ACROSS tenants: the worker's claim and its +-- crash-recovery reaper run on the platform handle with no workspace predicate +-- and order by run_at / locked_until globally. A workspace-leading index cannot +-- serve an ordered global scan, so the two originals are kept alongside under +-- _global names. Both pairs are tiny — jobs holds pending work, not history. +CREATE INDEX idx_jobs_pending_global ON jobs (run_at) WHERE status = 'pending'; +CREATE INDEX idx_jobs_running_expired_global ON jobs (locked_until) WHERE status = 'running'; + +-- ── The policies (D2) ───────────────────────────────────────────────────────── +-- +-- One permissive policy per tenant table, covering ALL commands. The +-- two-argument current_setting returns NULL for an unset parameter, so an +-- unbound session matches no row — reads return nothing and writes are refused +-- rather than defaulting to some tenant. The platform handle binds '' for the +-- same reason: no workspace id is the empty string, so '' matches nothing +-- either, and a stale value left on a pooled connection cannot leak into a +-- later statement because every statement sets the parameter itself. + +CREATE POLICY api_keys_tenant ON api_keys USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY availability_overrides_tenant ON availability_overrides USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY availability_rules_tenant ON availability_rules USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY booking_answers_tenant ON booking_answers USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY booking_attendees_tenant ON booking_attendees USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY booking_hosts_tenant ON booking_hosts USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY booking_manage_tokens_tenant ON booking_manage_tokens USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY bookings_tenant ON bookings USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY calendar_connections_tenant ON calendar_connections USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY connection_calendars_tenant ON connection_calendars USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY event_type_hosts_tenant ON event_type_hosts USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY event_type_questions_tenant ON event_type_questions USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY event_type_reminders_tenant ON event_type_reminders USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY event_types_tenant ON event_types USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY idempotency_keys_tenant ON idempotency_keys USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY invite_tokens_tenant ON invite_tokens USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY jobs_tenant ON jobs USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY magic_link_tokens_tenant ON magic_link_tokens USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY meeting_consents_tenant ON meeting_consents USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY notes_tenant ON notes USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY oauth_access_tokens_tenant ON oauth_access_tokens USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY oauth_auth_codes_tenant ON oauth_auth_codes USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY recordings_tenant ON recordings USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY server_settings_tenant ON server_settings USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY sessions_tenant ON sessions USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY team_members_tenant ON team_members USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY teams_tenant ON teams USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY transcripts_tenant ON transcripts USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY users_tenant ON users USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY webhook_deliveries_tenant ON webhook_deliveries USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY webhooks_tenant ON webhooks USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); +CREATE POLICY zoom_connections_tenant ON zoom_connections USING (workspace_id = current_setting('app.workspace_id', true)) WITH CHECK (workspace_id = current_setting('app.workspace_id', true)); + +-- workspaces itself is readable by the application role — h.publicURL(), the +-- suspended check and the host resolver all need it — and writable only by the +-- platform role, which owns the table and is therefore exempt from this policy. +CREATE POLICY workspaces_read ON workspaces FOR SELECT USING (true); + +-- +goose Down +DROP POLICY IF EXISTS workspaces_read ON workspaces; + +DROP POLICY IF EXISTS zoom_connections_tenant ON zoom_connections; +DROP POLICY IF EXISTS webhooks_tenant ON webhooks; +DROP POLICY IF EXISTS webhook_deliveries_tenant ON webhook_deliveries; +DROP POLICY IF EXISTS users_tenant ON users; +DROP POLICY IF EXISTS transcripts_tenant ON transcripts; +DROP POLICY IF EXISTS teams_tenant ON teams; +DROP POLICY IF EXISTS team_members_tenant ON team_members; +DROP POLICY IF EXISTS sessions_tenant ON sessions; +DROP POLICY IF EXISTS server_settings_tenant ON server_settings; +DROP POLICY IF EXISTS recordings_tenant ON recordings; +DROP POLICY IF EXISTS oauth_auth_codes_tenant ON oauth_auth_codes; +DROP POLICY IF EXISTS oauth_access_tokens_tenant ON oauth_access_tokens; +DROP POLICY IF EXISTS notes_tenant ON notes; +DROP POLICY IF EXISTS meeting_consents_tenant ON meeting_consents; +DROP POLICY IF EXISTS magic_link_tokens_tenant ON magic_link_tokens; +DROP POLICY IF EXISTS jobs_tenant ON jobs; +DROP POLICY IF EXISTS invite_tokens_tenant ON invite_tokens; +DROP POLICY IF EXISTS idempotency_keys_tenant ON idempotency_keys; +DROP POLICY IF EXISTS event_types_tenant ON event_types; +DROP POLICY IF EXISTS event_type_reminders_tenant ON event_type_reminders; +DROP POLICY IF EXISTS event_type_questions_tenant ON event_type_questions; +DROP POLICY IF EXISTS event_type_hosts_tenant ON event_type_hosts; +DROP POLICY IF EXISTS connection_calendars_tenant ON connection_calendars; +DROP POLICY IF EXISTS calendar_connections_tenant ON calendar_connections; +DROP POLICY IF EXISTS bookings_tenant ON bookings; +DROP POLICY IF EXISTS booking_manage_tokens_tenant ON booking_manage_tokens; +DROP POLICY IF EXISTS booking_hosts_tenant ON booking_hosts; +DROP POLICY IF EXISTS booking_attendees_tenant ON booking_attendees; +DROP POLICY IF EXISTS booking_answers_tenant ON booking_answers; +DROP POLICY IF EXISTS availability_rules_tenant ON availability_rules; +DROP POLICY IF EXISTS availability_overrides_tenant ON availability_overrides; +DROP POLICY IF EXISTS api_keys_tenant ON api_keys; + +DROP INDEX IF EXISTS idx_jobs_running_expired_global; +DROP INDEX IF EXISTS idx_jobs_pending_global; + +DROP INDEX idx_jobs_running_expired; +CREATE INDEX idx_jobs_running_expired ON jobs (locked_until) WHERE status = 'running'; + +DROP INDEX idx_jobs_pending; +CREATE INDEX idx_jobs_pending ON jobs (run_at) WHERE status = 'pending'; + +DROP INDEX idx_bookings_host_time; +CREATE INDEX idx_bookings_host_time ON bookings (host_id, start_at, end_at) WHERE status = 'confirmed'; + +DROP INDEX idx_bookings_no_double; +CREATE UNIQUE INDEX idx_bookings_no_double ON bookings (host_id, start_at) WHERE status <> 'cancelled'; + +DROP INDEX idx_notes_booking; +CREATE UNIQUE INDEX idx_notes_booking ON notes (booking_id); + +DROP INDEX ux_jobs_type_payload; +CREATE UNIQUE INDEX ux_jobs_type_payload ON jobs (type, payload); + +ALTER TABLE teams DROP CONSTRAINT teams_workspace_id_slug_key; +ALTER TABLE teams ADD CONSTRAINT teams_slug_key UNIQUE (slug); + +ALTER TABLE event_types DROP CONSTRAINT event_types_workspace_id_slug_key; +ALTER TABLE event_types ADD CONSTRAINT event_types_slug_key UNIQUE (slug); + +ALTER TABLE users DROP CONSTRAINT users_workspace_id_email_key; +ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email); + +ALTER TABLE meeting_consents DROP CONSTRAINT meeting_consents_pkey; +ALTER TABLE meeting_consents ADD PRIMARY KEY (room, participant_identity); + +ALTER TABLE idempotency_keys DROP CONSTRAINT idempotency_keys_pkey; +ALTER TABLE idempotency_keys ADD PRIMARY KEY (idempotency_key); + +ALTER TABLE server_settings DROP CONSTRAINT server_settings_pkey; +ALTER TABLE server_settings ADD PRIMARY KEY (id); + +ALTER TABLE zoom_connections DROP COLUMN workspace_id; +ALTER TABLE webhooks DROP COLUMN workspace_id; +ALTER TABLE webhook_deliveries DROP COLUMN workspace_id; +ALTER TABLE users DROP COLUMN workspace_id; +ALTER TABLE transcripts DROP COLUMN workspace_id; +ALTER TABLE teams DROP COLUMN workspace_id; +ALTER TABLE team_members DROP COLUMN workspace_id; +ALTER TABLE sessions DROP COLUMN workspace_id; +ALTER TABLE server_settings DROP COLUMN workspace_id; +ALTER TABLE recordings DROP COLUMN workspace_id; +ALTER TABLE oauth_auth_codes DROP COLUMN workspace_id; +ALTER TABLE oauth_access_tokens DROP COLUMN workspace_id; +ALTER TABLE notes DROP COLUMN workspace_id; +ALTER TABLE meeting_consents DROP COLUMN workspace_id; +ALTER TABLE magic_link_tokens DROP COLUMN workspace_id; +ALTER TABLE jobs DROP COLUMN workspace_id; +ALTER TABLE invite_tokens DROP COLUMN workspace_id; +ALTER TABLE idempotency_keys DROP COLUMN workspace_id; +ALTER TABLE event_types DROP COLUMN workspace_id; +ALTER TABLE event_type_reminders DROP COLUMN workspace_id; +ALTER TABLE event_type_questions DROP COLUMN workspace_id; +ALTER TABLE event_type_hosts DROP COLUMN workspace_id; +ALTER TABLE connection_calendars DROP COLUMN workspace_id; +ALTER TABLE calendar_connections DROP COLUMN workspace_id; +ALTER TABLE bookings DROP COLUMN workspace_id; +ALTER TABLE booking_manage_tokens DROP COLUMN workspace_id; +ALTER TABLE booking_hosts DROP COLUMN workspace_id; +ALTER TABLE booking_attendees DROP COLUMN workspace_id; +ALTER TABLE booking_answers DROP COLUMN workspace_id; +ALTER TABLE availability_rules DROP COLUMN workspace_id; +ALTER TABLE availability_overrides DROP COLUMN workspace_id; +ALTER TABLE api_keys DROP COLUMN workspace_id; + +DROP TABLE workspaces; diff --git a/internal/db/migrations/postgres/00061_sso_nonces.sql b/internal/db/migrations/postgres/00061_sso_nonces.sql new file mode 100644 index 0000000..3ca523a --- /dev/null +++ b/internal/db/migrations/postgres/00061_sso_nonces.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- Single-use identifiers (jti) from the signed SSO hand-off tokens. A token's jti is +-- claimed here before its session is created, so a replay of a still-valid token +-- collides on the primary key instead of being handed a second session. +-- +-- expires_at mirrors the token's own exp. The background worker purges rows past it in +-- the same GC pass that sweeps expired sessions and magic links, which is what keeps +-- this table the size of one token lifetime rather than of every sign-in ever made. +-- +-- Nothing here is engine-specific except the collation: the column types are the +-- portable TEXT the rest of the schema uses, and no DEFAULT is needed because the +-- writer binds both values. +-- +-- ⛔ expires_at is COLLATE "C" for the reason migration 00059 gives for the other 54 +-- TEXT timestamps: the worker's purge is `WHERE expires_at < ?`, a lexicographic +-- comparison, and under a non-C database collation the ordering of the two timestamp +-- shapes this schema stores is not guaranteed to be byte order. It is declared here +-- rather than added by a later ALTER because the table is new in this migration. +-- internal/db/collation_test.go audits it by column NAME, so it is enforced. +CREATE TABLE sso_nonces ( + jti TEXT PRIMARY KEY, + expires_at TEXT COLLATE "C" NOT NULL +); + +CREATE INDEX idx_sso_nonces_expires_at ON sso_nonces(expires_at); + +-- +goose Down +DROP INDEX IF EXISTS idx_sso_nonces_expires_at; +DROP TABLE sso_nonces; diff --git a/internal/db/migrations/postgres/00062_workspace_settings_columns.sql b/internal/db/migrations/postgres/00062_workspace_settings_columns.sql new file mode 100644 index 0000000..44e0ea8 --- /dev/null +++ b/internal/db/migrations/postgres/00062_workspace_settings_columns.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- Two per-workspace settings the platform API's `defaults` carries and server_settings +-- had nowhere to put. Both are per-TENANT facts on a multi-tenant instance, so the +-- environment cannot express them: +-- +-- embed_allowed_origins the origins allowed to embed this workspace's booking page. +-- One tenant's allowlist must not apply to another's embed, and +-- EMBED_ALLOWED_ORIGINS is one process-wide value. +-- stt_base_url the speech-to-text endpoint host for this workspace's +-- notetaker. It is a residency knob (transcribe inside one +-- jurisdiction), and residency is a property of the tenant. +-- +-- Both default to '' meaning "fall back to the process-wide value", so a single-tenant +-- instance and every existing row behave exactly as before. +-- +-- ⚠️ These columns are WRITTEN by the platform API and not yet READ: the embed CORS +-- check still uses config.EmbedAllowedOrigins and the notetaker still uses +-- config.STTBaseURL. Storing them first means a provisioned workspace does not silently +-- lose the values the caller sent; wiring the readers is a separate change, because each +-- has to go through the per-workspace settings cache (D7) rather than a process-wide +-- read. Until then a multi-tenant deployment shares one embed allowlist and one STT host. +ALTER TABLE server_settings ADD COLUMN embed_allowed_origins TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stt_base_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN stt_base_url; +ALTER TABLE server_settings DROP COLUMN embed_allowed_origins; diff --git a/internal/db/migrations/00001_initial_schema.sql b/internal/db/migrations/sqlite/00001_initial_schema.sql similarity index 100% rename from internal/db/migrations/00001_initial_schema.sql rename to internal/db/migrations/sqlite/00001_initial_schema.sql diff --git a/internal/db/migrations/00002_manage_tokens.sql b/internal/db/migrations/sqlite/00002_manage_tokens.sql similarity index 100% rename from internal/db/migrations/00002_manage_tokens.sql rename to internal/db/migrations/sqlite/00002_manage_tokens.sql diff --git a/internal/db/migrations/sqlite/00003_job_lock_timeout.sql b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql new file mode 100644 index 0000000..b78622d --- /dev/null +++ b/internal/db/migrations/sqlite/00003_job_lock_timeout.sql @@ -0,0 +1,14 @@ +-- +goose Up + +-- locked_until lets the worker reclaim jobs that were left in 'running' +-- state after a process crash. Set to now+N seconds on claim; the reaper +-- at the start of each Poll resets expired running jobs back to 'pending'. +ALTER TABLE jobs ADD COLUMN locked_until TEXT; + +CREATE INDEX IF NOT EXISTS idx_jobs_running_expired + ON jobs (locked_until) WHERE status = 'running'; + +-- +goose Down + +DROP INDEX IF EXISTS idx_jobs_running_expired; +ALTER TABLE jobs DROP COLUMN locked_until; diff --git a/internal/db/migrations/00004_sessions.sql b/internal/db/migrations/sqlite/00004_sessions.sql similarity index 100% rename from internal/db/migrations/00004_sessions.sql rename to internal/db/migrations/sqlite/00004_sessions.sql diff --git a/internal/db/migrations/sqlite/00005_override_unique.sql b/internal/db/migrations/sqlite/00005_override_unique.sql new file mode 100644 index 0000000..62ebd36 --- /dev/null +++ b/internal/db/migrations/sqlite/00005_override_unique.sql @@ -0,0 +1,6 @@ +-- +goose Up +CREATE UNIQUE INDEX IF NOT EXISTS idx_availability_overrides_user_date + ON availability_overrides (user_id, date); + +-- +goose Down +DROP INDEX IF EXISTS idx_availability_overrides_user_date; diff --git a/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql new file mode 100644 index 0000000..46a7ae2 --- /dev/null +++ b/internal/db/migrations/sqlite/00006_jobs_type_payload_unique.sql @@ -0,0 +1,9 @@ +-- +goose Up + +-- Prevent duplicate jobs of the same type+payload (e.g. two reminder.send jobs +-- for the same booking_id if enqueueReminder is called more than once). +CREATE UNIQUE INDEX IF NOT EXISTS ux_jobs_type_payload + ON jobs (type, payload); + +-- +goose Down +DROP INDEX IF EXISTS ux_jobs_type_payload; diff --git a/internal/db/migrations/00007_user_prefs.sql b/internal/db/migrations/sqlite/00007_user_prefs.sql similarity index 100% rename from internal/db/migrations/00007_user_prefs.sql rename to internal/db/migrations/sqlite/00007_user_prefs.sql diff --git a/internal/db/migrations/00008_date_format.sql b/internal/db/migrations/sqlite/00008_date_format.sql similarity index 100% rename from internal/db/migrations/00008_date_format.sql rename to internal/db/migrations/sqlite/00008_date_format.sql diff --git a/internal/db/migrations/00009_override_reason.sql b/internal/db/migrations/sqlite/00009_override_reason.sql similarity index 100% rename from internal/db/migrations/00009_override_reason.sql rename to internal/db/migrations/sqlite/00009_override_reason.sql diff --git a/internal/db/migrations/00010_messaging_prefs.sql b/internal/db/migrations/sqlite/00010_messaging_prefs.sql similarity index 100% rename from internal/db/migrations/00010_messaging_prefs.sql rename to internal/db/migrations/sqlite/00010_messaging_prefs.sql diff --git a/internal/db/migrations/00011_server_settings.sql b/internal/db/migrations/sqlite/00011_server_settings.sql similarity index 100% rename from internal/db/migrations/00011_server_settings.sql rename to internal/db/migrations/sqlite/00011_server_settings.sql diff --git a/internal/db/migrations/00012_auth_providers.sql b/internal/db/migrations/sqlite/00012_auth_providers.sql similarity index 100% rename from internal/db/migrations/00012_auth_providers.sql rename to internal/db/migrations/sqlite/00012_auth_providers.sql diff --git a/internal/db/migrations/00013_google_oauth_settings.sql b/internal/db/migrations/sqlite/00013_google_oauth_settings.sql similarity index 100% rename from internal/db/migrations/00013_google_oauth_settings.sql rename to internal/db/migrations/sqlite/00013_google_oauth_settings.sql diff --git a/internal/db/migrations/00014_booking_answers_question_cascade.sql b/internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql similarity index 100% rename from internal/db/migrations/00014_booking_answers_question_cascade.sql rename to internal/db/migrations/sqlite/00014_booking_answers_question_cascade.sql diff --git a/internal/db/migrations/00015_calendar_connections_expiry.sql b/internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql similarity index 100% rename from internal/db/migrations/00015_calendar_connections_expiry.sql rename to internal/db/migrations/sqlite/00015_calendar_connections_expiry.sql diff --git a/internal/db/migrations/00016_event_types_max_active_bookings.sql b/internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql similarity index 100% rename from internal/db/migrations/00016_event_types_max_active_bookings.sql rename to internal/db/migrations/sqlite/00016_event_types_max_active_bookings.sql diff --git a/internal/db/migrations/00017_crypto_keystore.sql b/internal/db/migrations/sqlite/00017_crypto_keystore.sql similarity index 100% rename from internal/db/migrations/00017_crypto_keystore.sql rename to internal/db/migrations/sqlite/00017_crypto_keystore.sql diff --git a/internal/db/migrations/00018_user_roles.sql b/internal/db/migrations/sqlite/00018_user_roles.sql similarity index 100% rename from internal/db/migrations/00018_user_roles.sql rename to internal/db/migrations/sqlite/00018_user_roles.sql diff --git a/internal/db/migrations/00019_user_archived.sql b/internal/db/migrations/sqlite/00019_user_archived.sql similarity index 100% rename from internal/db/migrations/00019_user_archived.sql rename to internal/db/migrations/sqlite/00019_user_archived.sql diff --git a/internal/db/migrations/00020_user_archived_by.sql b/internal/db/migrations/sqlite/00020_user_archived_by.sql similarity index 100% rename from internal/db/migrations/00020_user_archived_by.sql rename to internal/db/migrations/sqlite/00020_user_archived_by.sql diff --git a/internal/db/migrations/00021_event_type_hosts.sql b/internal/db/migrations/sqlite/00021_event_type_hosts.sql similarity index 100% rename from internal/db/migrations/00021_event_type_hosts.sql rename to internal/db/migrations/sqlite/00021_event_type_hosts.sql diff --git a/internal/db/migrations/00022_booking_hosts.sql b/internal/db/migrations/sqlite/00022_booking_hosts.sql similarity index 100% rename from internal/db/migrations/00022_booking_hosts.sql rename to internal/db/migrations/sqlite/00022_booking_hosts.sql diff --git a/internal/db/migrations/00023_booking_hosts_event_id.sql b/internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql similarity index 100% rename from internal/db/migrations/00023_booking_hosts_event_id.sql rename to internal/db/migrations/sqlite/00023_booking_hosts_event_id.sql diff --git a/internal/db/migrations/sqlite/00024_idempotency_keys.sql b/internal/db/migrations/sqlite/00024_idempotency_keys.sql new file mode 100644 index 0000000..42573e8 --- /dev/null +++ b/internal/db/migrations/sqlite/00024_idempotency_keys.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Idempotency keys for POST /v1/bookings. A client (e.g. an automation agent) +-- can safely retry a booking with the same Idempotency-Key header: the original +-- response is replayed verbatim instead of creating a duplicate booking. The key +-- is reserved (status_code NULL) while the original request runs; on success the +-- response is stored, on failure the row is released so a retry can proceed. +-- Rows are purged by the worker 24h after creation. +CREATE TABLE idempotency_keys ( + idempotency_key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + status_code INTEGER, -- NULL while the original request is in flight + response_body TEXT, + booking_id TEXT, + created_at TEXT NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS idempotency_keys; diff --git a/internal/db/migrations/00025_booking_hosts_needs_sync.sql b/internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql similarity index 100% rename from internal/db/migrations/00025_booking_hosts_needs_sync.sql rename to internal/db/migrations/sqlite/00025_booking_hosts_needs_sync.sql diff --git a/internal/db/migrations/sqlite/00026_event_type_subjects.sql b/internal/db/migrations/sqlite/00026_event_type_subjects.sql new file mode 100644 index 0000000..4e58851 --- /dev/null +++ b/internal/db/migrations/sqlite/00026_event_type_subjects.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- Per-event-type custom email subjects for the attendee-facing emails. NULL/empty +-- means "use the built-in subject" — so existing rows and new ones default to the +-- current behaviour. Mirrors the msg_* custom-note columns. +ALTER TABLE event_types ADD COLUMN subj_confirmation TEXT; +ALTER TABLE event_types ADD COLUMN subj_cancellation TEXT; +ALTER TABLE event_types ADD COLUMN subj_reschedule TEXT; +ALTER TABLE event_types ADD COLUMN subj_reminder TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN subj_confirmation; +ALTER TABLE event_types DROP COLUMN subj_cancellation; +ALTER TABLE event_types DROP COLUMN subj_reschedule; +ALTER TABLE event_types DROP COLUMN subj_reminder; diff --git a/internal/db/migrations/sqlite/00027_webhook_fields.sql b/internal/db/migrations/sqlite/00027_webhook_fields.sql new file mode 100644 index 0000000..01a7397 --- /dev/null +++ b/internal/db/migrations/sqlite/00027_webhook_fields.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Per-webhook payload field selection: a JSON array of field keys to include in +-- the delivery's "data" object. NULL means "the default set" — the original +-- booking-metadata payload — so existing webhooks keep their exact current shape +-- (and never start emitting attendee PII or answers without being reconfigured). +ALTER TABLE webhooks ADD COLUMN fields TEXT; + +-- +goose Down +ALTER TABLE webhooks DROP COLUMN fields; diff --git a/internal/db/migrations/00028_tracking_settings.sql b/internal/db/migrations/sqlite/00028_tracking_settings.sql similarity index 100% rename from internal/db/migrations/00028_tracking_settings.sql rename to internal/db/migrations/sqlite/00028_tracking_settings.sql diff --git a/internal/db/migrations/sqlite/00029_branding_settings.sql b/internal/db/migrations/sqlite/00029_branding_settings.sql new file mode 100644 index 0000000..3445c88 --- /dev/null +++ b/internal/db/migrations/sqlite/00029_branding_settings.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- Branding (instance-wide, on the singleton row): +-- business_name display name used as the wordmark in emails and on the public +-- booking/manage pages. Falls back to "Calnode" when empty. +-- logo_url absolute https URL to a logo image, shown in the email header +-- and the public page header. Empty = text wordmark only. +ALTER TABLE server_settings ADD COLUMN business_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN logo_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN business_name; +ALTER TABLE server_settings DROP COLUMN logo_url; diff --git a/internal/db/migrations/sqlite/00030_logo_height.sql b/internal/db/migrations/sqlite/00030_logo_height.sql new file mode 100644 index 0000000..326a38b --- /dev/null +++ b/internal/db/migrations/sqlite/00030_logo_height.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo display height in px (email header); public pages scale it up modestly. +-- Operator-adjustable via a 16–64px slider in Settings → Branding; 28 = sensible default. +ALTER TABLE server_settings ADD COLUMN logo_height INTEGER NOT NULL DEFAULT 28; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_height; diff --git a/internal/db/migrations/sqlite/00031_logo_opacity.sql b/internal/db/migrations/sqlite/00031_logo_opacity.sql new file mode 100644 index 0000000..6a7bdb8 --- /dev/null +++ b/internal/db/migrations/sqlite/00031_logo_opacity.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- Logo opacity as a percentage (20–100); lets operators make the logo subtle. +-- Applied as CSS opacity in emails + public pages. Default 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN logo_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN logo_opacity; diff --git a/internal/db/migrations/sqlite/00032_calendar_account_kind.sql b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql new file mode 100644 index 0000000..0a66861 --- /dev/null +++ b/internal/db/migrations/sqlite/00032_calendar_account_kind.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Records whether a connected Microsoft calendar is a work/school account or a +-- personal Microsoft account. Personal accounts can't mint Teams-for-Business +-- links, so this gates whether a "teams" event type can auto-generate one. +-- '' = unknown (legacy rows / Google) → treated as capable; real value is +-- captured from the id_token tenant claim on (re)connect. +ALTER TABLE calendar_connections ADD COLUMN account_kind TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE calendar_connections DROP COLUMN account_kind; diff --git a/internal/db/migrations/sqlite/00033_oauth_mcp.sql b/internal/db/migrations/sqlite/00033_oauth_mcp.sql new file mode 100644 index 0000000..a17e378 --- /dev/null +++ b/internal/db/migrations/sqlite/00033_oauth_mcp.sql @@ -0,0 +1,49 @@ +-- +goose Up +-- OAuth 2.1 authorization-server tables backing the MCP "Connect" flow (PRD §11 +-- authorization). Calnode is its own AS for the /mcp resource: clients self-register +-- (dynamic client registration, public PKCE clients), the workspace owner authorizes +-- via the existing session/Google/Microsoft login + a consent screen, and bearer +-- access tokens (hashed, like api_keys) gate /mcp. All token/code values are stored as +-- SHA-256 hashes — the plaintext is only ever returned once to the client. + +CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + client_name TEXT NOT NULL DEFAULT '', + redirect_uris TEXT NOT NULL, -- JSON array of allowed redirect URIs + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_auth_codes ( + code_hash TEXT PRIMARY KEY, -- SHA-256 of the authorization code + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, -- PKCE S256 challenge + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', -- RFC 8707 resource indicator + expires_at TEXT NOT NULL, -- short-lived (single use) + created_at TEXT NOT NULL +); + +CREATE TABLE oauth_access_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, -- SHA-256 of the access token + refresh_hash TEXT UNIQUE, -- SHA-256 of the refresh token (nullable) + client_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT '', + resource TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL, -- access-token expiry + created_at TEXT NOT NULL, + last_used_at TEXT +); + +CREATE INDEX idx_oauth_tokens_user ON oauth_access_tokens(user_id); +CREATE INDEX idx_oauth_tokens_refresh ON oauth_access_tokens(refresh_hash); + +-- +goose Down +DROP INDEX idx_oauth_tokens_refresh; +DROP INDEX idx_oauth_tokens_user; +DROP TABLE oauth_access_tokens; +DROP TABLE oauth_auth_codes; +DROP TABLE oauth_clients; diff --git a/internal/db/migrations/00034_llm_settings.sql b/internal/db/migrations/sqlite/00034_llm_settings.sql similarity index 100% rename from internal/db/migrations/00034_llm_settings.sql rename to internal/db/migrations/sqlite/00034_llm_settings.sql diff --git a/internal/db/migrations/sqlite/00035_llm_instructions.sql b/internal/db/migrations/sqlite/00035_llm_instructions.sql new file mode 100644 index 0000000..1d08451 --- /dev/null +++ b/internal/db/migrations/sqlite/00035_llm_instructions.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Admin "Additional instructions" appended to the assistant's base system prompt +-- (tone, business context, do's/don'ts). The base prompt — the tool-calling contract + +-- safety rails — stays in code; this is the customization layer only. +ALTER TABLE server_settings ADD COLUMN llm_extra_instructions TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN llm_extra_instructions; diff --git a/internal/db/migrations/00036_zoom_integration.sql b/internal/db/migrations/sqlite/00036_zoom_integration.sql similarity index 100% rename from internal/db/migrations/00036_zoom_integration.sql rename to internal/db/migrations/sqlite/00036_zoom_integration.sql diff --git a/internal/db/migrations/00037_stripe_payments.sql b/internal/db/migrations/sqlite/00037_stripe_payments.sql similarity index 100% rename from internal/db/migrations/00037_stripe_payments.sql rename to internal/db/migrations/sqlite/00037_stripe_payments.sql diff --git a/internal/db/migrations/00038_booking_amount_paid.sql b/internal/db/migrations/sqlite/00038_booking_amount_paid.sql similarity index 100% rename from internal/db/migrations/00038_booking_amount_paid.sql rename to internal/db/migrations/sqlite/00038_booking_amount_paid.sql diff --git a/internal/db/migrations/00039_calendar_account_email.sql b/internal/db/migrations/sqlite/00039_calendar_account_email.sql similarity index 100% rename from internal/db/migrations/00039_calendar_account_email.sql rename to internal/db/migrations/sqlite/00039_calendar_account_email.sql diff --git a/internal/db/migrations/00040_magic_link_tokens.sql b/internal/db/migrations/sqlite/00040_magic_link_tokens.sql similarity index 100% rename from internal/db/migrations/00040_magic_link_tokens.sql rename to internal/db/migrations/sqlite/00040_magic_link_tokens.sql diff --git a/internal/db/migrations/00041_native_analytics.sql b/internal/db/migrations/sqlite/00041_native_analytics.sql similarity index 100% rename from internal/db/migrations/00041_native_analytics.sql rename to internal/db/migrations/sqlite/00041_native_analytics.sql diff --git a/internal/db/migrations/00042_livekit.sql b/internal/db/migrations/sqlite/00042_livekit.sql similarity index 100% rename from internal/db/migrations/00042_livekit.sql rename to internal/db/migrations/sqlite/00042_livekit.sql diff --git a/internal/db/migrations/00043_livekit_recording.sql b/internal/db/migrations/sqlite/00043_livekit_recording.sql similarity index 100% rename from internal/db/migrations/00043_livekit_recording.sql rename to internal/db/migrations/sqlite/00043_livekit_recording.sql diff --git a/internal/db/migrations/00044_meeting_consents.sql b/internal/db/migrations/sqlite/00044_meeting_consents.sql similarity index 100% rename from internal/db/migrations/00044_meeting_consents.sql rename to internal/db/migrations/sqlite/00044_meeting_consents.sql diff --git a/internal/db/migrations/00045_notetaker.sql b/internal/db/migrations/sqlite/00045_notetaker.sql similarity index 100% rename from internal/db/migrations/00045_notetaker.sql rename to internal/db/migrations/sqlite/00045_notetaker.sql diff --git a/internal/db/migrations/sqlite/00046_legal_links.sql b/internal/db/migrations/sqlite/00046_legal_links.sql new file mode 100644 index 0000000..5fef0ab --- /dev/null +++ b/internal/db/migrations/sqlite/00046_legal_links.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Legal links (instance-wide, on the singleton settings row): absolute URLs to the +-- operator's own Privacy Policy and Terms. Shown as links in the public booking-page +-- footer and linked from the cookie-consent banner. Empty = the link is hidden. +-- The operator is the data controller; Calnode only surfaces the links they provide. +ALTER TABLE server_settings ADD COLUMN privacy_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN terms_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN privacy_url; +ALTER TABLE server_settings DROP COLUMN terms_url; diff --git a/internal/db/migrations/sqlite/00047_event_type_archived.sql b/internal/db/migrations/sqlite/00047_event_type_archived.sql new file mode 100644 index 0000000..a863f8d --- /dev/null +++ b/internal/db/migrations/sqlite/00047_event_type_archived.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Archived event types: soft-hide from the default admin list without deleting the +-- row (deletion is blocked by ON DELETE RESTRICT once any booking exists). NULL = +-- not archived. Archiving also sets is_active = 0, so every existing bookability gate +-- (public booking page, the shared booking-creation core, MCP) already excludes an +-- archived type — no query needs to learn about archived_at to stay correct. Reversible. +ALTER TABLE event_types ADD COLUMN archived_at TEXT; + +-- +goose Down +ALTER TABLE event_types DROP COLUMN archived_at; diff --git a/internal/db/migrations/sqlite/00048_override_group.sql b/internal/db/migrations/sqlite/00048_override_group.sql new file mode 100644 index 0000000..e74d3f7 --- /dev/null +++ b/internal/db/migrations/sqlite/00048_override_group.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- group_id ties together the per-date rows created from a single date-range block +-- (an "out of office" span), so the UI can show and delete them as one entry. NULL +-- for single-date overrides. Slot generation is unchanged — it still reads the +-- individual per-date rows; the group is purely an admin-side convenience. +ALTER TABLE availability_overrides ADD COLUMN group_id TEXT; + +-- +goose Down +ALTER TABLE availability_overrides DROP COLUMN group_id; diff --git a/internal/db/migrations/00049_connection_calendars.sql b/internal/db/migrations/sqlite/00049_connection_calendars.sql similarity index 100% rename from internal/db/migrations/00049_connection_calendars.sql rename to internal/db/migrations/sqlite/00049_connection_calendars.sql diff --git a/internal/db/migrations/sqlite/00050_branding_banner.sql b/internal/db/migrations/sqlite/00050_branding_banner.sql new file mode 100644 index 0000000..ea8a528 --- /dev/null +++ b/internal/db/migrations/sqlite/00050_branding_banner.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- Banner (instance-wide, on the singleton row): an optional full-width image +-- shown below the logo on the public booking/manage pages and in emails. +-- banner_url absolute https URL to a banner image; empty = hidden. +-- banner_opacity 20-100; CSS opacity. 100 = fully opaque. +ALTER TABLE server_settings ADD COLUMN banner_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN banner_opacity INTEGER NOT NULL DEFAULT 100; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN banner_url; +ALTER TABLE server_settings DROP COLUMN banner_opacity; diff --git a/internal/db/migrations/00051_booking_attendee_locale.sql b/internal/db/migrations/sqlite/00051_booking_attendee_locale.sql similarity index 100% rename from internal/db/migrations/00051_booking_attendee_locale.sql rename to internal/db/migrations/sqlite/00051_booking_attendee_locale.sql diff --git a/internal/db/migrations/00052_msg_greeting.sql b/internal/db/migrations/sqlite/00052_msg_greeting.sql similarity index 100% rename from internal/db/migrations/00052_msg_greeting.sql rename to internal/db/migrations/sqlite/00052_msg_greeting.sql diff --git a/internal/db/migrations/00053_fallback_locale.sql b/internal/db/migrations/sqlite/00053_fallback_locale.sql similarity index 100% rename from internal/db/migrations/00053_fallback_locale.sql rename to internal/db/migrations/sqlite/00053_fallback_locale.sql diff --git a/internal/db/migrations/00054_resend_api_key.sql b/internal/db/migrations/sqlite/00054_resend_api_key.sql similarity index 100% rename from internal/db/migrations/00054_resend_api_key.sql rename to internal/db/migrations/sqlite/00054_resend_api_key.sql diff --git a/internal/db/migrations/00055_booking_hosts_calendar_id.sql b/internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql similarity index 100% rename from internal/db/migrations/00055_booking_hosts_calendar_id.sql rename to internal/db/migrations/sqlite/00055_booking_hosts_calendar_id.sql diff --git a/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql new file mode 100644 index 0000000..d07b8ad --- /dev/null +++ b/internal/db/migrations/sqlite/00056_bookings_list_indexes.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Indexes for the paginated bookings list (§9). +-- +-- The two indexes that existed both lead on host_id and are partial +-- (idx_bookings_no_double, idx_bookings_host_time), which serves the double-book +-- guard well and the list not at all. Every paged query planned as: +-- +-- SCAN bookings USING INDEX idx_bookings_no_double +-- USE TEMP B-TREE FOR ORDER BY +-- +-- i.e. sort the entire matching set to hand back 25 rows. Paginating the API without +-- this makes the response smaller but not the work: the cost still grows with every +-- booking ever made. +-- +-- With (start_at, id) the same query plans as a plain index walk and stops at the +-- LIMIT, no sort: +-- +-- SCAN bookings USING INDEX idx_bookings_start_at +-- +-- id is included as the tiebreaker the list orders by; start_at is not unique, and +-- without a second key two bookings at the same time can swap between pages and one +-- of them is never shown. +CREATE INDEX IF NOT EXISTS idx_bookings_start_at + ON bookings (start_at, id); + +-- Filtering to one event type went from a full scan to a search. The trailing +-- ORDER BY term still costs a small in-memory sort within equal start_at groups, +-- which is not worth another index. +CREATE INDEX IF NOT EXISTS idx_bookings_event_type_start + ON bookings (event_type_id, start_at, id); + +-- +goose Down +DROP INDEX IF EXISTS idx_bookings_start_at; +DROP INDEX IF EXISTS idx_bookings_event_type_start; diff --git a/internal/db/migrations/00057_event_type_show_taken_slots.sql b/internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql similarity index 100% rename from internal/db/migrations/00057_event_type_show_taken_slots.sql rename to internal/db/migrations/sqlite/00057_event_type_show_taken_slots.sql diff --git a/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql new file mode 100644 index 0000000..866ba46 --- /dev/null +++ b/internal/db/migrations/sqlite/00058_webhook_delivery_created_at.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- webhook_deliveries had no timestamp of its own, so "the 50 most recent deliveries" +-- was expressed as ORDER BY rowid DESC. That is unportable — PostgreSQL has no rowid — +-- and it was never quite correct here either: SQLite's rowid tracks insertion order +-- only until something renumbers it, and VACUUM is allowed to. +-- +-- The default is a constant empty string rather than a timestamp expression because +-- SQLite's ALTER TABLE ADD COLUMN forbids a parenthesised or non-deterministic DEFAULT. +-- New rows get their value bound by the writer (internal/webhook). Rows that predate +-- this migration keep '', which sorts last under ORDER BY created_at DESC — correct, +-- since they are the oldest deliveries on the instance. +ALTER TABLE webhook_deliveries ADD COLUMN created_at TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE webhook_deliveries DROP COLUMN created_at; diff --git a/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql b/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql new file mode 100644 index 0000000..c6e628d --- /dev/null +++ b/internal/db/migrations/sqlite/00059_text_timestamp_collation.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- No-op on SQLite, deliberately, and the file exists so the two migration +-- directories keep one file per version (TestMigrationDirs_parity enforces that, +-- and TargetVersion is dialect-independent because of it). +-- +-- The Postgres half pins every TEXT timestamp column to COLLATE "C" so that +-- `run_at <= ?`, the consent window, the booking overlap predicates, the token +-- expiries and `ORDER BY created_at` compare bytes there. SQLite has nothing to +-- pin: its only collations are BINARY (the default), NOCASE and RTRIM, and +-- BINARY *is* memcmp. The behaviour this migration buys on Postgres is what +-- SQLite already does, which is why the port could get this far without noticing. +-- +-- A statement is included rather than leaving the section empty because goose +-- treats a migration with no statements as a parse problem, and a SELECT is the +-- cheapest way to say "nothing to do" in a file that still has to be applied and +-- recorded. +SELECT 1; + +-- +goose Down +SELECT 1; diff --git a/internal/db/migrations/sqlite/00060_multi_tenant.sql b/internal/db/migrations/sqlite/00060_multi_tenant.sql new file mode 100644 index 0000000..9b9c32e --- /dev/null +++ b/internal/db/migrations/sqlite/00060_multi_tenant.sql @@ -0,0 +1,221 @@ +-- +goose Up +-- +-- The SQLite half of multi-tenant mode. SQLite CANNOT run multi-tenant — +-- config.Validate refuses MULTI_TENANT without a postgres:// DSN, because the +-- isolation guarantee is PostgreSQL row-level security and SQLite has no +-- equivalent. What this file does is keep the two schemas the same shape, so the +-- cross-engine comparison test stays meaningful and so no Go call site has to +-- ask which engine it is on before naming workspace_id. +-- +-- Three deliberate differences from the Postgres file, all forced by SQLite: +-- +-- 1. No REFERENCES workspaces(id). SQLite rejects +-- `ADD COLUMN ... NOT NULL DEFAULT 'default' REFERENCES ws(id)` outright — +-- "Cannot add a REFERENCES column with non-NULL default value (1)", measured +-- on modernc.org/sqlite with foreign_keys=ON. Rebuilding all 32 tables to +-- get the constraint would be a large, risky migration whose only payoff is +-- ON DELETE CASCADE for a workspace delete, and workspace deletes only +-- happen in multi-tenant mode, i.e. never here. The two tables rebuilt below +-- omit it too, so the engine is consistent with itself rather than being +-- half-constrained. +-- 2. No row-level security, and no policies. There is nothing to express them +-- with. +-- 3. Only the four uniqueness changes that need the constraint MOVED are made. +-- users(email), event_types(slug), teams(slug) and server_settings' id = 1 +-- singleton stay exactly as they are: with one workspace, a global unique +-- and a (workspace_id, x) unique admit precisely the same rows, and a +-- table rebuild of users or event_types to prove that would be four +-- hundred lines of risk for no behaviour change. + +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + public_host TEXT NOT NULL UNIQUE, + region TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +-- public_host is empty on purpose: no HTTP request carries an empty Host, so the +-- default workspace is unreachable by host resolution. +INSERT INTO workspaces (id, slug, public_host, region, status) +VALUES ('default', 'default', '', '', 'active'); + +-- ── The column (D1) ─────────────────────────────────────────────────────────── +-- A literal 'default' rather than a session setting: SQLite has no +-- current_setting, and with one workspace there is nothing to resolve. + +ALTER TABLE api_keys ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE availability_overrides ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE availability_rules ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE booking_answers ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE booking_attendees ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE booking_hosts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE booking_manage_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE bookings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE calendar_connections ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE connection_calendars ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE event_type_hosts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE event_type_questions ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE event_type_reminders ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE event_types ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE invite_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE jobs ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE magic_link_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE notes ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE oauth_access_tokens ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE oauth_auth_codes ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE recordings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE server_settings ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE sessions ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE team_members ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE teams ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE transcripts ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE users ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE webhook_deliveries ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE webhooks ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE zoom_connections ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default'; + +-- idempotency_keys and meeting_consents get the column through their rebuild +-- below, because their PRIMARY KEY is what has to change and SQLite cannot ALTER +-- a table constraint. + +-- ── idempotency_keys: PK (idempotency_key) → (workspace_id, idempotency_key) ── +CREATE TABLE idempotency_keys_new ( + workspace_id TEXT NOT NULL DEFAULT 'default', + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER, -- NULL while the original request is in flight + response_body TEXT, + booking_id TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, idempotency_key) +); +INSERT INTO idempotency_keys_new (idempotency_key, request_hash, status_code, response_body, booking_id, created_at) + SELECT idempotency_key, request_hash, status_code, response_body, booking_id, created_at FROM idempotency_keys; +DROP TABLE idempotency_keys; +ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys; + +-- ── meeting_consents: PK (room, participant_identity) gains workspace_id ────── +CREATE TABLE meeting_consents_new ( + workspace_id TEXT NOT NULL DEFAULT 'default', + room TEXT NOT NULL, + participant_identity TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'continue', -- continue | leave + decided_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (workspace_id, room, participant_identity) +); +INSERT INTO meeting_consents_new (room, participant_identity, name, decision, decided_at) + SELECT room, participant_identity, name, decision, decided_at FROM meeting_consents; +DROP TABLE meeting_consents; +ALTER TABLE meeting_consents_new RENAME TO meeting_consents; + +-- ── The two unique indexes, which need no rebuild ───────────────────────────── +DROP INDEX ux_jobs_type_payload; +CREATE UNIQUE INDEX ux_jobs_type_payload ON jobs (workspace_id, type, payload); + +DROP INDEX idx_notes_booking; +CREATE UNIQUE INDEX idx_notes_booking ON notes (workspace_id, booking_id); + +-- ── The partial indexes on bookings and jobs ────────────────────────────────── +DROP INDEX idx_bookings_no_double; +CREATE UNIQUE INDEX idx_bookings_no_double ON bookings (workspace_id, host_id, start_at) + WHERE status != 'cancelled'; + +DROP INDEX idx_bookings_host_time; +CREATE INDEX idx_bookings_host_time ON bookings (workspace_id, host_id, start_at, end_at) + WHERE status = 'confirmed'; + +DROP INDEX idx_jobs_pending; +CREATE INDEX idx_jobs_pending ON jobs (workspace_id, run_at) WHERE status = 'pending'; + +DROP INDEX idx_jobs_running_expired; +CREATE INDEX idx_jobs_running_expired ON jobs (workspace_id, locked_until) WHERE status = 'running'; + +-- The worker claims and reaps across tenants on the platform handle, ordered by +-- run_at / locked_until globally, which a workspace-leading index cannot serve. +CREATE INDEX idx_jobs_pending_global ON jobs (run_at) WHERE status = 'pending'; +CREATE INDEX idx_jobs_running_expired_global ON jobs (locked_until) WHERE status = 'running'; + +-- +goose Down +DROP INDEX IF EXISTS idx_jobs_running_expired_global; +DROP INDEX IF EXISTS idx_jobs_pending_global; + +DROP INDEX idx_jobs_running_expired; +CREATE INDEX idx_jobs_running_expired ON jobs (locked_until) WHERE status = 'running'; + +DROP INDEX idx_jobs_pending; +CREATE INDEX idx_jobs_pending ON jobs (run_at) WHERE status = 'pending'; + +DROP INDEX idx_bookings_host_time; +CREATE INDEX idx_bookings_host_time ON bookings (host_id, start_at, end_at) WHERE status = 'confirmed'; + +DROP INDEX idx_bookings_no_double; +CREATE UNIQUE INDEX idx_bookings_no_double ON bookings (host_id, start_at) WHERE status != 'cancelled'; + +DROP INDEX idx_notes_booking; +CREATE UNIQUE INDEX idx_notes_booking ON notes (booking_id); + +DROP INDEX ux_jobs_type_payload; +CREATE UNIQUE INDEX ux_jobs_type_payload ON jobs (type, payload); + +CREATE TABLE meeting_consents_old ( + room TEXT NOT NULL, + participant_identity TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT 'continue', + decided_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + PRIMARY KEY (room, participant_identity) +); +INSERT INTO meeting_consents_old (room, participant_identity, name, decision, decided_at) + SELECT room, participant_identity, name, decision, decided_at FROM meeting_consents; +DROP TABLE meeting_consents; +ALTER TABLE meeting_consents_old RENAME TO meeting_consents; + +CREATE TABLE idempotency_keys_old ( + idempotency_key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + status_code INTEGER, + response_body TEXT, + booking_id TEXT, + created_at TEXT NOT NULL +); +INSERT INTO idempotency_keys_old (idempotency_key, request_hash, status_code, response_body, booking_id, created_at) + SELECT idempotency_key, request_hash, status_code, response_body, booking_id, created_at FROM idempotency_keys; +DROP TABLE idempotency_keys; +ALTER TABLE idempotency_keys_old RENAME TO idempotency_keys; + +ALTER TABLE zoom_connections DROP COLUMN workspace_id; +ALTER TABLE webhooks DROP COLUMN workspace_id; +ALTER TABLE webhook_deliveries DROP COLUMN workspace_id; +ALTER TABLE users DROP COLUMN workspace_id; +ALTER TABLE transcripts DROP COLUMN workspace_id; +ALTER TABLE teams DROP COLUMN workspace_id; +ALTER TABLE team_members DROP COLUMN workspace_id; +ALTER TABLE sessions DROP COLUMN workspace_id; +ALTER TABLE server_settings DROP COLUMN workspace_id; +ALTER TABLE recordings DROP COLUMN workspace_id; +ALTER TABLE oauth_auth_codes DROP COLUMN workspace_id; +ALTER TABLE oauth_access_tokens DROP COLUMN workspace_id; +ALTER TABLE notes DROP COLUMN workspace_id; +ALTER TABLE magic_link_tokens DROP COLUMN workspace_id; +ALTER TABLE jobs DROP COLUMN workspace_id; +ALTER TABLE invite_tokens DROP COLUMN workspace_id; +ALTER TABLE event_types DROP COLUMN workspace_id; +ALTER TABLE event_type_reminders DROP COLUMN workspace_id; +ALTER TABLE event_type_questions DROP COLUMN workspace_id; +ALTER TABLE event_type_hosts DROP COLUMN workspace_id; +ALTER TABLE connection_calendars DROP COLUMN workspace_id; +ALTER TABLE calendar_connections DROP COLUMN workspace_id; +ALTER TABLE bookings DROP COLUMN workspace_id; +ALTER TABLE booking_manage_tokens DROP COLUMN workspace_id; +ALTER TABLE booking_hosts DROP COLUMN workspace_id; +ALTER TABLE booking_attendees DROP COLUMN workspace_id; +ALTER TABLE booking_answers DROP COLUMN workspace_id; +ALTER TABLE availability_rules DROP COLUMN workspace_id; +ALTER TABLE availability_overrides DROP COLUMN workspace_id; +ALTER TABLE api_keys DROP COLUMN workspace_id; + +DROP TABLE workspaces; diff --git a/internal/db/migrations/sqlite/00061_sso_nonces.sql b/internal/db/migrations/sqlite/00061_sso_nonces.sql new file mode 100644 index 0000000..0937e30 --- /dev/null +++ b/internal/db/migrations/sqlite/00061_sso_nonces.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Single-use identifiers (jti) from the signed SSO hand-off tokens. A token's jti is +-- claimed here before its session is created, so a replay of a still-valid token +-- collides on the primary key instead of being handed a second session. +-- +-- expires_at mirrors the token's own exp. The background worker purges rows past it in +-- the same GC pass that sweeps expired sessions and magic links, which is what keeps +-- this table the size of one token lifetime rather than of every sign-in ever made. +CREATE TABLE sso_nonces ( + jti TEXT PRIMARY KEY, + expires_at TEXT NOT NULL +); + +CREATE INDEX idx_sso_nonces_expires_at ON sso_nonces(expires_at); + +-- +goose Down +DROP INDEX IF EXISTS idx_sso_nonces_expires_at; +DROP TABLE sso_nonces; diff --git a/internal/db/migrations/sqlite/00062_workspace_settings_columns.sql b/internal/db/migrations/sqlite/00062_workspace_settings_columns.sql new file mode 100644 index 0000000..2634535 --- /dev/null +++ b/internal/db/migrations/sqlite/00062_workspace_settings_columns.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- See the Postgres half for why these two exist. Identical here: plain ADD COLUMN with a +-- '' default, so no table is rebuilt and every existing row keeps behaving as it did. +-- +-- SQLite cannot run multi-tenant (config.Validate refuses MULTI_TENANT without a +-- postgres:// DSN), so on this engine the columns are only ever the empty fallback. They +-- exist here because the two migration directories keep one file per version, and because +-- the schema-comparison test asserts the engines agree on column sets. +ALTER TABLE server_settings ADD COLUMN embed_allowed_origins TEXT NOT NULL DEFAULT ''; +ALTER TABLE server_settings ADD COLUMN stt_base_url TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE server_settings DROP COLUMN stt_base_url; +ALTER TABLE server_settings DROP COLUMN embed_allowed_origins; diff --git a/internal/db/migrations_internal_test.go b/internal/db/migrations_internal_test.go new file mode 100644 index 0000000..328b3a1 --- /dev/null +++ b/internal/db/migrations_internal_test.go @@ -0,0 +1,58 @@ +package db + +import ( + "io/fs" + "testing" +) + +// TestMigrationDirs_parity is what lets TargetVersion be dialect-independent and +// what stops the two sets drifting: a migration added for one engine and +// forgotten for the other would otherwise only show up when someone ran the other +// engine. +func TestMigrationDirs_parity(t *testing.T) { + sqliteFiles := migrationFiles(t, DialectSQLite) + postgresFiles := migrationFiles(t, DialectPostgres) + + if len(sqliteFiles) != len(postgresFiles) { + t.Fatalf("migration count differs: sqlite %d, postgres %d", len(sqliteFiles), len(postgresFiles)) + } + + for i, name := range sqliteFiles { + if postgresFiles[i] != name { + t.Errorf("migration %d differs: sqlite %q, postgres %q", i, name, postgresFiles[i]) + } + } + + sqliteTarget, err := maxVersion(DialectSQLite.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(sqlite): %v", err) + } + postgresTarget, err := maxVersion(DialectPostgres.migrationsDir()) + if err != nil { + t.Fatalf("maxVersion(postgres): %v", err) + } + if sqliteTarget != postgresTarget { + t.Errorf("target version differs: sqlite %d, postgres %d", sqliteTarget, postgresTarget) + } + if int(sqliteTarget) != len(sqliteFiles) { + t.Errorf("target version %d does not match file count %d — a gap or a duplicate number", + sqliteTarget, len(sqliteFiles)) + } +} + +// migrationFiles lists a dialect's embedded migrations, sorted (fs.ReadDir sorts +// by name, which for NNNNN_ prefixes is version order). +func migrationFiles(t *testing.T, dialect Dialect) []string { + t.Helper() + + entries, err := fs.ReadDir(migrations, dialect.migrationsDir()) + if err != nil { + t.Fatalf("read %s: %v", dialect.migrationsDir(), err) + } + + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} diff --git a/internal/db/pair.go b/internal/db/pair.go new file mode 100644 index 0000000..999d831 --- /dev/null +++ b/internal/db/pair.go @@ -0,0 +1,125 @@ +package db + +import ( + "context" + "errors" + "fmt" +) + +// OpenPair opens the two handles multi-tenant mode needs (D4). +// +// app DATABASE_URL — the application role. NOBYPASSRLS, and it must +// not own the tables: a role that owns a table is +// exempt from that table's policy unless FORCE is +// set, and BYPASSRLS is exempt regardless. This is +// the handle every request runs on, through +// ForWorkspace. +// platform DATABASE_ADMIN_URL — the platform role. Owner of the schema, with +// BYPASSRLS. Runs migrations and EnableRLS, the +// worker's cross-tenant claim loop, the +// reconciler's workspace enumeration, the platform +// API, and the credential lookups that have to +// resolve a tenant before one is known. +// +// app.Platform() returns platform, so nothing downstream has to carry both. +// Both are returned so the caller closes both; Close on one closes one pool. +// +// Both DSNs must be PostgreSQL. SQLite has no row-level security, so there is +// nothing to enforce isolation with and a "multi-tenant" SQLite instance would +// separate nothing — config.Validate refuses that combination before this is +// reached, and this refuses it again because a library that only works when its +// caller validated is a library that will one day be called by something else. +func OpenPair(appURL, adminURL string, opts ...Option) (app, platform *DB, err error) { + if dialectFromURL(appURL) != DialectPostgres { + return nil, nil, errors.New("db: multi-tenant mode needs a postgres:// application DSN — " + + "tenant isolation is PostgreSQL row-level security") + } + if dialectFromURL(adminURL) != DialectPostgres { + return nil, nil, errors.New("db: multi-tenant mode needs a postgres:// platform DSN") + } + + platform, err = OpenDB(adminURL, opts...) + if err != nil { + return nil, nil, fmt.Errorf("open platform handle: %w", err) + } + // The platform handle binds too, so that "every statement on a paired handle + // sets app.workspace_id" has no exceptions to remember. It binds '', which no + // workspace id can equal, so it matches no row under the policies — and it is + // inert in practice because the platform role bypasses them. VerifyRoles is + // what makes that "in practice" a checked fact rather than an assumption. + platform.multiTenant = true + + app, err = OpenDB(appURL, opts...) + if err != nil { + platform.Close() //nolint:errcheck + return nil, nil, fmt.Errorf("open application handle: %w", err) + } + app.multiTenant = true + app.platform = platform + + return app, platform, nil +} + +// VerifyRoles checks the two role attributes the isolation guarantee rests on, +// and is meant to run at boot, right after EnableRLS, with the boot failing if it +// does. It is called on the application handle. +// +// ⛔ Both halves are load-bearing and both fail SILENTLY if they are wrong. +// +// - If the application role is a superuser, has BYPASSRLS, or owns any tenant +// table, it is not constrained by the policies. Nothing breaks. Every request +// works. It can also read every other workspace's rows. That is the +// misconfiguration this exists for: the failure mode is a security hole with +// no symptom. +// - If the platform role does NOT bypass — it is the owner, and FORCE ROW LEVEL +// SECURITY covers owners — then the platform handle's ” binding matches no +// row and the worker claims nothing, the reconciler enumerates nothing, and +// the platform API returns empty. Also no error, just an instance that quietly +// does no background work. +// +// It is a no-op outside multi-tenant mode, where there is one role and no policy. +func (h *DB) VerifyRoles(ctx context.Context) error { + if !h.binds() { + return nil + } + + var super, bypass bool + if err := h.DB.QueryRowContext(ctx, + `SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`).Scan(&super, &bypass); err != nil { + return fmt.Errorf("read the application role's attributes: %w", err) + } + if super || bypass { + return fmt.Errorf("the DATABASE_URL role bypasses row-level security (superuser=%v bypassrls=%v), "+ + "so every workspace can read every other workspace's rows; it must be a NOBYPASSRLS, non-superuser role", + super, bypass) + } + + var owned int + if err := h.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pg_tables + WHERE schemaname = current_schema() AND tableowner = current_user`).Scan(&owned); err != nil { + return fmt.Errorf("count tables owned by the application role: %w", err) + } + if owned > 0 { + return fmt.Errorf("the DATABASE_URL role owns %d tables in this schema; "+ + "it must not own them, or its row-level-security policies do not apply to it "+ + "(FORCE covers the owner, but relying on that leaves no margin)", owned) + } + + platform := h.Platform() + if platform == h { + return errors.New("multi-tenant mode has no platform handle; open the pair with db.OpenPair") + } + var pSuper, pBypass bool + if err := platform.DB.QueryRowContext(ctx, + `SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`).Scan(&pSuper, &pBypass); err != nil { + return fmt.Errorf("read the platform role's attributes: %w", err) + } + if !pSuper && !pBypass { + return errors.New("the DATABASE_ADMIN_URL role neither is a superuser nor has BYPASSRLS, " + + "so it cannot read across workspaces: the worker would claim no jobs and the reconciler would " + + "enumerate nothing, silently") + } + + return nil +} diff --git a/internal/db/pair_test.go b/internal/db/pair_test.go new file mode 100644 index 0000000..22b3c3b --- /dev/null +++ b/internal/db/pair_test.go @@ -0,0 +1,446 @@ +package db_test + +import ( + "context" + "database/sql" + "errors" + "net/url" + "sync" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// Boundary 2: the two handles, and the per-statement tenant binding. +// +// Everything that matters here needs a NOBYPASSRLS application role, for the +// reason rls_proof_test.go spells out at length: through a superuser handle these +// assertions pass whether the binding works or not. The harness is shared with +// that file. + +// tenantPair is a live OpenPair against the test schema, with the application +// handle connected as a NOBYPASSRLS role that owns nothing. +type tenantPair struct { + app *db.DB // DATABASE_URL — the application role + platform *db.DB // DATABASE_ADMIN_URL — the owner, which bypasses + owner *db.DB // the suite's own migrated handle, for fixtures and control reads +} + +func openTenantPair(t *testing.T) tenantPair { + t.Helper() + + owner := dbtest.RequirePostgres(t) + seedTwoWorkspaces(t, owner) + if err := owner.EnableRLS(context.Background()); err != nil { + t.Fatalf("EnableRLS: %v", err) + } + + role := newAppRole(t, owner) // skips loudly if it cannot be non-superuser + role.handle.Close() // OpenPair opens its own; this one was only the probe + + // The platform DSN is the suite's own, pinned to the test's schema. In this + // harness that role is the superuser that owns the schema, which is exactly + // what DATABASE_ADMIN_URL is in production: owner plus BYPASSRLS. + platformDSN := withSchema(t, dbtest.PostgresDSN(), role.schema) + + app, platform, err := db.OpenPair(role.dsn, platformDSN) + if err != nil { + t.Fatalf("OpenPair: %v", err) + } + t.Cleanup(func() { + app.Close() + platform.Close() + }) + + // The guard that makes the '' binding on the platform handle safe, and that + // would catch an application role able to read everything. + if err := app.VerifyRoles(context.Background()); err != nil { + t.Fatalf("VerifyRoles: %v", err) + } + + return tenantPair{app: app, platform: platform, owner: owner} +} + +func withSchema(t *testing.T, dsn, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse %s: %v", dbtest.DSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +// TestOpenPair_workspaceHandleSeesOnlyItsOwn walks every statement shape a +// workspace handle offers. Each one is asserted twice: A sees its own row, and A +// does not see B's. +func TestOpenPair_workspaceHandleSeesOnlyItsOwn(t *testing.T) { + p := openTenantPair(t) + a := p.app.ForWorkspace("ws-a") + + if got := a.Workspace(); got != "ws-a" { + t.Errorf("Workspace() = %q; want ws-a", got) + } + + t.Run("QueryRow", func(t *testing.T) { + var n int + if err := a.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("scan: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1 of the 2 users", n) + } + var email string + err := a.QueryRow(`SELECT email FROM users WHERE id = ?`, "u-b").Scan(&email) + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("reading B's user gave (%q, %v); want sql.ErrNoRows", email, err) + } + assertIdle(t, a, "after two QueryRow/Scan pairs") + }) + + t.Run("Query", func(t *testing.T) { + rows, err := a.Query(`SELECT id FROM users ORDER BY id`) + if err != nil { + t.Fatalf("query: %v", err) + } + // The positive control on the release assertion: while the cursor is open + // the connection IS held, so the zero after Close means something. + if inUse := a.Stats().InUse; inUse != 1 { + t.Errorf("with a cursor open the pool reports InUse=%d; want 1", inUse) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatalf("scan: %v", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + if err := rows.Close(); err != nil { + t.Fatalf("rows.Close: %v", err) + } + if len(ids) != 1 || ids[0] != "u-a" { + t.Errorf("ids = %v; want [u-a]", ids) + } + assertIdle(t, a, "after Rows.Close") + }) + + t.Run("Exec", func(t *testing.T) { + // An UPDATE with no workspace predicate. It must reach A's row and not B's. + res, err := a.Exec(`UPDATE users SET name = ?`, "renamed") + if err != nil { + t.Fatalf("update: %v", err) + } + n, err := res.RowsAffected() + if err != nil { + t.Fatalf("rows affected: %v", err) + } + if n != 1 { + t.Errorf("an unscoped UPDATE touched %d rows; want 1", n) + } + var bName string + if err := p.owner.QueryRow(`SELECT name FROM users WHERE id = ?`, "u-b").Scan(&bName); err != nil { + t.Fatalf("read B through the owner: %v", err) + } + if bName == "renamed" { + t.Error("the UPDATE reached B's row") + } + assertIdle(t, a, "after Exec") + }) + + t.Run("Tx", func(t *testing.T) { + tx, err := a.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck // the standard pattern; release must be idempotent + + if inUse := a.Stats().InUse; inUse != 1 { + t.Errorf("with a transaction open the pool reports InUse=%d; want 1", inUse) + } + if got := tx.Workspace(); got != "ws-a" { + t.Errorf("tx.Workspace() = %q; want ws-a", got) + } + + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count in tx: %v", err) + } + if n != 1 { + t.Errorf("inside a transaction, count = %d; want 1", n) + } + if _, err := tx.Exec(`INSERT INTO teams (id, name, slug) VALUES (?, ?, ?)`, "t-a", "A", "a"); err != nil { + t.Fatalf("insert in tx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + assertIdle(t, a, "after Commit (and the deferred Rollback that follows)") + + var ws string + if err := p.owner.QueryRow(`SELECT workspace_id FROM teams WHERE id = ?`, "t-a").Scan(&ws); err != nil { + t.Fatalf("read the team through the owner: %v", err) + } + if ws != "ws-a" { + t.Errorf("the team inserted in the transaction landed in %q; want ws-a", ws) + } + }) +} + +// TestOpenPair_insertNamesNoColumn is D1's payoff: the ~200 INSERT statements in +// the tree do not mention workspace_id and must not have to. +func TestOpenPair_insertNamesNoColumn(t *testing.T) { + p := openTenantPair(t) + a := p.app.ForWorkspace("ws-a") + + if _, err := a.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u-a2", "a2@example.com", "A2"); err != nil { + t.Fatalf("insert: %v", err) + } + var ws string + if err := p.owner.QueryRow(`SELECT workspace_id FROM users WHERE id = ?`, "u-a2").Scan(&ws); err != nil { + t.Fatalf("read back through the owner: %v", err) + } + if ws != "ws-a" { + t.Errorf("the row landed in %q; want ws-a", ws) + } + assertIdle(t, a, "after Exec") +} + +// TestOpenPair_insertIntoAnotherWorkspaceIsRefused: the WITH CHECK half. A +// handle bound to A cannot write into B even by naming it. +func TestOpenPair_insertIntoAnotherWorkspaceIsRefused(t *testing.T) { + p := openTenantPair(t) + a := p.app.ForWorkspace("ws-a") + + _, err := a.Exec( + `INSERT INTO users (id, email, name, workspace_id) VALUES (?, ?, ?, ?)`, + "u-cross", "cross@example.com", "X", "ws-b") + if err == nil { + t.Fatal("writing into ws-b through A's handle succeeded") + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "42501" { + t.Errorf("error was %v; want SQLSTATE 42501 (the row-level-security violation)", err) + } + var n int + if err := p.owner.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, "u-cross").Scan(&n); err != nil { + t.Fatalf("count through the owner: %v", err) + } + if n != 0 { + t.Error("the refused row is in the table") + } + assertIdle(t, a, "after a refused Exec") +} + +// TestOpenPair_platformHandleSeesBoth. It is the handle the worker, the +// reconciler and every credential lookup run on, and those are meaningless if it +// cannot read across workspaces. +func TestOpenPair_platformHandleSeesBoth(t *testing.T) { + p := openTenantPair(t) + + var n int + if err := p.platform.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 2 { + t.Errorf("the platform handle sees %d users; want both", n) + } + if got := p.platform.Workspace(); got != "" { + t.Errorf("platform Workspace() = %q; want empty", got) + } + // It binds '' anyway, which is what keeps "every statement on a paired handle + // sets the parameter" free of exceptions. + if !p.platform.MultiTenant() { + t.Error("the platform handle should still bind, even though it bypasses") + } + assertIdle(t, p.platform, "after QueryRow on the platform handle") + + // And Platform() on the app handle is that same handle, so nothing downstream + // has to carry both. + if p.app.Platform() != p.platform { + t.Error("app.Platform() is not the platform handle OpenPair returned") + } + if p.app.ForWorkspace("ws-a").Platform() != p.platform { + t.Error("a workspace handle lost its platform handle") + } +} + +// TestOpenPair_unboundAppHandleMatchesNothing: the pair's base app handle is not +// a back door. It binds ”, which no workspace id can equal. +func TestOpenPair_unboundAppHandleMatchesNothing(t *testing.T) { + p := openTenantPair(t) + + var n int + if err := p.app.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("the unbound application handle sees %d users; want 0", n) + } +} + +// TestOpenPair_handleSurvivesItsRequest is why the binding is per statement +// rather than a pinned connection: Calnode's handlers spawn fire-and-forget +// goroutines (notify hosts, enqueue the webhook, enqueue reminders) that outlive +// the request. A handle that pinned a session would be unusable there. +func TestOpenPair_handleSurvivesItsRequest(t *testing.T) { + p := openTenantPair(t) + + // The "request": builds the handle, returns, goes out of scope. + handle := func() *db.DB { return p.app.ForWorkspace("ws-a") }() + + var wg sync.WaitGroup + errs := make(chan error, 4) + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + var email string + if err := handle.QueryRow(`SELECT email FROM users`).Scan(&email); err != nil { + errs <- err + return + } + if email != "a@example.com" { + errs <- errors.New("goroutine read " + email) + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Error(err) + } + assertIdle(t, handle, "after four concurrent goroutine reads") +} + +// TestForWorkspace_rejectsAMalformedID. The id is a bind parameter, never SQL +// text, so this is not an injection defence — it is a guard against a caller +// passing something that is not an id and getting a handle that silently matches +// nothing. +func TestForWorkspace_rejectsAMalformedID(t *testing.T) { + p := openTenantPair(t) + + for _, bad := range []string{ + "", "WS-A", "ws a", "ws'a", "ws;a", "../etc", "a@b.com", + "0123456789012345678901234567890123456789012345678901234567890123456789", // 70 chars + } { + h := p.app.ForWorkspace(bad) + if h.Err() == nil { + t.Errorf("ForWorkspace(%q) produced a usable handle", bad) + continue + } + if !errors.Is(h.Err(), db.ErrInvalidWorkspace) { + t.Errorf("ForWorkspace(%q) err = %v; want ErrInvalidWorkspace", bad, h.Err()) + } + // Every statement shape has to refuse, not just report. + var n int + if err := h.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n); !errors.Is(err, db.ErrInvalidWorkspace) { + t.Errorf("QueryRow on a poisoned handle gave %v", err) + } + if _, err := h.Query(`SELECT 1`); !errors.Is(err, db.ErrInvalidWorkspace) { + t.Errorf("Query on a poisoned handle gave %v", err) + } + if _, err := h.Exec(`SELECT 1`); !errors.Is(err, db.ErrInvalidWorkspace) { + t.Errorf("Exec on a poisoned handle gave %v", err) + } + if _, err := h.Begin(); !errors.Is(err, db.ErrInvalidWorkspace) { + t.Errorf("Begin on a poisoned handle gave %v", err) + } + } + + for _, good := range []string{"default", "ws-a", "a", "acme_corp", "ws-0123"} { + if h := p.app.ForWorkspace(good); h.Err() != nil { + t.Errorf("ForWorkspace(%q) = %v; want a usable handle", good, h.Err()) + } + } + assertIdle(t, p.app, "no statement should have reached the pool") +} + +// TestOpenPair_prepareIsRefusedOnABoundHandle. A *sql.Stmt is re-prepared on +// whatever connection the pool hands it, with no hook to bind the tenant first, +// so it would run unbound and silently see nothing. Refusing is the only safe +// answer; nothing in the tree prepares a statement. +func TestOpenPair_prepareIsRefusedOnABoundHandle(t *testing.T) { + p := openTenantPair(t) + if _, err := p.app.ForWorkspace("ws-a").Prepare(`SELECT 1`); err == nil { + t.Fatal("Prepare on a tenant-bound handle succeeded") + } +} + +// TestOpenPair_refusesSQLite: the library refuses the combination its caller is +// already supposed to have refused, because a library that only works when its +// caller validated is one that will be called by something else one day. +func TestOpenPair_refusesSQLite(t *testing.T) { + if _, _, err := db.OpenPair("sqlite://:memory:", "postgres://x:y@h/db"); err == nil { + t.Error("OpenPair accepted a SQLite application DSN") + } + if _, _, err := db.OpenPair("postgres://x:y@h/db", "sqlite://:memory:"); err == nil { + t.Error("OpenPair accepted a SQLite platform DSN") + } +} + +// TestSingleHandle_forWorkspaceIsIdentity is the byte-identical promise at the +// handle layer. On SQLite and on single-tenant PostgreSQL, ForWorkspace returns +// the same handle, Platform returns the same handle, and no statement acquires a +// connection of its own or sets a session parameter — so nothing about an +// existing deployment changes. +func TestSingleHandle_forWorkspaceIsIdentity(t *testing.T) { + handle := dbtest.Open(t) + + if handle.MultiTenant() { + t.Error("a handle from OpenDB should not bind a tenant") + } + if handle.ForWorkspace("ws-a") != handle { + t.Error("ForWorkspace returned a different handle on a single handle") + } + // Not even a malformed id, because there is nothing to validate against: one + // workspace, no parameter to bind. + if handle.ForWorkspace("NOT AN ID") != handle { + t.Error("ForWorkspace validated on a single handle; it should be the identity function") + } + if handle.Platform() != handle { + t.Error("Platform() should be the handle itself when there is one role") + } + if got := handle.Workspace(); got != "" { + t.Errorf("Workspace() = %q; want empty", got) + } + // Prepare stays available, which it is on every existing deployment. + stmt, err := handle.Prepare(`SELECT COUNT(*) FROM users`) + if err != nil { + t.Fatalf("Prepare on a single handle: %v", err) + } + stmt.Close() + + if dbtest.PostgresDSN() == "" { + t.Log("SQLite: the tenant-binding cases in this file are skipped — there is no row-level " + + "security to enforce isolation with, which is why config.Validate refuses MULTI_TENANT " + + "without a postgres:// DSN. ForWorkspace being the identity function IS the SQLite behaviour.") + } +} + +// TestSingleHandle_verifyRolesIsANoOp: boot code needs no dialect or mode branch. +func TestSingleHandle_verifyRolesIsANoOp(t *testing.T) { + if err := dbtest.Open(t).VerifyRoles(context.Background()); err != nil { + t.Errorf("VerifyRoles on a single handle: %v", err) + } +} + +// assertIdle is the release proof. Every statement shape hands its connection +// back, so the pool returns to zero in use; a shape that leaked would show up +// here as a number that only ever grows. +func assertIdle(t *testing.T, handle *db.DB, when string) { + t.Helper() + if inUse := handle.Stats().InUse; inUse != 0 { + t.Errorf("pool reports InUse=%d %s; the connection was not released", inUse, when) + } +} diff --git a/internal/db/pool_test.go b/internal/db/pool_test.go new file mode 100644 index 0000000..d38c133 --- /dev/null +++ b/internal/db/pool_test.go @@ -0,0 +1,167 @@ +package db_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/calnode/calnode/internal/config" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// unreachablePostgresDSN is a syntactically valid Postgres URL pointed at +// nothing. sql.Open is lazy — pgx parses the DSN and no connection is attempted +// until a query runs — so the pool settings can be read back from Stats() +// without a server anywhere near the test. +const unreachablePostgresDSN = "postgres://calnode:pw@127.0.0.1:5432/calnode?sslmode=disable" + +func TestOpenDB_poolSizeFromEnv(t *testing.T) { + tests := []struct { + name string + open string // DB_MAX_OPEN_CONNS, "" = unset + idle string // DB_MAX_IDLE_CONNS, "" = unset + wantOpen int + }{ + {name: "unset uses the defaults", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "raised", open: "40", idle: "10", wantOpen: 40}, + {name: "lowered to one", open: "1", idle: "1", wantOpen: 1}, + {name: "zero is not positive, so the default stands", open: "0", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "negative is not positive either", open: "-4", wantOpen: config.DefaultDBMaxOpenConns}, + {name: "unparsable falls back", open: "lots", wantOpen: config.DefaultDBMaxOpenConns}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + setPoolEnv(t, tc.open, tc.idle) + + handle, err := db.OpenDB(unreachablePostgresDSN) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Stats().MaxOpenConnections; got != tc.wantOpen { + t.Errorf("MaxOpenConnections = %d; want %d", got, tc.wantOpen) + } + }) + } +} + +// TestOpenDB_withPoolBeatsEnv covers the escape hatch: a caller that must not +// follow the environment. +func TestOpenDB_withPoolBeatsEnv(t *testing.T) { + setPoolEnv(t, "40", "20") + + handle, err := db.OpenDB(unreachablePostgresDSN, db.WithPool(3, 2)) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + if got := handle.Stats().MaxOpenConnections; got != 3 { + t.Errorf("MaxOpenConnections = %d; want 3 (WithPool, not the environment)", got) + } +} + +// TestOpenDB_sqlitePoolIsNotConfigurable is the correctness guarantee, not a +// preference: SQLite's single connection is what serialises write transactions +// (ARCHITECTURE §17, and it is why booking.lockHosts is a no-op there), and the +// pragmas are connection-scoped. An operator who sets DB_MAX_OPEN_CONNS for +// their Postgres instance and later moves the same environment onto a SQLite one +// must not silently lose that. +func TestOpenDB_sqlitePoolIsNotConfigurable(t *testing.T) { + setPoolEnv(t, "40", "20") + + for _, url := range []string{ + "sqlite://:memory:", + "sqlite://" + filepath.Join(t.TempDir(), "calnode.db"), + } { + handle, err := db.OpenDB(url, db.WithPool(40, 20)) + if err != nil { + t.Fatalf("OpenDB(%s): %v", url, err) + } + if got := handle.Stats().MaxOpenConnections; got != 1 { + t.Errorf("OpenDB(%s): MaxOpenConnections = %d; want 1 whatever the environment says", url, got) + } + handle.Close() + } +} + +// TestPostgres_idleLimitApplied measures the idle half against a real server. +// database/sql exposes the open limit through Stats() but not the idle one, so +// the only honest way to check SetMaxIdleConns took effect is to occupy several +// connections at once and count what the pool keeps when they are handed back. +func TestPostgres_idleLimitApplied(t *testing.T) { + dsn := dbtest.PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; nothing to run against PostgreSQL", dbtest.DSNEnv) + } + + const maxOpen, maxIdle = 4, 1 + handle, err := db.OpenDB(dsn, db.WithPool(maxOpen, maxIdle)) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + defer handle.Close() + + ctx := context.Background() + + // A transaction holds a connection for its lifetime, so four of them force + // four real connections open. No schema is touched: this is the pool, not the + // database. + txs := make([]*db.Tx, 0, maxOpen) + for i := 0; i < maxOpen; i++ { + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx %d: %v", i, err) + } + var one int + if err := tx.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil { + t.Fatalf("SELECT 1 in tx %d: %v", i, err) + } + txs = append(txs, tx) + } + if got := handle.Stats().OpenConnections; got != maxOpen { + t.Errorf("OpenConnections while %d transactions are live = %d; want %d", maxOpen, got, maxOpen) + } + + for i, tx := range txs { + if err := tx.Commit(); err != nil { + t.Fatalf("Commit %d: %v", i, err) + } + } + + // Every connection is back in the pool now; the idle limit decides how many + // are kept rather than closed. + if got := handle.Stats().Idle; got > maxIdle { + t.Errorf("Idle after returning %d connections = %d; want at most %d", maxOpen, got, maxIdle) + } + t.Logf("pool: max open %d, max idle %d, idle after release %d", + handle.Stats().MaxOpenConnections, maxIdle, handle.Stats().Idle) +} + +// setPoolEnv sets or unsets both knobs for one test. t.Setenv restores the +// previous value at the end and refuses to run in a parallel test, which is what +// keeps these from leaking into the rest of the package. +func setPoolEnv(t *testing.T, open, idle string) { + t.Helper() + for _, kv := range []struct{ key, value string }{ + {"DB_MAX_OPEN_CONNS", open}, + {"DB_MAX_IDLE_CONNS", idle}, + } { + if kv.value == "" { + // t.Setenv has no "unset" mode; do it by hand and restore by hand. + previous, had := os.LookupEnv(kv.key) + os.Unsetenv(kv.key) + t.Cleanup(func() { + if had { + os.Setenv(kv.key, previous) + } + }) + continue + } + t.Setenv(kv.key, kv.value) + } +} diff --git a/internal/db/postgres_test.go b/internal/db/postgres_test.go new file mode 100644 index 0000000..cfabaf1 --- /dev/null +++ b/internal/db/postgres_test.go @@ -0,0 +1,621 @@ +package db_test + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/url" + "os" + "regexp" + "slices" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// The PostgreSQL tests need a server, so they are opt-in: with +// CALNODE_TEST_POSTGRES_DSN unset they skip, and upstream CI and anyone running +// the SQLite build see no change. Point it at a database you are happy for a test +// to create and drop schemas in, e.g. +// +// CALNODE_TEST_POSTGRES_DSN=postgres://postgres:pw@127.0.0.1:5432/calnode?sslmode=disable +const postgresDSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// knownMigrationCount is the version a fully-migrated database must report. It is a +// sanity check on the embedded set, not a property of PostgreSQL, so it moves with +// every migration added — one named constant rather than the same literal in two +// assertions, so adding one is a single edit that cannot be half-done. +const knownMigrationCount = 62 + +// openTestPostgres returns a handle scoped to a schema of its own, created for +// this test and dropped when it finishes. A schema rather than a database because +// CREATE DATABASE cannot run inside a transaction and cannot be reached over the +// same connection, and a private schema is enough: goose_db_version and every +// table land inside it, so tests neither collide nor leave anything behind. +func openTestPostgres(t *testing.T) *db.DB { + t.Helper() + + dsn := os.Getenv(postgresDSNEnv) + if dsn == "" { + t.Skipf("%s not set; skipping PostgreSQL tests", postgresDSNEnv) + } + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("db.OpenDB(admin): %v", err) + } + defer admin.Close() + + // Random hex, not a test name: this is interpolated into DDL, and an + // identifier we generated byte by byte cannot carry a quote or a keyword. + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("rand: %v", err) + } + schema := "calnode_test_" + hex.EncodeToString(buf) + + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatalf("create schema %s: %v", schema, err) + } + + handle, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("db.OpenDB(%s): %v", schema, err) + } + + t.Cleanup(func() { + handle.Close() + + cleanup, err := db.OpenDB(dsn) + if err != nil { + t.Errorf("reopen to drop schema %s: %v", schema, err) + return + } + defer cleanup.Close() + if _, err := cleanup.Exec(`DROP SCHEMA ` + schema + ` CASCADE`); err != nil { + t.Errorf("drop schema %s: %v", schema, err) + } + }) + + return handle +} + +// withSearchPath adds search_path to the DSN. pgx forwards unrecognised URL +// parameters as server runtime parameters, so this pins every session on the +// handle to the test's schema without a per-connection hook. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse %s: %v", postgresDSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +func TestPostgres_openDialectAndPool(t *testing.T) { + handle := openTestPostgres(t) + + if got := handle.Dialect(); got != db.DialectPostgres { + t.Errorf("dialect = %v; want %v", got, db.DialectPostgres) + } + // The SQLite single-connection rule must not have followed us here. + if got := handle.Stats().MaxOpenConnections; got != 10 { + t.Errorf("MaxOpenConnections = %d; want 10", got) + } + if err := handle.Ping(); err != nil { + t.Fatalf("Ping: %v", err) + } +} + +func TestPostgres_migrateToTargetVersion(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + // Before migrating, the goose bookkeeping table is absent → not ready. + if ready, _ := db.SchemaReady(ctx, handle.DB); ready { + t.Error("SchemaReady = true before migrations ran; want false") + } + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + target, err := db.TargetVersion() + if err != nil { + t.Fatalf("TargetVersion: %v", err) + } + applied, err := db.AppliedVersion(ctx, handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != target { + t.Errorf("applied version = %d; want target %d", applied, target) + } + if target != knownMigrationCount { + t.Errorf("target version = %d; want %d (sanity check against the known migration set)", target, knownMigrationCount) + } + + ready, err := db.SchemaReady(ctx, handle.DB) + if err != nil { + t.Fatalf("SchemaReady after migrate: %v", err) + } + if !ready { + t.Error("SchemaReady = false after migrations ran; want true") + } +} + +func TestPostgres_migrateIdempotent(t *testing.T) { + handle := openTestPostgres(t) + + for range 2 { + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + } +} + +// TestPostgres_migrateViaPackageFunc covers the compatibility path: Migrate on a +// bare *sql.DB has to recover the dialect from the driver, and getting that wrong +// would run the SQLite migrations against Postgres. +func TestPostgres_migrateViaPackageFunc(t *testing.T) { + handle := openTestPostgres(t) + + if err := db.Migrate(handle.DB); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + + applied, err := db.AppliedVersion(context.Background(), handle.DB) + if err != nil { + t.Fatalf("AppliedVersion: %v", err) + } + if applied != knownMigrationCount { + t.Errorf("applied version = %d; want %d", applied, knownMigrationCount) + } +} + +func TestPostgres_tablesExist(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + tables := []string{ + "users", "api_keys", "teams", "team_members", + "event_types", "event_type_questions", + "availability_rules", "availability_overrides", + "calendar_connections", + "bookings", "booking_attendees", "booking_answers", + "webhooks", "webhook_deliveries", + "jobs", + // Tables added by later migrations, so this also proves the whole set ran. + "server_settings", "crypto_keystore", "event_type_hosts", "booking_hosts", + "idempotency_keys", "oauth_access_tokens", "zoom_connections", + "magic_link_tokens", "recordings", "meeting_consents", "transcripts", + "notes", "connection_calendars", + } + + for _, table := range tables { + var name string + err := handle.QueryRow( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = ?`, table, + ).Scan(&name) + if err != nil { + t.Errorf("table %q not found after migration: %v", table, err) + } + } +} + +// TestPostgres_doubleBookingIndex checks the partial unique index carried over as +// a partial index. Without the WHERE clause it would block a re-book of a +// cancelled slot, which is the behaviour the SQLite index deliberately avoids. +func TestPostgres_doubleBookingIndex(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + var indexdef string + err := handle.QueryRow( + `SELECT indexdef FROM pg_indexes + WHERE schemaname = current_schema() AND indexname = 'idx_bookings_no_double'`, + ).Scan(&indexdef) + if err != nil { + t.Fatalf("double-booking guard index not found: %v", err) + } + + for _, want := range []string{"UNIQUE", "host_id", "start_at", "WHERE"} { + if !regexp.MustCompile(want).MatchString(indexdef) { + t.Errorf("index definition %q is missing %q", indexdef, want) + } + } +} + +// TestPostgres_flagColumnsStayIntegers is the load-bearing type check: the 0/1 +// flag columns are read into Go ints all over the codebase, so a translation that +// turned them into BOOLEAN would break every one of those scans. +func TestPostgres_flagColumnsStayIntegers(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + flags := []struct{ table, column string }{ + {"users", "is_admin"}, + {"users", "is_owner"}, + {"users", "email_login"}, + {"users", "notify_confirmation"}, + {"event_types", "is_active"}, + {"event_types", "is_public"}, + {"event_types", "show_taken_slots"}, + {"event_type_questions", "required"}, + {"availability_overrides", "is_available"}, + {"calendar_connections", "check_conflicts"}, + {"calendar_connections", "is_destination"}, + {"connection_calendars", "check_conflicts"}, + {"booking_attendees", "is_organizer"}, + {"booking_hosts", "is_primary"}, + {"booking_hosts", "needs_sync"}, + {"server_settings", "smtp_tls"}, + {"server_settings", "smtp_starttls"}, + {"server_settings", "llm_enabled"}, + {"server_settings", "recordings_enabled"}, + {"server_settings", "notetaker_enabled"}, + } + + for _, f := range flags { + var dataType string + err := handle.QueryRow( + `SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?`, + f.table, f.column, + ).Scan(&dataType) + if err != nil { + t.Errorf("%s.%s: %v", f.table, f.column, err) + continue + } + if dataType != "smallint" && dataType != "integer" { + t.Errorf("%s.%s is %q; want an integer type (763 call sites scan these into Go ints)", + f.table, f.column, dataType) + } + } + + // Prove the scan, not just the catalogue: server_settings is seeded by 00011. + var smtpTLS, smtpStartTLS int + if err := handle.QueryRow( + `SELECT smtp_tls, smtp_starttls FROM server_settings WHERE id = ?`, 1, + ).Scan(&smtpTLS, &smtpStartTLS); err != nil { + t.Fatalf("scan flag columns into int: %v", err) + } + if smtpTLS != 0 || smtpStartTLS != 1 { + t.Errorf("seeded flags = (%d, %d); want (0, 1)", smtpTLS, smtpStartTLS) + } +} + +// TestPostgres_timestampDefaultsMatchSQLite pins the format of the defaulted +// timestamp columns. They stay TEXT holding the same strings SQLite writes, +// because every time column in this schema is compared and sorted as a string. +func TestPostgres_timestampDefaultsMatchSQLite(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // users.created_at defaults to SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now'). + if _, err := handle.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("insert user: %v", err) + } + + var createdAt string + if err := handle.QueryRow(`SELECT created_at FROM users WHERE id = ?`, "u1").Scan(&createdAt); err != nil { + t.Fatalf("read created_at: %v", err) + } + const isoMillis = `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$` + if !regexp.MustCompile(isoMillis).MatchString(createdAt) { + t.Errorf("users.created_at = %q; want SQLite's %s form", createdAt, isoMillis) + } + + // server_settings.updated_at defaults to SQLite's datetime('now'). + var updatedAt string + if err := handle.QueryRow(`SELECT updated_at FROM server_settings WHERE id = ?`, 1).Scan(&updatedAt); err != nil { + t.Fatalf("read updated_at: %v", err) + } + const secondsUTC = `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$` + if !regexp.MustCompile(secondsUTC).MatchString(updatedAt) { + t.Errorf("server_settings.updated_at = %q; want SQLite's %s form", updatedAt, secondsUTC) + } +} + +// TestPostgres_identityPrimaryKey covers the one column that relied on SQLite's +// rowid: keyvault.go inserts a keystore entry without an id. +func TestPostgres_identityPrimaryKey(t *testing.T) { + handle := openTestPostgres(t) + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.Exec(` + INSERT INTO crypto_keystore + (label, wrapped_dek, kdf, kdf_salt, kdf_params, dek_version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, + "primary", []byte("wrapped"), "argon2id", []byte("salt"), `{"m":65536,"t":3,"p":2}`, + "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); err != nil { + t.Fatalf("insert keystore entry without an id: %v", err) + } + + var id int64 + var wrapped []byte + if err := handle.QueryRow( + `SELECT id, wrapped_dek FROM crypto_keystore WHERE label = ?`, "primary", + ).Scan(&id, &wrapped); err != nil { + t.Fatalf("read keystore entry: %v", err) + } + if id == 0 { + t.Error("id was not assigned; the identity column is missing") + } + if string(wrapped) != "wrapped" { + t.Errorf("wrapped_dek = %q; want %q (BYTEA round trip)", wrapped, "wrapped") + } +} + +// TestPostgres_wrapperRebinds is the end-to-end proof that the wrapper is what +// makes ?-placeholder SQL work here: the same statement through the bare handle +// must fail. +func TestPostgres_wrapperRebinds(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + if _, err := handle.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("ExecContext through the wrapper: %v", err) + } + + if _, err := handle.DB.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u2", "b@example.com", "B"); err == nil { + t.Error("? placeholders succeeded on the bare *sql.DB; the rebinding test proves nothing") + } + + var name string + if err := handle.QueryRowContext(ctx, + `SELECT name FROM users WHERE email = ? AND is_admin = ?`, "a@example.com", 0).Scan(&name); err != nil { + t.Fatalf("QueryRowContext: %v", err) + } + if name != "A" { + t.Errorf("name = %q; want %q", name, "A") + } + + rows, err := handle.QueryContext(ctx, `SELECT id FROM users WHERE email = ?`, "a@example.com") + if err != nil { + t.Fatalf("QueryContext: %v", err) + } + count := 0 + for rows.Next() { + count++ + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + rows.Close() + if count != 1 { + t.Errorf("rows returned = %d; want 1", count) + } + + tx, err := handle.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, "B", "u1"); err != nil { + tx.Rollback() + t.Fatalf("tx.ExecContext: %v", err) + } + if err := tx.QueryRowContext(ctx, `SELECT name FROM users WHERE id = ?`, "u1").Scan(&name); err != nil { + tx.Rollback() + t.Fatalf("tx.QueryRowContext: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("tx.Commit: %v", err) + } + if name != "B" { + t.Errorf("name after tx update = %q; want %q", name, "B") + } + + stmt, err := handle.PrepareContext(ctx, `SELECT COUNT(*) FROM users WHERE email = ?`) + if err != nil { + t.Fatalf("PrepareContext: %v", err) + } + defer stmt.Close() + var n int + if err := stmt.QueryRowContext(ctx, "a@example.com").Scan(&n); err != nil { + t.Fatalf("stmt.QueryRowContext: %v", err) + } + if n != 1 { + t.Errorf("count = %d; want 1", n) + } +} + +// TestPostgres_partialUniqueIndexEnforced exercises the double-booking guard +// itself rather than its definition: the index is the only thing standing between +// two concurrent bookings and a double-booked host now that Postgres has a +// connection pool (see openPostgres). +func TestPostgres_partialUniqueIndexEnforced(t *testing.T) { + handle := openTestPostgres(t) + ctx := context.Background() + + if err := handle.Migrate(); err != nil { + t.Fatalf("Migrate: %v", err) + } + + seed := []struct { + query string + args []any + }{ + {`INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, []any{"h1", "h@example.com", "Host"}}, + {`INSERT INTO event_types (id, user_id, slug, name, duration_minutes) VALUES (?, ?, ?, ?, ?)`, + []any{"e1", "h1", "intro", "Intro", 30}}, + {`INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + []any{"b1", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"}}, + } + for _, s := range seed { + if _, err := handle.ExecContext(ctx, s.query, s.args...); err != nil { + t.Fatalf("seed %q: %v", s.query, err) + } + } + + // Same host, same start: refused. + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b2", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err == nil { + t.Error("second booking at the same start time was accepted; the guard index is not enforcing") + } + + // Cancelling the first frees the slot — the point of the WHERE clause. + if _, err := handle.ExecContext(ctx, + `UPDATE bookings SET status = 'cancelled' WHERE id = ?`, "b1"); err != nil { + t.Fatalf("cancel booking: %v", err) + } + if _, err := handle.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at) VALUES (?, ?, ?, ?, ?)`, + "b3", "e1", "h1", "2026-01-01T10:00:00.000Z", "2026-01-01T10:30:00.000Z"); err != nil { + t.Errorf("re-booking a cancelled slot was refused: %v", err) + } +} + +// TestPostgres_schemaMatchesSQLite migrates both engines and compares the result, +// table by table and column by column. It is the check the translation actually +// needs: every other test here says a specific thing survived, and this one says +// nothing was quietly dropped, renamed or added along the way. +// +// Names only, not types: TEXT versus text and SMALLINT versus integer are the +// translation working as intended, and the types that do matter are pinned by +// TestPostgres_flagColumnsStayIntegers. +func TestPostgres_schemaMatchesSQLite(t *testing.T) { + postgres := openTestPostgres(t) + if err := postgres.Migrate(); err != nil { + t.Fatalf("Migrate(postgres): %v", err) + } + + sqlite, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB(sqlite): %v", err) + } + defer sqlite.Close() + if err := sqlite.Migrate(); err != nil { + t.Fatalf("Migrate(sqlite): %v", err) + } + + sqliteSchema := sqliteColumns(t, sqlite) + postgresSchema := postgresColumns(t, postgres) + + // Guard against a vacuous pass: an empty map on either side would satisfy + // every comparison below. + if len(sqliteSchema) < 30 { + t.Fatalf("only %d tables found on SQLite; the comparison would prove nothing", len(sqliteSchema)) + } + + for table, want := range sqliteSchema { + got, ok := postgresSchema[table] + if !ok { + t.Errorf("table %q exists on SQLite and not on Postgres", table) + continue + } + if !slices.Equal(want, got) { + t.Errorf("table %q columns differ\n sqlite: %v\n pgsql: %v", table, want, got) + } + } + for table := range postgresSchema { + if _, ok := sqliteSchema[table]; !ok { + t.Errorf("table %q exists on Postgres and not on SQLite", table) + } + } +} + +// sqliteColumns maps table name to sorted column names, skipping SQLite's own +// bookkeeping tables (sqlite_sequence appears because goose's version table uses +// AUTOINCREMENT). +func sqliteColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + t.Fatalf("list sqlite tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + // PRAGMA table_info takes no placeholder; the name comes from + // sqlite_master, not from input. + rows, err := handle.Query(`SELECT name FROM pragma_table_info('` + table + `')`) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +// postgresColumns maps table name to sorted column names for the test schema. +func postgresColumns(t *testing.T, handle *db.DB) map[string][]string { + t.Helper() + + rows, err := handle.Query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' + ORDER BY table_name`) + if err != nil { + t.Fatalf("list postgres tables: %v", err) + } + tables := scanStrings(t, rows) + + schema := make(map[string][]string, len(tables)) + for _, table := range tables { + rows, err := handle.Query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ?`, table) + if err != nil { + t.Fatalf("columns of %q: %v", table, err) + } + cols := scanStrings(t, rows) + slices.Sort(cols) + schema[table] = cols + } + return schema +} + +func scanStrings(t *testing.T, rows *db.Rows) []string { + t.Helper() + defer rows.Close() + + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, s) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err: %v", err) + } + return out +} diff --git a/internal/db/rebind.go b/internal/db/rebind.go new file mode 100644 index 0000000..f0d5639 --- /dev/null +++ b/internal/db/rebind.go @@ -0,0 +1,113 @@ +package db + +import ( + "strconv" + "strings" +) + +// Rebind rewrites the ? placeholders in query to PostgreSQL's $1…$n form, +// numbering them left to right so the caller's argument order is unchanged. +// +// It is a small lexer rather than a string replace because a ? inside a string +// literal, a quoted identifier or a comment is data, not a placeholder. +// Renumbering one of those corrupts the statement, and the corruption surfaces +// at runtime — as a wrong result or a confusing type error — a long way from the +// code that caused it. +// +// Not recognised, because Calnode's SQL contains none of them and guessing would +// be worse than saying so: PostgreSQL escape strings (E'...', where a backslash +// escapes the closing quote), dollar-quoted bodies ($tag$…$tag$), and +// MSSQL-style [bracketed] identifiers. Statements using those must be written per +// dialect with Dialect.SQL and their own $n. +func Rebind(query string) string { + // Overwhelmingly the common case for DDL and for already-converted SQL. + if !strings.Contains(query, "?") { + return query + } + + var b strings.Builder + b.Grow(len(query) + 8) + + n := 0 + for i := 0; i < len(query); { + switch c := query[i]; { + case c == '\'' || c == '"' || c == '`': + // A quoted run is copied verbatim: single quotes are string + // literals, and both double quotes and backticks are identifier + // quotes SQLite accepts. + end := endOfQuoted(query, i) + b.WriteString(query[i:end]) + i = end + case c == '-' && i+1 < len(query) && query[i+1] == '-': + end := endOfLineComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '/' && i+1 < len(query) && query[i+1] == '*': + end := endOfBlockComment(query, i) + b.WriteString(query[i:end]) + i = end + case c == '?': + n++ + b.WriteByte('$') + b.WriteString(strconv.Itoa(n)) + i++ + default: + b.WriteByte(c) + i++ + } + } + + return b.String() +} + +// endOfQuoted returns the index just past the quoted run starting at i, where +// query[i] is the opening quote. A doubled quote is an escaped quote and does not +// close the run — that is SQL's only escape under both engines' default settings. +// An unterminated run extends to the end of the string, which hands the malformed +// statement to the engine to reject instead of mangling the remainder. +func endOfQuoted(query string, i int) int { + quote := query[i] + for j := i + 1; j < len(query); j++ { + if query[j] != quote { + continue + } + if j+1 < len(query) && query[j+1] == quote { + j++ // skip the escaped quote; the loop's j++ skips its partner + continue + } + return j + 1 + } + return len(query) +} + +// endOfLineComment returns the index of the newline ending the -- comment at i, +// so the newline itself is copied by the caller and line structure survives. +func endOfLineComment(query string, i int) int { + if k := strings.IndexByte(query[i:], '\n'); k >= 0 { + return i + k + } + return len(query) +} + +// endOfBlockComment returns the index just past the /* */ comment at i. Nesting +// is honoured: PostgreSQL nests block comments, so treating the first */ as the +// end would drop back into "SQL" while still inside a comment. +func endOfBlockComment(query string, i int) int { + depth := 0 + for j := i; j+1 < len(query); { + switch { + case query[j] == '/' && query[j+1] == '*': + depth++ + j += 2 + case query[j] == '*' && query[j+1] == '/': + depth-- + j += 2 + if depth == 0 { + return j + } + default: + j++ + } + } + return len(query) +} diff --git a/internal/db/rebind_test.go b/internal/db/rebind_test.go new file mode 100644 index 0000000..e469f61 --- /dev/null +++ b/internal/db/rebind_test.go @@ -0,0 +1,147 @@ +package db_test + +import ( + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +func TestRebind(t *testing.T) { + tests := []struct { + name string + query string + want string + }{{ + name: "no placeholders", + query: `SELECT id FROM users`, + want: `SELECT id FROM users`, + }, { + name: "one placeholder", + query: `SELECT id FROM users WHERE email = ?`, + want: `SELECT id FROM users WHERE email = $1`, + }, { + name: "numbered left to right", + query: `UPDATE users SET name = ?, email = ? WHERE id = ?`, + want: `UPDATE users SET name = $1, email = $2 WHERE id = $3`, + }, { + name: "string literal holding a question mark", + query: `SELECT id FROM event_types WHERE name = 'why?' AND slug = ?`, + want: `SELECT id FROM event_types WHERE name = 'why?' AND slug = $1`, + }, { + name: "escaped quote inside a literal does not end it", + query: `SELECT ? WHERE reason = 'it''s a ?' AND id = ?`, + want: `SELECT $1 WHERE reason = 'it''s a ?' AND id = $2`, + }, { + name: "double-quoted identifier", + query: `SELECT "odd?column" FROM t WHERE id = ?`, + want: `SELECT "odd?column" FROM t WHERE id = $1`, + }, { + name: "escaped double quote inside an identifier", + query: `SELECT "a""?b" FROM t WHERE id = ?`, + want: `SELECT "a""?b" FROM t WHERE id = $1`, + }, { + name: "backtick identifier", + query: "SELECT `weird?` FROM t WHERE id = ?", + want: "SELECT `weird?` FROM t WHERE id = $1", + }, { + name: "line comment", + query: "SELECT id -- what about ?\nFROM t WHERE id = ?", + want: "SELECT id -- what about ?\nFROM t WHERE id = $1", + }, { + name: "line comment at end of input", + query: `SELECT id FROM t WHERE id = ? -- trailing ?`, + want: `SELECT id FROM t WHERE id = $1 -- trailing ?`, + }, { + name: "block comment", + query: `SELECT /* ? not a param ? */ id FROM t WHERE id = ?`, + want: `SELECT /* ? not a param ? */ id FROM t WHERE id = $1`, + }, { + name: "nested block comment", + query: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = ?`, + want: `SELECT /* outer /* inner ? */ still ? */ id FROM t WHERE id = $1`, + }, { + name: "division is not a comment", + query: `SELECT price_cents / 100 FROM event_types WHERE id = ?`, + want: `SELECT price_cents / 100 FROM event_types WHERE id = $1`, + }, { + name: "negative number is not a comment", + query: `SELECT id FROM t WHERE n = -1 AND id = ?`, + want: `SELECT id FROM t WHERE n = -1 AND id = $1`, + }, { + name: "unterminated literal swallows the rest", + query: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + want: `SELECT id FROM t WHERE s = 'oops ? and id = ?`, + }, { + name: "unterminated block comment swallows the rest", + query: `SELECT id FROM t /* oops ? and id = ?`, + want: `SELECT id FROM t /* oops ? and id = ?`, + }, { + name: "already rebound is left alone", + query: `SELECT id FROM t WHERE id = $1`, + want: `SELECT id FROM t WHERE id = $1`, + }, { + name: "multi-line statement with a comment block", + query: "-- +goose Up\nINSERT INTO t (a, b) VALUES (?, ?)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + want: "-- +goose Up\nINSERT INTO t (a, b) VALUES ($1, $2)\n -- ? in a comment\n ON CONFLICT DO NOTHING", + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := db.Rebind(tt.query); got != tt.want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", tt.query, got, tt.want) + } + }) + } +} + +// TestRebind_manyPlaceholders covers the two-digit boundary: a naive +// implementation that scanned for "$1" or replaced in a fixed order would go +// wrong at ten. +func TestRebind_manyPlaceholders(t *testing.T) { + const n = 12 + query := "INSERT INTO t VALUES (" + strings.Repeat("?, ", n-1) + "?)" + + got := db.Rebind(query) + + want := "INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)" + if got != want { + t.Errorf("Rebind(%q)\n got %q\nwant %q", query, got, want) + } +} + +func TestDialectRebind(t *testing.T) { + const query = `SELECT id FROM users WHERE email = ? AND is_admin = ?` + + if got := db.DialectSQLite.Rebind(query); got != query { + t.Errorf("DialectSQLite.Rebind rewrote the query: %q", got) + } + + want := `SELECT id FROM users WHERE email = $1 AND is_admin = $2` + if got := db.DialectPostgres.Rebind(query); got != want { + t.Errorf("DialectPostgres.Rebind = %q; want %q", got, want) + } +} + +func TestDialectSQL(t *testing.T) { + const ( + sqlite = `UPDATE server_settings SET updated_at = datetime('now') WHERE id = 1` + postgres = `UPDATE server_settings SET updated_at = to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') WHERE id = 1` + ) + + if got := db.DialectSQLite.SQL(sqlite, postgres); got != sqlite { + t.Errorf("DialectSQLite.SQL = %q; want the sqlite statement", got) + } + if got := db.DialectPostgres.SQL(sqlite, postgres); got != postgres { + t.Errorf("DialectPostgres.SQL = %q; want the postgres statement", got) + } +} + +func TestDialectString(t *testing.T) { + if got := db.DialectSQLite.String(); got != "sqlite" { + t.Errorf("DialectSQLite.String() = %q; want %q", got, "sqlite") + } + if got := db.DialectPostgres.String(); got != "postgres" { + t.Errorf("DialectPostgres.String() = %q; want %q", got, "postgres") + } +} diff --git a/internal/db/rls_proof_test.go b/internal/db/rls_proof_test.go new file mode 100644 index 0000000..a7fb9f6 --- /dev/null +++ b/internal/db/rls_proof_test.go @@ -0,0 +1,328 @@ +package db_test + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "net/url" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// The row-level-security proof. +// +// ⛔ A SUPERUSER HANDLE PROVES NOTHING HERE. Superusers bypass row-level security +// unconditionally, and so does any role with BYPASSRLS, and so does the table's +// OWNER unless FORCE is set. The suite's DSN is normally the superuser that owns +// the test schema, so asserting isolation through it would pass whether the +// policies existed or not. Everything below therefore runs as a role created for +// the test with NOBYPASSRLS, which owns nothing, and the test SKIPS LOUDLY rather +// than falling back if such a role cannot be created or turns out to bypass +// anyway. +// +// The two halves are both required. The negative half (an unscoped read sees +// nothing, an unscoped write is refused) is what the policy is for. The positive +// half (a scoped read sees its own rows, a scoped write lands in its own +// workspace with no column named) is what stops the negative half from being +// satisfied by a database that simply does not work. + +// appRole is a non-superuser application role with a handle opened as it. +type appRole struct { + name string + dsn string // the suite's DSN rewritten for this role, search_path pinned + schema string + handle *db.DB +} + +// newAppRole creates a NOBYPASSRLS role, grants it the test schema's tables, and +// returns a handle connected as that role with search_path pointed at the schema. +// +// It reads the schema name back from the owner handle rather than taking it as an +// argument, because dbtest owns that name and nothing else exposes it. +func newAppRole(t *testing.T, owner *db.DB) *appRole { + t.Helper() + + dsn := dbtest.PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; the row-level-security proof needs a real server", dbtest.DSNEnv) + } + + var schema string + if err := owner.QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatalf("read current_schema: %v", err) + } + + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("rand: %v", err) + } + // The name is hex we generated, so interpolating it into DDL cannot carry a + // quote or a keyword. PostgreSQL takes no placeholder for a role name. + name := "calnode_app_" + hex.EncodeToString(buf) + const password = "rls_proof_pw" // local test role, dropped at the end of the test + + if _, err := owner.Exec(`CREATE ROLE ` + name + ` LOGIN PASSWORD '` + password + `' NOBYPASSRLS`); err != nil { + t.Skipf("LOUD SKIP: cannot CREATE ROLE on this server (%v) — the row-level-security proof "+ + "REQUIRES a NOBYPASSRLS role, and asserting isolation through the suite's own "+ + "superuser DSN would pass with or without the policies. Point %s at a server where "+ + "the test role may create roles.", err, dbtest.DSNEnv) + } + t.Cleanup(func() { + // DROP OWNED also revokes the privileges granted below, which DROP ROLE + // would otherwise refuse over. + if _, err := owner.Exec(`DROP OWNED BY ` + name); err != nil { + t.Errorf("drop owned by %s: %v", name, err) + } + if _, err := owner.Exec(`DROP ROLE ` + name); err != nil { + t.Errorf("drop role %s: %v", name, err) + } + }) + + for _, stmt := range []string{ + `GRANT USAGE ON SCHEMA ` + schema + ` TO ` + name, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ` + schema + ` TO ` + name, + `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ` + schema + ` TO ` + name, + } { + if _, err := owner.Exec(stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + + roleDSN := appDSN(t, dsn, name, password, schema) + handle, err := db.OpenDB(roleDSN) + if err != nil { + t.Fatalf("open as %s: %v", name, err) + } + t.Cleanup(func() { handle.Close() }) + + // Belt and braces against the thing that makes this whole test vacuous. + var super, bypass bool + if err := handle.QueryRow( + `SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`).Scan(&super, &bypass); err != nil { + t.Fatalf("read own role attributes: %v", err) + } + if super || bypass { + t.Skipf("LOUD SKIP: the application role reports rolsuper=%v rolbypassrls=%v, so it BYPASSES "+ + "row-level security and every assertion below would pass vacuously", super, bypass) + } + if !ownsNothing(t, handle, schema) { + t.Skipf("LOUD SKIP: the application role owns tables in %s, and a table's owner is exempt from "+ + "its own policy unless FORCE is set", schema) + } + + return &appRole{name: name, dsn: roleDSN, schema: schema, handle: handle} +} + +// ownsNothing reports whether the connected role owns none of the schema's +// tables. FORCE ROW LEVEL SECURITY covers the owner too, so this is belt and +// braces rather than the guarantee — but a role that owns the tables is not the +// role this test means to be. +func ownsNothing(t *testing.T, handle *db.DB, schema string) bool { + t.Helper() + var n int + if err := handle.QueryRow( + `SELECT COUNT(*) FROM pg_tables WHERE schemaname = ? AND tableowner = current_user`, + schema).Scan(&n); err != nil { + t.Fatalf("count owned tables: %v", err) + } + return n == 0 +} + +// appDSN rewrites the suite's DSN for the application role. pgx forwards +// unrecognised query parameters as runtime parameters, which is how search_path +// reaches the session — the same mechanism dbtest uses. +func appDSN(t *testing.T, dsn, user, password, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse %s: %v", dbtest.DSNEnv, err) + } + u.User = url.UserPassword(user, password) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +// TestPostgres_rlsIsolatesAnUnprivilegedRole is the gate on migration 00060 plus +// db.EnableRLS: with the policies live and no workspace bound, the application +// role can neither read nor write; with one bound, it reads and writes exactly +// its own workspace. +func TestPostgres_rlsIsolatesAnUnprivilegedRole(t *testing.T) { + owner := dbtest.RequirePostgres(t) + ctx := context.Background() + + seedTwoWorkspaces(t, owner) + if err := owner.EnableRLS(ctx); err != nil { + t.Fatalf("EnableRLS: %v", err) + } + + app := newAppRole(t, owner) + + // Positive control on the DATA rather than the policy: the rows exist, so a + // zero below is the policy hiding them and not an empty table. + if got := ownerCount(t, owner, ""); got != 2 { + t.Fatalf("the owner sees %d users; want 2 — the fixture did not land", got) + } + + t.Run("unbound reads nothing", func(t *testing.T) { + conn := pin(t, app.handle) + var n int + if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Errorf("an unbound session sees %d of 2 users; want 0 — an unset app.workspace_id must match no row", n) + } + }) + + t.Run("unbound write is refused and lands nowhere", func(t *testing.T) { + conn := pin(t, app.handle) + _, err := conn.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`, "u-unbound", "unbound@example.com", "U") + if err == nil { + t.Fatal("an unbound INSERT succeeded; the policy's WITH CHECK must refuse it") + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "42501" { + t.Errorf("unbound INSERT failed with %v; want SQLSTATE 42501 (insufficient_privilege, the RLS violation)", err) + } + // ⛔ The failure mode that matters: the column default is + // COALESCE(current_setting(...), 'default'), so a policy that did not + // refuse this would have written the row into the DEFAULT workspace. + if got := ownerCount(t, owner, db.DefaultWorkspaceID); got != 0 { + t.Errorf("%d users landed in the default workspace; the refused INSERT must leave nothing behind", got) + } + if got := ownerCount(t, owner, ""); got != 2 { + t.Errorf("the owner now sees %d users; want the original 2", got) + } + }) + + t.Run("bound reads and writes exactly one workspace", func(t *testing.T) { + conn := pin(t, app.handle) + if _, err := conn.ExecContext(ctx, `SELECT set_config('app.workspace_id', $1, false)`, "ws-a"); err != nil { + t.Fatalf("bind workspace: %v", err) + } + + var n int + if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("a session bound to ws-a sees %d users; want 1", n) + } + + var email string + if err := conn.QueryRowContext(ctx, `SELECT email FROM users`).Scan(&email); err != nil { + t.Fatalf("read: %v", err) + } + if email != "a@example.com" { + t.Errorf("bound to ws-a, read %q; want a@example.com", email) + } + + // No column named, which is D1's whole point: the ~200 INSERT statements + // in the tree need no edit. + if _, err := conn.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`, "u-a2", "a2@example.com", "A2"); err != nil { + t.Fatalf("bound INSERT: %v", err) + } + var landed string + if err := conn.QueryRowContext(ctx, + `SELECT workspace_id FROM users WHERE id = $1`, "u-a2").Scan(&landed); err != nil { + t.Fatalf("read back the inserted row: %v", err) + } + if landed != "ws-a" { + t.Errorf("the inserted row landed in %q; want ws-a", landed) + } + + // And B is still invisible, by the value the fixture put there. + var visible int + if err := conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE email = $1`, "b@example.com").Scan(&visible); err != nil { + t.Fatalf("count B's user: %v", err) + } + if visible != 0 { + t.Errorf("bound to ws-a, B's user is visible") + } + }) + + t.Run("naming another workspace explicitly is refused", func(t *testing.T) { + conn := pin(t, app.handle) + if _, err := conn.ExecContext(ctx, `SELECT set_config('app.workspace_id', $1, false)`, "ws-a"); err != nil { + t.Fatalf("bind workspace: %v", err) + } + _, err := conn.ExecContext(ctx, + `INSERT INTO users (id, email, name, workspace_id) VALUES ($1, $2, $3, $4)`, + "u-cross", "cross@example.com", "X", "ws-b") + if err == nil { + t.Fatal("writing into another workspace succeeded; WITH CHECK must refuse it") + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != "42501" { + t.Errorf("cross-workspace INSERT failed with %v; want SQLSTATE 42501", err) + } + }) +} + +// seedTwoWorkspaces writes one workspace and one user each for ws-a and ws-b +// through the owner handle, naming workspace_id explicitly because the owner +// binds nothing. +func seedTwoWorkspaces(t *testing.T, owner *db.DB) { + t.Helper() + for _, ws := range []string{"ws-a", "ws-b"} { + if _, err := owner.Exec( + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', 'active')`, + ws, ws, ws+".example.com"); err != nil { + t.Fatalf("seed workspace %s: %v", ws, err) + } + } + for _, row := range []struct{ id, email, ws string }{ + {"u-a", "a@example.com", "ws-a"}, + {"u-b", "b@example.com", "ws-b"}, + } { + if _, err := owner.Exec( + `INSERT INTO users (id, email, name, workspace_id) VALUES (?, ?, ?, ?)`, + row.id, row.email, row.id, row.ws); err != nil { + t.Fatalf("seed user %s: %v", row.id, err) + } + } +} + +// ownerCount counts users through the owner handle, which is exempt from the +// policy, optionally narrowed to one workspace. An empty workspace means all. +func ownerCount(t *testing.T, owner *db.DB, workspace string) int { + t.Helper() + var n int + var err error + if workspace == "" { + err = owner.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + } else { + err = owner.QueryRow(`SELECT COUNT(*) FROM users WHERE workspace_id = ?`, workspace).Scan(&n) + } + if err != nil { + t.Fatalf("count through the owner handle: %v", err) + } + return n +} + +// pin takes one connection out of the pool and keeps it for the caller. +// +// set_config(..., false) is SESSION scoped, so the statements that depend on it +// have to run on the same connection. Statements issued through a *sql.Conn are +// NOT rebound by the wrapper, so everything here is written in PostgreSQL's own +// $n form. +func pin(t *testing.T, handle *db.DB) *sql.Conn { + t.Helper() + conn, err := handle.Conn(context.Background()) + if err != nil { + t.Fatalf("acquire connection: %v", err) + } + t.Cleanup(func() { conn.Close() }) + return conn +} diff --git a/internal/db/tenancy.go b/internal/db/tenancy.go new file mode 100644 index 0000000..c2b50f7 --- /dev/null +++ b/internal/db/tenancy.go @@ -0,0 +1,150 @@ +package db + +import ( + "context" + "fmt" +) + +// DefaultWorkspaceID is the tenant every row belongs to when MULTI_TENANT is +// unset. It is a literal rather than a generated id so that a single-tenant +// database migrated by 00060 reads the same on every machine, and so the SQLite +// column default can be a constant. +const DefaultWorkspaceID = "default" + +// WorkspaceSetting is the PostgreSQL session parameter a tenant handle binds +// before every statement. The row-level-security policies compare +// workspace_id against it, and its two-argument current_setting form returns +// NULL when it has never been set — so an unbound session matches no row. +const WorkspaceSetting = "app.workspace_id" + +// TenantTables lists every table that carries a workspace_id and a +// row-level-security policy. It is the Go copy of the list in the header of +// migration 00060, and TestTenancy_tableListsCoverTheSchema fails if either side +// drifts: a table added by a later migration must be classified here or in +// ExemptTables, so "nobody remembered to protect it" is not a possible outcome. +// +// Sorted, because the migration's ALTER and CREATE POLICY blocks are sorted and +// a reader diffing the two should not have to reorder one of them first. +var TenantTables = []string{ + "api_keys", + "availability_overrides", + "availability_rules", + "booking_answers", + "booking_attendees", + "booking_hosts", + "booking_manage_tokens", + "bookings", + "calendar_connections", + "connection_calendars", + "event_type_hosts", + "event_type_questions", + "event_type_reminders", + "event_types", + "idempotency_keys", + "invite_tokens", + "jobs", + "magic_link_tokens", + "meeting_consents", + "notes", + "oauth_access_tokens", + "oauth_auth_codes", + "recordings", + "server_settings", + "sessions", + "team_members", + "teams", + "transcripts", + "users", + "webhook_deliveries", + "webhooks", + "zoom_connections", +} + +// ExemptTables lists the tables that deliberately have no workspace_id. +// +// - workspaces is the tenant root. It carries its own SELECT-only policy for +// the application role instead: the host resolver, the suspended check and +// publicURL all read it, and only the platform role writes it. +// - crypto_keystore holds the wrapped DEK, and there is one DEK per process +// (ARCHITECTURE §5). Per-tenant DEKs are a later hardening. +// - goose_db_version is migration bookkeeping. +// - sso_nonces holds the replay guard for /v1/auth/sso. A nonce is a jti: it is +// meaningful GLOBALLY, because the question it answers is "has this exact token +// been spent", and the answer must be the same regardless of which workspace the +// token names. Per workspace it would be weaker for no benefit — the same jti +// could be replayed once per tenant. It also has no natural owner: the row exists +// before the token's `wid` claim has been trusted. +// - oauth_clients is dynamic client registration, which is per client +// APPLICATION rather than per tenant: one connector registration serves +// every workspace it is later authorised against, and the grant that IS +// per-tenant lives in oauth_access_tokens, which is a tenant table. +var ExemptTables = []string{ + "crypto_keystore", + "goose_db_version", + "oauth_clients", + "sso_nonces", + "workspaces", +} + +// EnableRLS turns row-level security on, and forces it, for every tenant table. +// It is idempotent, so it can run on every boot, and it is a no-op on SQLite, +// which has no row-level security to enable. +// +// This is deliberately NOT part of migration 00060. FORCE ROW LEVEL SECURITY +// makes a table's policy apply to its OWNER as well, and in single-tenant mode +// DATABASE_URL is the owner: a schema migrated with FORCE and no +// app.workspace_id binding returns zero rows to its owner for every SELECT. +// Measured against PostgreSQL 17.11 with a NOBYPASSRLS owner role — and hidden +// entirely by a superuser DSN, since superusers bypass RLS, which is the shape +// of green that proves nothing. Gating the two ALTERs on MULTI_TENANT keeps the +// promise that a single-tenant database behaves exactly as it did before. +// +// The policies themselves live in the migration, where they are reviewable SQL. +// A policy on a table whose row-level security is not enabled is inert, so +// creating them unconditionally costs a single-tenant instance nothing. +// +// Table names are interpolated into the DDL because PostgreSQL takes no +// placeholder for an identifier. They come from TenantTables, a committed +// constant list, never from input. +// SuspendDefaultWorkspace marks the seeded `default` workspace suspended. It runs at +// multi-tenant boot only, and is idempotent. +// +// Migration 00060 seeds `default` because it is the workspace every single-tenant row +// belongs to and the SQLite column default names it. On a MULTI_TENANT instance that row +// is a tenant nobody owns: it has no public_host (deliberately, so no Host can reach it), +// no users, and no settings. Left `active` it is still enumerated by every background +// sweep — the reconciler takes a pass over it on each cycle, and any future periodic loop +// would too — so the instance does work on behalf of a tenant that cannot receive it. +// +// ⛔ Suspending it at BOOT rather than in the migration is the whole point. The migration +// cannot see MULTI_TENANT, and in single-tenant mode `default` IS the workspace: a +// suspended row there would make Scoped answer 503 to every request on the instance. So +// the mode decides, and the mode is only known here. +// +// Suspension is the right shape rather than deletion: the row is referenced by +// server_settings and by any single-tenant data a converted instance still holds, and +// activeWorkspaceIDs already filters on status = 'active', so one status flip removes it +// from every sweep at once (D12's suspended semantics, reused). +func (h *DB) SuspendDefaultWorkspace(ctx context.Context) error { + if _, err := h.Platform().ExecContext(ctx, + `UPDATE workspaces SET status = 'suspended' WHERE id = ? AND status <> 'suspended'`, + DefaultWorkspaceID); err != nil { + return fmt.Errorf("suspend the default workspace: %w", err) + } + return nil +} + +func (h *DB) EnableRLS(ctx context.Context) error { + if h.dialect != DialectPostgres { + return nil + } + for _, table := range TenantTables { + if _, err := h.DB.ExecContext(ctx, `ALTER TABLE `+table+` ENABLE ROW LEVEL SECURITY`); err != nil { + return fmt.Errorf("enable row level security on %s: %w", table, err) + } + if _, err := h.DB.ExecContext(ctx, `ALTER TABLE `+table+` FORCE ROW LEVEL SECURITY`); err != nil { + return fmt.Errorf("force row level security on %s: %w", table, err) + } + } + return nil +} diff --git a/internal/db/tenancy_test.go b/internal/db/tenancy_test.go new file mode 100644 index 0000000..49b9c6a --- /dev/null +++ b/internal/db/tenancy_test.go @@ -0,0 +1,510 @@ +package db_test + +import ( + "context" + "slices" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// These tests hold migration 00060: the tenant column, its foreign key, the +// per-table policy, and the classification of every table as tenant or exempt. +// +// The structural assertions read information_schema and pg_catalog rather than +// the migration file, so they describe what the database ended up with. What +// they deliberately do NOT prove is that the policies isolate anything: the test +// DSN is a superuser, and superusers bypass row-level security, so a behavioural +// assertion here would pass with or without the policies. That proof needs a +// NOBYPASSRLS application role, which arrives with the handle work in Boundary 2. + +// TestTenancy_tableListsCoverTheSchema is the drift gate. A table added by a +// later migration has to be classified, so "nobody remembered to give it a +// workspace_id" cannot happen quietly. +func TestTenancy_tableListsCoverTheSchema(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query( + `SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' + ORDER BY table_name`) + if err != nil { + t.Fatalf("list tables: %v", err) + } + live := scanStrings(t, rows) + + if len(live) < 30 { + t.Fatalf("only %d tables in the schema; the comparison would prove nothing", len(live)) + } + + classified := append(slices.Clone(db.TenantTables), db.ExemptTables...) + slices.Sort(classified) + + for _, table := range live { + if !slices.Contains(classified, table) { + t.Errorf("table %q is in neither db.TenantTables nor db.ExemptTables — classify it (and give it a workspace_id if it is a tenant table)", table) + } + } + for _, table := range classified { + if !slices.Contains(live, table) { + t.Errorf("table %q is classified in internal/db but does not exist in the migrated schema", table) + } + } + if got, want := len(classified), len(live); got != want { + t.Errorf("classified %d tables, schema has %d", got, want) + } + t.Logf("classified %d tables: %d tenant, %d exempt", len(live), len(db.TenantTables), len(db.ExemptTables)) +} + +// TestPostgres_tenantColumnShape asserts D1 for every tenant table at once: the +// column exists, is TEXT, is NOT NULL, and defaults from the session setting. +func TestPostgres_tenantColumnShape(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query( + `SELECT table_name, data_type, is_nullable, COALESCE(column_default, '') + FROM information_schema.columns + WHERE table_schema = current_schema() AND column_name = 'workspace_id' + ORDER BY table_name`) + if err != nil { + t.Fatalf("read columns: %v", err) + } + defer rows.Close() + + seen := map[string]bool{} + for rows.Next() { + var table, dataType, nullable, def string + if err := rows.Scan(&table, &dataType, &nullable, &def); err != nil { + t.Fatalf("scan: %v", err) + } + seen[table] = true + + if !slices.Contains(db.TenantTables, table) { + t.Errorf("%s has a workspace_id column but is not in db.TenantTables", table) + } + if dataType != "text" { + t.Errorf("%s.workspace_id is %s; want text", table, dataType) + } + if nullable != "NO" { + t.Errorf("%s.workspace_id is nullable", table) + } + // The setting has to be named in the default, or a statement that omits + // the column would land in the wrong tenant rather than being refused. + if !strings.Contains(def, db.WorkspaceSetting) { + t.Errorf("%s.workspace_id default %q does not read %s", table, def, db.WorkspaceSetting) + } + // ⚠️ The missing_ok form matters: the bare current_setting RAISES on an + // unset parameter, which would fail every INSERT in single-tenant mode. + if !strings.Contains(def, "true") { + t.Errorf("%s.workspace_id default %q does not use the missing_ok form of current_setting", table, def) + } + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate: %v", err) + } + + for _, table := range db.TenantTables { + if !seen[table] { + t.Errorf("tenant table %s has no workspace_id column", table) + } + } + t.Logf("checked workspace_id on %d tables", len(seen)) +} + +// TestPostgres_exemptTablesHaveNoTenantColumn is the other half: an exempt table +// that quietly grew a workspace_id would be a table with a tenant column and no +// policy, which is worse than either. +func TestPostgres_exemptTablesHaveNoTenantColumn(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + for _, table := range db.ExemptTables { + var n int + err := handle.QueryRow( + `SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = ? AND column_name = 'workspace_id'`, + table).Scan(&n) + if err != nil { + t.Fatalf("count columns of %s: %v", table, err) + } + if n != 0 { + t.Errorf("exempt table %s has a workspace_id column", table) + } + } +} + +// TestPostgres_tenantForeignKeys asserts the FK to workspaces(id) with ON DELETE +// CASCADE, which is what makes a workspace delete (D12) remove its rows rather +// than orphan them. +func TestPostgres_tenantForeignKeys(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query( + `SELECT c.relname, con.confdeltype + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_class f ON f.oid = con.confrelid + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = con.conkey[1] + WHERE con.contype = 'f' + AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema()) + AND a.attname = 'workspace_id' + AND f.relname = 'workspaces' + AND array_length(con.conkey, 1) = 1 + ORDER BY c.relname`) + if err != nil { + t.Fatalf("read foreign keys: %v", err) + } + defer rows.Close() + + found := map[string]string{} + for rows.Next() { + var table, deleteRule string + if err := rows.Scan(&table, &deleteRule); err != nil { + t.Fatalf("scan: %v", err) + } + found[table] = deleteRule + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate: %v", err) + } + + for _, table := range db.TenantTables { + rule, ok := found[table] + if !ok { + t.Errorf("%s.workspace_id has no foreign key to workspaces(id)", table) + continue + } + if rule != "c" { // 'c' is CASCADE in pg_constraint.confdeltype + t.Errorf("%s.workspace_id foreign key delete rule is %q; want CASCADE", table, rule) + } + } + t.Logf("checked %d cascading foreign keys to workspaces(id)", len(found)) +} + +// TestPostgres_exactlyOnePolicyPerTenantTable asserts D2's policy half. Exactly +// one, because policies are permissive and OR together: a second one is a hole, +// not a redundancy. +func TestPostgres_exactlyOnePolicyPerTenantTable(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query( + `SELECT tablename, policyname, cmd, COALESCE(qual, ''), COALESCE(with_check, '') + FROM pg_policies WHERE schemaname = current_schema() + ORDER BY tablename, policyname`) + if err != nil { + t.Fatalf("read pg_policies: %v", err) + } + defer rows.Close() + + type policy struct{ name, cmd, using, check string } + byTable := map[string][]policy{} + for rows.Next() { + var table string + var p policy + if err := rows.Scan(&table, &p.name, &p.cmd, &p.using, &p.check); err != nil { + t.Fatalf("scan: %v", err) + } + byTable[table] = append(byTable[table], p) + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate: %v", err) + } + + for _, table := range db.TenantTables { + got := byTable[table] + if len(got) != 1 { + t.Errorf("%s has %d policies; want exactly 1 (permissive policies OR together, so a second one is a hole)", table, len(got)) + continue + } + p := got[0] + if want := table + "_tenant"; p.name != want { + t.Errorf("%s policy is named %q; want %q", table, p.name, want) + } + if p.cmd != "ALL" { + t.Errorf("%s policy covers %q; want ALL", table, p.cmd) + } + for label, expr := range map[string]string{"USING": p.using, "WITH CHECK": p.check} { + if !strings.Contains(expr, db.WorkspaceSetting) { + t.Errorf("%s policy %s clause %q does not read %s", table, label, expr, db.WorkspaceSetting) + } + } + } + + // workspaces carries a SELECT-only policy so the application role can resolve + // a host and read a status without being able to write the tenant root. + ws := byTable["workspaces"] + if len(ws) != 1 { + t.Fatalf("workspaces has %d policies; want exactly 1", len(ws)) + } + if ws[0].cmd != "SELECT" { + t.Errorf("workspaces policy covers %q; want SELECT", ws[0].cmd) + } + if ws[0].check != "" { + t.Errorf("workspaces policy has a WITH CHECK (%q); a SELECT policy that could admit a write is not what was intended", ws[0].check) + } + + for _, table := range []string{"crypto_keystore", "goose_db_version", "oauth_clients"} { + if n := len(byTable[table]); n != 0 { + t.Errorf("exempt table %s has %d policies; want 0", table, n) + } + } +} + +// TestPostgres_rlsIsOffUntilEnabled is the single-tenant promise, held as an +// assertion rather than a comment: migrating alone must not change what an +// existing PostgreSQL deployment can see. +// +// ⛔ The reason this is not in the migration: FORCE ROW LEVEL SECURITY applies a +// policy to the table's OWNER too, and in single-tenant mode DATABASE_URL is the +// owner. Measured on PostgreSQL 17.11 with a NOBYPASSRLS owner role, a schema +// migrated with FORCE and no app.workspace_id binding returns 0 rows for every +// SELECT. A superuser DSN hides that completely. +func TestPostgres_rlsIsOffUntilEnabled(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + before := rlsFlags(t, handle) + for _, table := range db.TenantTables { + f, ok := before[table] + if !ok { + t.Fatalf("%s missing from pg_class", table) + } + if f.enabled || f.forced { + t.Errorf("%s has row-level security enabled straight after migrating (enabled=%v forced=%v); "+ + "single-tenant PostgreSQL must be unaffected by 00060", table, f.enabled, f.forced) + } + } + + ctx := context.Background() + if err := handle.EnableRLS(ctx); err != nil { + t.Fatalf("EnableRLS: %v", err) + } + // Twice, because it runs on every boot. + if err := handle.EnableRLS(ctx); err != nil { + t.Fatalf("EnableRLS (second call): %v", err) + } + + after := rlsFlags(t, handle) + for _, table := range db.TenantTables { + f := after[table] + if !f.enabled || !f.forced { + t.Errorf("%s after EnableRLS: enabled=%v forced=%v; want both true", table, f.enabled, f.forced) + } + } + for _, table := range db.ExemptTables { + if f := after[table]; f.enabled || f.forced { + t.Errorf("exempt table %s had row-level security enabled (enabled=%v forced=%v)", table, f.enabled, f.forced) + } + } + t.Logf("EnableRLS turned on %d tenant tables and left %d exempt ones alone", len(db.TenantTables), len(db.ExemptTables)) +} + +// TestSQLite_enableRLSIsANoOp: the same call on the engine that has no row-level +// security must not error, so boot code needs no dialect branch. +func TestSQLite_enableRLSIsANoOp(t *testing.T) { + if dbtest.PostgresDSN() != "" { + t.Skip("this case is about the SQLite path; the suite is pointed at PostgreSQL") + } + handle := dbtest.Open(t) + if err := handle.EnableRLS(context.Background()); err != nil { + t.Fatalf("EnableRLS on SQLite: %v", err) + } +} + +// TestPostgres_compositeUniqueness asserts D8 and D9: what became per-workspace, +// and what deliberately stayed global. +func TestPostgres_compositeUniqueness(t *testing.T) { + handle := dbtest.RequirePostgres(t) + + rows, err := handle.Query( + `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = current_schema()`) + if err != nil { + t.Fatalf("read pg_indexes: %v", err) + } + defs := map[string]string{} + func() { + defer rows.Close() + for rows.Next() { + var name, def string + if err := rows.Scan(&name, &def); err != nil { + t.Fatalf("scan index: %v", err) + } + defs[name] = def + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate indexes: %v", err) + } + }() + + // Per workspace now. A UNIQUE constraint is backed by an index of the same + // name, so both spellings are visible here. + perWorkspace := map[string][]string{ + "users_workspace_id_email_key": {"workspace_id", "email"}, + "event_types_workspace_id_slug_key": {"workspace_id", "slug"}, + "teams_workspace_id_slug_key": {"workspace_id", "slug"}, + "idempotency_keys_pkey": {"workspace_id", "idempotency_key"}, + "meeting_consents_pkey": {"workspace_id", "room", "participant_identity"}, + "server_settings_pkey": {"workspace_id", "id"}, + "ux_jobs_type_payload": {"workspace_id", "type", "payload"}, + "idx_notes_booking": {"workspace_id", "booking_id"}, + "idx_bookings_no_double": {"workspace_id", "host_id", "start_at"}, + "idx_bookings_host_time": {"workspace_id", "host_id", "start_at", "end_at"}, + "idx_jobs_pending": {"workspace_id", "run_at"}, + "idx_jobs_running_expired": {"workspace_id", "locked_until"}, + "idx_jobs_pending_global": {"run_at"}, + "idx_jobs_running_expired_global": {"locked_until"}, + } + for name, cols := range perWorkspace { + def, ok := defs[name] + if !ok { + t.Errorf("index %s does not exist", name) + continue + } + if got := indexColumns(def); !slices.Equal(got, cols) { + t.Errorf("index %s covers %v; want %v (%s)", name, got, cols, def) + } + } + + // The global ones went away. + for _, gone := range []string{"users_email_key", "event_types_slug_key", "teams_slug_key"} { + if def, ok := defs[gone]; ok { + t.Errorf("%s still exists (%s); it should have become the (workspace_id, …) form", gone, def) + } + } + + // Credentials stay global: the tenant of a request that carries one is + // resolved FROM it, so the lookup happens before any workspace is known. + global := map[string][]string{ + "api_keys_key_hash_key": {"key_hash"}, + "sessions_pkey": {"id"}, + "oauth_access_tokens_token_hash_key": {"token_hash"}, + "oauth_access_tokens_refresh_hash_key": {"refresh_hash"}, + "oauth_auth_codes_pkey": {"code_hash"}, + "booking_manage_tokens_pkey": {"token_hash"}, + "magic_link_tokens_pkey": {"token_hash"}, + "invite_tokens_token_hash_key": {"token_hash"}, + "crypto_keystore_label_key": {"label"}, + } + for name, cols := range global { + def, ok := defs[name] + if !ok { + t.Errorf("index %s does not exist", name) + continue + } + if got := indexColumns(def); !slices.Equal(got, cols) { + t.Errorf("index %s covers %v; want %v — this one has to stay global (%s)", name, got, cols, def) + } + } +} + +// TestTenancy_defaultWorkspaceSeeded runs on whichever engine the environment +// selects, because the foreign keys on PostgreSQL and every existing row on both +// engines depend on that one row existing. +func TestTenancy_defaultWorkspaceSeeded(t *testing.T) { + handle := dbtest.Open(t) + + var id, slug, host, status string + err := handle.QueryRow( + `SELECT id, slug, public_host, status FROM workspaces WHERE id = ?`, + db.DefaultWorkspaceID).Scan(&id, &slug, &host, &status) + if err != nil { + t.Fatalf("read the default workspace: %v", err) + } + if slug != db.DefaultWorkspaceID { + t.Errorf("slug = %q; want %q", slug, db.DefaultWorkspaceID) + } + // Empty on purpose: no HTTP request carries an empty Host, so host + // resolution can never land on the default workspace. + if host != "" { + t.Errorf("public_host = %q; want empty", host) + } + if status != "active" { + t.Errorf("status = %q; want active", status) + } + + var n int + if err := handle.QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&n); err != nil { + t.Fatalf("count workspaces: %v", err) + } + if n != 1 { + t.Errorf("migrating seeded %d workspaces; want exactly 1", n) + } +} + +// TestTenancy_existingRowsLandInTheDefaultWorkspace: an INSERT that names no +// workspace_id has to work unchanged, on both engines, which is the whole point +// of D1's column default — the ~200 INSERT statements in the tree need no edit. +func TestTenancy_existingRowsLandInTheDefaultWorkspace(t *testing.T) { + handle := dbtest.Open(t) + + if _, err := handle.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + "u1", "a@example.com", "A"); err != nil { + t.Fatalf("insert without naming workspace_id: %v", err) + } + + var ws string + if err := handle.QueryRow(`SELECT workspace_id FROM users WHERE id = ?`, "u1").Scan(&ws); err != nil { + t.Fatalf("read back: %v", err) + } + if ws != db.DefaultWorkspaceID { + t.Errorf("workspace_id = %q; want %q", ws, db.DefaultWorkspaceID) + } +} + +type rlsFlag struct{ enabled, forced bool } + +func rlsFlags(t *testing.T, handle *db.DB) map[string]rlsFlag { + t.Helper() + + rows, err := handle.Query( + `SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class + WHERE relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = current_schema()) + AND relkind = 'r'`) + if err != nil { + t.Fatalf("read pg_class: %v", err) + } + defer rows.Close() + + out := map[string]rlsFlag{} + for rows.Next() { + var name string + var f rlsFlag + if err := rows.Scan(&name, &f.enabled, &f.forced); err != nil { + t.Fatalf("scan: %v", err) + } + out[name] = f + } + if err := rows.Err(); err != nil { + t.Fatalf("iterate: %v", err) + } + return out +} + +// indexColumns pulls the column list out of a pg_indexes definition. It anchors +// on "USING btree (" rather than the last parenthesis, because a partial index's +// WHERE clause carries parentheses of its own and there are five of those here. +// Expression indexes would need more than this; none of the ones asserted above +// is one. +func indexColumns(def string) []string { + const anchor = "USING btree (" + start := strings.Index(def, anchor) + if start < 0 { + return nil + } + start += len(anchor) + end := strings.IndexByte(def[start:], ')') + if end < 0 { + return nil + } + var out []string + for _, part := range strings.Split(def[start:start+end], ",") { + // Strip a trailing operator class, collation or sort direction. + field := strings.TrimSpace(part) + if i := strings.IndexByte(field, ' '); i >= 0 { + field = field[:i] + } + out = append(out, field) + } + return out +} diff --git a/internal/dbtest/dbtest.go b/internal/dbtest/dbtest.go new file mode 100644 index 0000000..b0cbba4 --- /dev/null +++ b/internal/dbtest/dbtest.go @@ -0,0 +1,189 @@ +// Package dbtest opens a migrated database for a test, on whichever engine the +// environment selects. +// +// Unset CALNODE_TEST_POSTGRES_DSN — the default, and what upstream CI and every +// other contributor sees — means in-memory SQLite, exactly as before. Set it, and +// the same tests run against that PostgreSQL server, each in a schema of its own +// that is created before the migrations and dropped afterwards. Nothing here changes +// what a test asserts; it changes which engine has to satisfy it. +package dbtest + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/url" + "os" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" +) + +// DSNEnv names the environment variable that switches the suite onto PostgreSQL. +const DSNEnv = "CALNODE_TEST_POSTGRES_DSN" + +// PostgresDSN returns the configured PostgreSQL DSN, or "" when the suite should +// run on SQLite. +func PostgresDSN() string { return os.Getenv(DSNEnv) } + +// Open returns a migrated handle for t, on Postgres when DSNEnv is set and on +// in-memory SQLite otherwise. The handle is closed when t finishes. +func Open(t *testing.T) *db.DB { + t.Helper() + if dsn := PostgresDSN(); dsn != "" { + return openPostgres(t, dsn) + } + return openSQLite(t) +} + +// RequirePostgres returns a migrated Postgres handle, skipping t when DSNEnv is +// unset. For the tests that are only meaningful on Postgres — a race the SQLite +// pool makes impossible, say. +func RequirePostgres(t *testing.T) *db.DB { + t.Helper() + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set; nothing to run against PostgreSQL", DSNEnv) + } + return openPostgres(t, dsn) +} + +func openSQLite(t *testing.T) *db.DB { + t.Helper() + h, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("dbtest: open sqlite: %v", err) + } + t.Cleanup(func() { h.Close() }) + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate sqlite: %v", err) + } + return h +} + +// openPostgres gives t its own schema on the shared server. +// +// A schema rather than a database: CREATE DATABASE cannot run inside a +// transaction, takes seconds on a busy server, and needs rights a CI service +// container may not grant the test role. A schema is one statement, isolates +// tables and goose's own bookkeeping equally well, and disappears whole with +// DROP ... CASCADE. Everything reaches it through search_path on the connection, +// so no query in the tree needs to name it. +func openPostgres(t *testing.T, dsn string) *db.DB { + t.Helper() + + schema := "calnode_test_" + randomSuffix(t) + + // A second handle on the default search_path, kept open only to create and drop + // the schema: the drop cannot run through a connection whose search_path points + // at the schema being dropped. + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("dbtest: open postgres (admin): %v", err) + } + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + admin.Close() + t.Fatalf("dbtest: create schema %s: %v", schema, err) + } + // Registered first, so it runs last: the schema is dropped after the handle + // below has been closed. + t.Cleanup(func() { + defer admin.Close() + if err := dropSchema(admin, schema); err != nil { + t.Errorf("dbtest: drop schema %s: %v", schema, err) + } + }) + + h, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("dbtest: open postgres: %v", err) + } + t.Cleanup(func() { h.Close() }) + + if err := h.Migrate(); err != nil { + t.Fatalf("dbtest: migrate postgres: %v", err) + } + return h +} + +// dropSchema drops the test's schema, retrying while work the test started is still +// finishing. +// +// Calnode's handlers do several things fire-and-forget: a booking spawns goroutines +// to notify hosts, enqueue the webhook and enqueue reminders. Those outlive the test +// BODY, and closing the pool does not stop them — database/sql closes idle +// connections and lets in-flight statements run to completion. DROP SCHEMA CASCADE +// needs an exclusive lock on every object in the schema, so it meets those +// statements and PostgreSQL reports "deadlock detected" (SQLSTATE 40P01). It showed +// up only under `go test ./...`, where packages run concurrently and everything is +// slower — two handler tests out of several hundred, in a different pair each run. +// +// lock_timeout is what makes retrying viable: without it the DROP either waits +// indefinitely or is chosen as a deadlock victim. With it the attempt fails fast and +// cheaply, and the background work it was contending with has finished by the next +// one. The budget is bounded so a schema that genuinely cannot be dropped is +// reported rather than waited on forever — a leaked schema accumulates on a shared +// server, so it is worth failing the test over. +// +// The statements run on ONE pinned connection because lock_timeout is per-session +// and the pool would otherwise be free to apply the SET to a different connection +// than the DROP. Neither statement has placeholders, so nothing needs rebinding. +func dropSchema(admin *db.DB, schema string) error { + ctx := context.Background() + conn, err := admin.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck // returning the drop's error is more useful + + if _, err := conn.ExecContext(ctx, `SET lock_timeout = '250ms'`); err != nil { + return err + } + + const attempts = 20 // ~5s of wall clock, against background work that takes ms + var lastErr error + for i := 0; i < attempts; i++ { + if _, lastErr = conn.ExecContext(ctx, `DROP SCHEMA `+quoteIdent(schema)+` CASCADE`); lastErr == nil { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return lastErr +} + +// withSearchPath returns dsn with search_path pointed at schema. pgx passes +// unrecognised query parameters through as PostgreSQL runtime parameters, so this +// is the whole of the isolation: goose writes goose_db_version there, the +// migrations create their tables there, and every unqualified name in the tree +// resolves there. +func withSearchPath(t *testing.T, dsn, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("dbtest: parse %s: %v", DSNEnv, err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} + +// randomSuffix keeps concurrent runs — two packages under `go test ./...`, or two +// CI jobs against one server — out of each other's schemas. +func randomSuffix(t *testing.T) string { + t.Helper() + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t.Fatalf("dbtest: random schema name: %v", err) + } + return hex.EncodeToString(b) +} + +// quoteIdent quotes a generated schema name. The names are hex from the line +// above, so this is belt and braces rather than a defence — but a schema name +// interpolated into DDL unquoted is a bad habit to leave lying around for the next +// person who passes something else in. +func quoteIdent(name string) string { + return `"` + name + `"` +} diff --git a/internal/dbtest/dbtest_test.go b/internal/dbtest/dbtest_test.go new file mode 100644 index 0000000..25f188c --- /dev/null +++ b/internal/dbtest/dbtest_test.go @@ -0,0 +1,76 @@ +package dbtest + +import ( + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// TestSearchPathIsolation checks the mechanism openPostgres relies on, without +// going through it: that a search_path in the DSN really does land unqualified +// objects in the per-test schema, and that they are invisible from the default +// path. If pgx ever stopped forwarding the parameter, openPostgres would silently +// migrate into the public schema and two packages would fight over one set of +// tables — which reads as flaky tests, not as a broken harness. +func TestSearchPathIsolation(t *testing.T) { + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("%s is not set", DSNEnv) + } + + schema := "calnode_test_" + randomSuffix(t) + + admin, err := db.OpenDB(dsn) + if err != nil { + t.Fatalf("open admin: %v", err) + } + defer admin.Close() + if _, err := admin.Exec(`CREATE SCHEMA ` + quoteIdent(schema)); err != nil { + t.Fatalf("create schema: %v", err) + } + defer func() { + if _, err := admin.Exec(`DROP SCHEMA ` + quoteIdent(schema) + ` CASCADE`); err != nil { + t.Errorf("drop schema: %v", err) + } + }() + + scoped, err := db.OpenDB(withSearchPath(t, dsn, schema)) + if err != nil { + t.Fatalf("open scoped: %v", err) + } + defer scoped.Close() + + var got string + if err := scoped.QueryRow(`SELECT current_schema()`).Scan(&got); err != nil { + t.Fatalf("current_schema: %v", err) + } + if got != schema { + t.Fatalf("current_schema() = %q; want %q — search_path did not reach the server", got, schema) + } + + if _, err := scoped.Exec(`CREATE TABLE isolated (id text)`); err != nil { + t.Fatalf("create table: %v", err) + } + var visible int + if err := admin.QueryRow( + `SELECT COUNT(*) FROM pg_tables WHERE schemaname = current_schema() AND tablename = 'isolated'`). + Scan(&visible); err != nil { + t.Fatalf("count on default path: %v", err) + } + if visible != 0 { + t.Errorf("table 'isolated' is visible on the default search_path; the schema is not isolating anything") + } +} + +// TestOpen returns a handle that has been migrated, on whichever engine is +// configured. goose's bookkeeping table is the engine-independent proof. +func TestOpen(t *testing.T) { + h := Open(t) + var n int + if err := h.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&n); err != nil { + t.Fatalf("goose_db_version: %v", err) + } + if n == 0 { + t.Error("goose_db_version is empty; Open returned an unmigrated handle") + } +} diff --git a/internal/dbtest/tenant.go b/internal/dbtest/tenant.go new file mode 100644 index 0000000..d161e8d --- /dev/null +++ b/internal/dbtest/tenant.go @@ -0,0 +1,118 @@ +package dbtest + +import ( + "context" + "crypto/rand" + "encoding/hex" + "net/url" + "testing" + + "github.com/calnode/calnode/internal/db" +) + +// RequireTenantPair returns a live multi-tenant handle pair against the test's own +// schema: app connected as a freshly created NOBYPASSRLS role that owns nothing, +// and platform as the suite's own role, which owns the schema and bypasses. +// Row-level security is enabled before it returns. +// +// ⛔ THE NON-SUPERUSER ROLE IS THE WHOLE POINT. Superusers bypass row-level +// security unconditionally, so does any role with BYPASSRLS, and so does a table's +// owner unless FORCE is set. The suite's DSN is normally the superuser that owns +// the test schema, so a tenancy assertion made through it passes whether the +// policies exist or not. This SKIPS LOUDLY rather than falling back if the role +// cannot be created, reports rolsuper/rolbypassrls, or owns a table. +// +// It lives here rather than in an internal/db test file because more than one +// package needs it: internal/db proves the handle, internal/server proves the +// routes, and Boundary 7 proves the whole surface. +func RequireTenantPair(t *testing.T) (app, platform *db.DB) { + t.Helper() + + dsn := PostgresDSN() + if dsn == "" { + t.Skipf("LOUD SKIP: %s is not set. A multi-tenant test needs a real PostgreSQL server: "+ + "the isolation guarantee is row-level security, and there is nothing to assert without it.", DSNEnv) + } + + owner := openPostgres(t, dsn) // creates the schema, migrates it, drops it after + + var schema string + if err := owner.QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatalf("dbtest: read current_schema: %v", err) + } + + // Random hex, so a name interpolated into DDL cannot carry a quote or a + // keyword. PostgreSQL takes no placeholder for a role name. + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + t.Fatalf("dbtest: rand: %v", err) + } + role := "calnode_app_" + hex.EncodeToString(buf) + const password = "tenant_pair_pw" // a local test role, dropped when the test ends + + if _, err := owner.Exec(`CREATE ROLE ` + role + ` LOGIN PASSWORD '` + password + `' NOBYPASSRLS`); err != nil { + t.Skipf("LOUD SKIP: cannot CREATE ROLE on this server (%v). The multi-tenant tests REQUIRE a "+ + "NOBYPASSRLS role — asserting isolation through the suite's own superuser DSN would pass "+ + "with or without the policies. Point %s at a server where the test role may create roles.", + err, DSNEnv) + } + t.Cleanup(func() { + // DROP OWNED also revokes the grants below, which DROP ROLE would refuse over. + if _, err := owner.Exec(`DROP OWNED BY ` + role); err != nil { + t.Errorf("dbtest: drop owned by %s: %v", role, err) + } + if _, err := owner.Exec(`DROP ROLE ` + role); err != nil { + t.Errorf("dbtest: drop role %s: %v", role, err) + } + }) + + for _, stmt := range []string{ + `GRANT USAGE ON SCHEMA ` + quoteIdent(schema) + ` TO ` + role, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ` + quoteIdent(schema) + ` TO ` + role, + `GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ` + quoteIdent(schema) + ` TO ` + role, + } { + if _, err := owner.Exec(stmt); err != nil { + t.Fatalf("dbtest: %s: %v", stmt, err) + } + } + + if err := owner.EnableRLS(context.Background()); err != nil { + t.Fatalf("dbtest: EnableRLS: %v", err) + } + + app, platform, err := db.OpenPair( + rewriteUser(t, dsn, role, password, schema), + withSearchPath(t, dsn, schema), + ) + if err != nil { + t.Fatalf("dbtest: OpenPair: %v", err) + } + t.Cleanup(func() { + app.Close() + platform.Close() + }) + + // The guard that turns "the application role cannot bypass" from an assumption + // into a checked fact, and that would catch a role able to read everything. + if err := app.VerifyRoles(context.Background()); err != nil { + t.Skipf("LOUD SKIP: VerifyRoles rejected this pair, so nothing below would prove isolation: %v", err) + } + + return app, platform +} + +// rewriteUser points dsn at a different role and pins search_path. pgx forwards +// unrecognised query parameters as PostgreSQL runtime parameters, which is how +// search_path reaches the session — the same mechanism openPostgres uses. +func rewriteUser(t *testing.T, dsn, user, password, schema string) string { + t.Helper() + u, err := url.Parse(dsn) + if err != nil { + t.Fatalf("dbtest: parse %s: %v", DSNEnv, err) + } + u.User = url.UserPassword(user, password) + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + return u.String() +} diff --git a/internal/dbtime/dbtime.go b/internal/dbtime/dbtime.go new file mode 100644 index 0000000..a47dd76 --- /dev/null +++ b/internal/dbtime/dbtime.go @@ -0,0 +1,35 @@ +// Package dbtime formats "now" for Calnode's TEXT timestamp columns. +// +// Those columns used to be filled by the engine, with datetime('now') or +// strftime('%Y-%m-%dT%H:%M:%fZ','now'). Neither function exists in PostgreSQL, so +// the value is computed here and bound as an ordinary parameter instead: one +// statement that both engines accept, and a timestamp a test can control by +// comparing against a value it computed itself. +// +// The two layouts are the two shapes already in the schema, kept byte-identical to +// what SQLite wrote before. Normalising them to one would be tidier and wrong: the +// stored text is compared lexicographically (recordings scope consents to a time +// window that way) and handed to clients verbatim (GET /v1/notes/{id} returns +// updated_at), so a shape change would silently alter both. +package dbtime + +import "time" + +const ( + // DateTime is SQLite's datetime('now') output: "2006-01-02 15:04:05", UTC, + // second resolution, no zone suffix. + DateTime = "2006-01-02 15:04:05" + + // RFC3339Milli is SQLite's strftime('%Y-%m-%dT%H:%M:%fZ','now') output: + // RFC 3339 with exactly three fractional digits. %f is "SS.SSS", so the + // milliseconds are always present, including a trailing zero. + RFC3339Milli = "2006-01-02T15:04:05.000Z" +) + +// Now returns the current UTC time in the DateTime layout — the replacement for +// datetime('now'). +func Now() string { return time.Now().UTC().Format(DateTime) } + +// NowMilli returns the current UTC time in the RFC3339Milli layout — the +// replacement for strftime('%Y-%m-%dT%H:%M:%fZ','now'). +func NowMilli() string { return time.Now().UTC().Format(RFC3339Milli) } diff --git a/internal/dbtime/dbtime_test.go b/internal/dbtime/dbtime_test.go new file mode 100644 index 0000000..a0ec829 --- /dev/null +++ b/internal/dbtime/dbtime_test.go @@ -0,0 +1,51 @@ +package dbtime_test + +import ( + "regexp" + "testing" + + _ "modernc.org/sqlite" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" +) + +// The whole point of this package is that a Go-computed timestamp is +// indistinguishable from the one SQLite used to write. Assert that against SQLite +// itself rather than against a hand-read of its docs: if the shapes ever diverge, +// existing rows and new rows stop comparing lexicographically and the recordings +// consent window silently returns the wrong set. +func TestLayoutsMatchSQLite(t *testing.T) { + handle, err := db.OpenDB("sqlite://:memory:") + if err != nil { + t.Fatalf("db.OpenDB: %v", err) + } + t.Cleanup(func() { handle.Close() }) + + cases := []struct { + name string + expr string + got string + }{ + {"datetime('now')", `SELECT datetime('now')`, dbtime.Now()}, + {"strftime millis", `SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')`, dbtime.NowMilli()}, + } + + for _, c := range cases { + var want string + if err := handle.QueryRow(c.expr).Scan(&want); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + // Compare shape, not value: the two clocks are read microseconds apart, so + // the digits legitimately differ. A digit-for-digit mask is what catches a + // layout mistake (missing "Z", " " where "T" belongs, 6 fractional digits). + if mask(want) != mask(c.got) { + t.Errorf("%s: sqlite wrote %q (shape %q), dbtime produced %q (shape %q)", + c.name, want, mask(want), c.got, mask(c.got)) + } + } +} + +var digits = regexp.MustCompile(`[0-9]`) + +func mask(s string) string { return digits.ReplaceAllString(s, "N") } diff --git a/internal/demo/demo.go b/internal/demo/demo.go index e37c6ef..5e7986f 100644 --- a/internal/demo/demo.go +++ b/internal/demo/demo.go @@ -7,10 +7,11 @@ package demo import ( "context" - "database/sql" "fmt" + "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -29,15 +30,18 @@ const ( // Monday-Friday availability, and a few upcoming sample bookings. Rows are // inserted directly via SQL, bypassing the HTTP layer — the same pattern // already used by this package's handler test fixtures. -func Seed(ctx context.Context, db *sql.DB) error { +func Seed(ctx context.Context, db *db.DB) error { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("demo seed: begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck + // ON CONFLICT DO NOTHING rather than SQLite's INSERT OR IGNORE: both engines + // accept it. The conflict target is omitted, which covers any unique + // constraint, exactly as OR IGNORE did. if _, err := tx.ExecContext(ctx, - `INSERT OR IGNORE INTO server_settings (id) VALUES (1)`); err != nil { + `INSERT INTO server_settings (id) VALUES (1) ON CONFLICT DO NOTHING`); err != nil { return fmt.Errorf("demo seed: server_settings: %w", err) } @@ -181,25 +185,33 @@ func nextWeekdayAt(now time.Time, minDaysOut, hour int) time.Time { // dynamically rather than hardcoded, since this actively deletes data — // see docs/ARCHITECTURE.md's hardcoded-column-count incident for why a // stale hardcoded list is worth avoiding here specifically. -func Reset(ctx context.Context, db *sql.DB) (err error) { +func Reset(ctx context.Context, db *db.DB) (err error) { tables, err := listTables(ctx, db) if err != nil { return err } - // foreign_keys is connection-scoped and can't be toggled mid-transaction, - // so it's set here, before BeginTx — safe only because the pool is a - // single persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). - // Needed because the delete order below is arbitrary relative to the - // schema's 46 migrations' worth of foreign-key relationships. - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { - return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) - } - defer func() { - if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { - err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + // The wipe order below is arbitrary relative to the schema's 46 migrations' + // worth of foreign keys, so referential integrity has to be stood down for it. + // The engines do that differently and neither way exists on the other: + // + // SQLite — PRAGMA foreign_keys is connection-scoped and cannot be toggled + // mid-transaction, so it is set before BeginTx. Safe only because the pool + // is one persistent connection (db.SetMaxOpenConns(1), internal/db/db.go). + // + // Postgres — there is no equivalent switch short of superuser rights, so a + // single TRUNCATE naming every table CASCADEs through the foreign keys + // instead. TRUNCATE is transactional there, so it stays inside the tx. + if isSQLite(db) { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); ferr != nil { + return fmt.Errorf("demo reset: disable foreign keys: %w", ferr) } - }() + defer func() { + if _, ferr := db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); ferr != nil && err == nil { + err = fmt.Errorf("demo reset: re-enable foreign keys: %w", ferr) + } + }() + } tx, err := db.BeginTx(ctx, nil) if err != nil { @@ -207,9 +219,21 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { } defer tx.Rollback() //nolint:errcheck - for _, t := range tables { - if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { - return fmt.Errorf("demo reset: delete from %s: %w", t, err) + if isSQLite(db) { + for _, t := range tables { + if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %q`, t)); err != nil { + return fmt.Errorf("demo reset: delete from %s: %w", t, err) + } + } + } else if len(tables) > 0 { + quoted := make([]string, len(tables)) + for i, t := range tables { + quoted[i] = `"` + t + `"` + } + // #nosec G202 -- every name came from pg_tables in this schema, not from a request. + if _, err = tx.ExecContext(ctx, + `TRUNCATE TABLE `+strings.Join(quoted, ", ")+` CASCADE`); err != nil { + return fmt.Errorf("demo reset: truncate: %w", err) } } if err = tx.Commit(); err != nil { @@ -219,10 +243,29 @@ func Reset(ctx context.Context, db *sql.DB) (err error) { return Seed(ctx, db) } -func listTables(ctx context.Context, db *sql.DB) ([]string, error) { - rows, err := db.QueryContext(ctx, ` - SELECT name FROM sqlite_master - WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name != 'goose_db_version'`) +// isSQLite is a free function rather than an inline comparison because Reset and +// listTables both name their handle "db", which shadows the package the Dialect +// constants live in. +func isSQLite(h *db.DB) bool { return h.Dialect() == db.DialectSQLite } + +func listTables(ctx context.Context, db *db.DB) ([]string, error) { + // sqlite_master has no Postgres counterpart. pg_tables scoped to + // current_schema() is the equivalent, and honouring the current schema is what + // lets a test run inside its own isolated one. + // + // Two tables are held back. goose_db_version is migration bookkeeping, and + // wiping it would make the next boot re-run every migration. workspaces is the + // tenant root (migration 00060): every application table's workspace_id has a + // foreign key to it, so truncating it takes the 'default' row with it and the + // re-seed's first INSERT fails with SQLSTATE 23503. It holds no visitor data — + // in demo mode there is exactly one row, and it is a constant. + rows, err := db.QueryContext(ctx, db.Dialect().SQL( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' + AND name NOT IN ('goose_db_version', 'workspaces')`, + `SELECT tablename FROM pg_tables + WHERE schemaname = current_schema() + AND tablename NOT IN ('goose_db_version', 'workspaces')`)) if err != nil { return nil, fmt.Errorf("demo reset: list tables: %w", err) } diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 183300c..cc7ecaf 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -2,27 +2,20 @@ package demo_test import ( "context" - "database/sql" "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/demo" ) -func newMigratedDB(t *testing.T) *sql.DB { +func newMigratedDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) return database } -func assertCount(t *testing.T, ctx context.Context, database *sql.DB, table string, want int) { +func assertCount(t *testing.T, ctx context.Context, database *db.DB, table string, want int) { t.Helper() var got int if err := database.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table).Scan(&got); err != nil { diff --git a/internal/gcal/gcal.go b/internal/gcal/gcal.go index 29b13e0..528774a 100644 --- a/internal/gcal/gcal.go +++ b/internal/gcal/gcal.go @@ -20,6 +20,7 @@ import ( "github.com/calnode/calnode/internal/calendar" "github.com/calnode/calnode/internal/connstore" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/oauthstore" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/uid" @@ -39,13 +40,13 @@ func (c *Client) InvitesGuests() bool { return true } type Client struct { config *oauth2.Config key [32]byte - db *sql.DB + db *db.DB logger *slog.Logger apiBase string // base URL for Calendar API; overridable in tests } // New creates a Client. encKeyHex is the 64-char hex AES-256 encryption key. -func New(db *sql.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { +func New(db *db.DB, clientID, clientSecret, redirectURL, encKeyHex string) (*Client, error) { b, err := hex.DecodeString(encKeyHex) if err != nil || len(b) != 32 { return nil, fmt.Errorf("gcal: invalid encryption key") @@ -400,3 +401,13 @@ func (c *Client) decryptEncoding(ciphertext string, enc *base64.Encoding) ([]byt } return plain, nil } + +// ForDB returns a copy of c reading and writing through handle, so a per-request +// handler can bind Google Calendar to its workspace. The OAuth app configuration and +// the encryption key are instance-level (D7) and are carried over unchanged; only +// the database handle differs. +func (c *Client) ForDB(handle *db.DB) calendar.Provider { + copied := *c + copied.db = handle + return &copied +} diff --git a/internal/gcal/gcal_test.go b/internal/gcal/gcal_test.go index e5c06d1..31780d6 100644 --- a/internal/gcal/gcal_test.go +++ b/internal/gcal/gcal_test.go @@ -2,7 +2,6 @@ package gcal import ( "context" - "database/sql" "strings" "testing" "time" @@ -10,20 +9,16 @@ import ( "golang.org/x/oauth2" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/uid" ) // testKeyHex is a valid 64-char hex key (32 bytes) used across tests. const testKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -func newTestDB(t *testing.T) *sql.DB { +func newTestDB(t *testing.T) *db.DB { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("newTestDB: open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("newTestDB: migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return database } @@ -38,7 +33,7 @@ func newTestClient(t *testing.T) *Client { } // seedUser inserts a minimal user row so that calendar_connections FK is satisfied. -func seedUser(t *testing.T, db *sql.DB, userID string) { +func seedUser(t *testing.T, db *db.DB, userID string) { t.Helper() _, err := db.ExecContext(context.Background(), ` INSERT INTO users (id, email, name, iana_timezone, is_admin, created_at) @@ -314,16 +309,19 @@ func TestFreeBusyConnections_returnsConnectedCalendars(t *testing.T) { } // insertConnCal adds a per-account sub-calendar selection row for the google provider. -func insertConnCal(t *testing.T, db *sql.DB, userID, account, calID string, check bool) { +func insertConnCal(t *testing.T, db *db.DB, userID, account, calID string, check bool) { t.Helper() cc := 0 if check { cc = 1 } + // uid.New() rather than lower(hex(randomblob(16))): randomblob is a SQLite + // built-in with no PostgreSQL counterpart, and every other row in these tests + // already gets its id from uid. _, err := db.ExecContext(context.Background(), ` INSERT INTO connection_calendars (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination) - VALUES (lower(hex(randomblob(16))), ?, 'google', ?, ?, '', ?, 0)`, - userID, account, calID, cc) + VALUES (?, ?, 'google', ?, ?, '', ?, 0)`, + uid.New(), userID, account, calID, cc) if err != nil { t.Fatalf("insertConnCal(%q): %v", calID, err) } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 0fef5b1..ae6528d 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -15,7 +15,13 @@ const ctxKeyUser contextKey = "user" // AuthUser is the authenticated caller stored in request context. type AuthUser struct { - ID string + ID string + + // WorkspaceID is the tenant this caller belongs to, read from the same row as + // the rest. It is what CredentialWorkspace resolves the request's workspace + // from, and it is "default" in single-tenant mode. + WorkspaceID string + Email string Name string IANATZ string @@ -68,13 +74,17 @@ func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc { var user AuthUser var keyID string var nc, nca, nr, nrm, nhb, nhc, nhr int - err := h.db.QueryRowContext(r.Context(), ` - SELECT ak.id, u.id, u.email, u.name, u.iana_timezone, u.time_format, u.week_start, u.date_format, COALESCE(u.avatar_url,''), u.is_admin, u.is_owner, + // ⛔ platformDB, not h.db. api_keys.key_hash is global precisely so a + // key resolves without a tenant, and the tenant is what this read + // DISCOVERS — on the workspace-bound handle it would find nothing and + // a valid key would be reported invalid. + err := h.platformDB().QueryRowContext(r.Context(), ` + SELECT ak.id, u.id, u.workspace_id, u.email, u.name, u.iana_timezone, u.time_format, u.week_start, u.date_format, COALESCE(u.avatar_url,''), u.is_admin, u.is_owner, COALESCE(u.notify_confirmation,1), COALESCE(u.notify_cancellation,1), COALESCE(u.notify_reschedule,1), COALESCE(u.notify_reminder,1), COALESCE(u.notify_host_booking,1), COALESCE(u.notify_host_cancel,1), COALESCE(u.notify_host_reschedule,1) FROM api_keys ak JOIN users u ON u.id = ak.user_id WHERE ak.key_hash = ? AND u.archived_at IS NULL`, hash). - Scan(&keyID, &user.ID, &user.Email, &user.Name, &user.IANATZ, &user.TimeFormat, &user.WeekStart, &user.DateFormat, &user.AvatarURL, &user.IsAdmin, &user.IsOwner, + Scan(&keyID, &user.ID, &user.WorkspaceID, &user.Email, &user.Name, &user.IANATZ, &user.TimeFormat, &user.WeekStart, &user.DateFormat, &user.AvatarURL, &user.IsAdmin, &user.IsOwner, &nc, &nca, &nr, &nrm, &nhb, &nhc, &nhr) user.NotifyConfirmation, user.NotifyCancellation, user.NotifyReschedule, user.NotifyReminder = nc != 0, nca != 0, nr != 0, nrm != 0 user.NotifyHostBooking, user.NotifyHostCancel, user.NotifyHostReschedule = nhb != 0, nhc != 0, nhr != 0 @@ -83,7 +93,7 @@ func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc { return } now := time.Now().UTC().Format(time.RFC3339Nano) - _, _ = h.db.ExecContext(r.Context(), + _, _ = h.platformDB().ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ? WHERE id = ?`, now, keyID) next(w, r.WithContext(context.WithValue(r.Context(), ctxKeyUser, user))) return @@ -94,15 +104,17 @@ func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc { now := time.Now().UTC().Format(time.RFC3339) var user AuthUser var nc, nca, nr, nrm, nhb, nhc, nhr int - if err := h.db.QueryRowContext(r.Context(), ` - SELECT u.id, u.email, u.name, u.iana_timezone, u.time_format, u.week_start, u.date_format, COALESCE(u.avatar_url,''), u.is_admin, u.is_owner, + // platformDB for the same reason as the API-key path above: + // sessions.id is global so a cookie resolves without a tenant. + if err := h.platformDB().QueryRowContext(r.Context(), ` + SELECT u.id, u.workspace_id, u.email, u.name, u.iana_timezone, u.time_format, u.week_start, u.date_format, COALESCE(u.avatar_url,''), u.is_admin, u.is_owner, COALESCE(u.notify_confirmation,1), COALESCE(u.notify_cancellation,1), COALESCE(u.notify_reschedule,1), COALESCE(u.notify_reminder,1), COALESCE(u.notify_host_booking,1), COALESCE(u.notify_host_cancel,1), COALESCE(u.notify_host_reschedule,1) FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ? AND u.archived_at IS NULL`, cookie.Value, now). - Scan(&user.ID, &user.Email, &user.Name, &user.IANATZ, &user.TimeFormat, &user.WeekStart, &user.DateFormat, &user.AvatarURL, &user.IsAdmin, &user.IsOwner, + Scan(&user.ID, &user.WorkspaceID, &user.Email, &user.Name, &user.IANATZ, &user.TimeFormat, &user.WeekStart, &user.DateFormat, &user.AvatarURL, &user.IsAdmin, &user.IsOwner, &nc, &nca, &nr, &nrm, &nhb, &nhc, &nhr); err == nil { user.NotifyConfirmation, user.NotifyCancellation, user.NotifyReschedule, user.NotifyReminder = nc != 0, nca != 0, nr != 0, nrm != 0 user.NotifyHostBooking, user.NotifyHostCancel, user.NotifyHostReschedule = nhb != 0, nhc != 0, nhr != 0 diff --git a/internal/handler/auth_google.go b/internal/handler/auth_google.go index 67c900c..58d118c 100644 --- a/internal/handler/auth_google.go +++ b/internal/handler/auth_google.go @@ -43,7 +43,21 @@ func (h *Handler) LoginGoogle(w http.ResponseWriter, r *http.Request) { http.Error(w, "Google OAuth not configured — set GOOGLE_CLIENT_ID", http.StatusServiceUnavailable) return } - state, err := h.newOAuthState(w) + // ⛔ The workspace is resolved HERE, from the Host the person clicked "sign in" on. The + // callback arrives on the identity host and cannot resolve it, which is why it rides the + // state cookie. /v1/auth/login is Platform-wrapped (it has to be, because its callback + // is), so this does explicitly what HostWorkspace would have done — including 404 on a + // host that names no workspace, rather than defaulting to a tenant nobody chose. + loginWorkspace := "" + if h.multiTenant { + ws, wsErr := h.workspaceByHost(r.Context(), r.Host) + if wsErr != nil { + h.writeResolveError(w, r, wsErr) + return + } + loginWorkspace = ws.ID + } + state, err := h.newOAuthState(w, loginWorkspace) if err != nil { h.logger.ErrorContext(r.Context(), "auth: generate state", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -62,7 +76,8 @@ func (h *Handler) CallbackGoogle(w http.ResponseWriter, r *http.Request) { } // Verify CSRF state (cookie must match the URL param) and consume it. - if !h.verifyOAuthState(w, r) { + stateWorkspace, stateOK := h.verifyOAuthState(w, r) + if !stateOK { http.Redirect(w, r, "/admin/login?error=state", http.StatusFound) return } @@ -88,7 +103,7 @@ func (h *Handler) CallbackGoogle(w http.ResponseWriter, r *http.Request) { } // Only existing users can log in — no self-registration. - h.finishOAuthLogin(w, r, info.Email) + h.finishOAuthLogin(w, r, info.Email, stateWorkspace) } // Logout deletes the session record and clears the session cookie. diff --git a/internal/handler/auth_google_test.go b/internal/handler/auth_google_test.go index 79b9b5a..13f2325 100644 --- a/internal/handler/auth_google_test.go +++ b/internal/handler/auth_google_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "log/slog" "net/http" "net/http/httptest" @@ -10,20 +9,15 @@ import ( "time" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) // authTestSetup opens an in-memory DB, runs migrations, seeds one user, and // returns the handler, database handle, and the seeded user ID. -func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { +func authTestSetup(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) userID := "user-auth-test" @@ -35,7 +29,7 @@ func authTestSetup(t *testing.T) (*handler.Handler, *sql.DB, string) { } // seedSession inserts a session row and returns the session id (cookie value). -func seedSession(t *testing.T, db *sql.DB, userID string, ttl time.Duration) string { +func seedSession(t *testing.T, db *db.DB, userID string, ttl time.Duration) string { t.Helper() sessID := "test-session-" + userID expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339) diff --git a/internal/handler/auth_microsoft.go b/internal/handler/auth_microsoft.go index 817bc55..0aba327 100644 --- a/internal/handler/auth_microsoft.go +++ b/internal/handler/auth_microsoft.go @@ -40,7 +40,21 @@ func (h *Handler) LoginMicrosoft(w http.ResponseWriter, r *http.Request) { http.Error(w, "Microsoft OAuth not configured", http.StatusServiceUnavailable) return } - state, err := h.newOAuthState(w) + // ⛔ The workspace is resolved HERE, from the Host the person clicked "sign in" on. The + // callback arrives on the identity host and cannot resolve it, which is why it rides the + // state cookie. /v1/auth/login is Platform-wrapped (it has to be, because its callback + // is), so this does explicitly what HostWorkspace would have done — including 404 on a + // host that names no workspace, rather than defaulting to a tenant nobody chose. + loginWorkspace := "" + if h.multiTenant { + ws, wsErr := h.workspaceByHost(r.Context(), r.Host) + if wsErr != nil { + h.writeResolveError(w, r, wsErr) + return + } + loginWorkspace = ws.ID + } + state, err := h.newOAuthState(w, loginWorkspace) if err != nil { h.logger.ErrorContext(r.Context(), "auth: generate state", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -59,7 +73,8 @@ func (h *Handler) CallbackMicrosoft(w http.ResponseWriter, r *http.Request) { http.Error(w, "Microsoft OAuth not configured", http.StatusServiceUnavailable) return } - if !h.verifyOAuthState(w, r) { + stateWorkspace, stateOK := h.verifyOAuthState(w, r) + if !stateOK { http.Redirect(w, r, "/admin/login?error=state", http.StatusFound) return } @@ -81,7 +96,7 @@ func (h *Handler) CallbackMicrosoft(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login?error=userinfo", http.StatusFound) return } - h.finishOAuthLogin(w, r, email) + h.finishOAuthLogin(w, r, email, stateWorkspace) } type microsoftUserInfo struct { diff --git a/internal/handler/auth_oauth.go b/internal/handler/auth_oauth.go index bc8f9d0..8a8c7a0 100644 --- a/internal/handler/auth_oauth.go +++ b/internal/handler/auth_oauth.go @@ -4,21 +4,42 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "fmt" "net/http" + "net/url" + "strings" + "time" + + "github.com/calnode/calnode/internal/uid" ) // newOAuthState generates a CSRF state token, sets it as a short-lived cookie, and -// returns it for inclusion in the provider's authorize URL. Shared by the Google +// returns the value to put in the provider's authorize URL. Shared by the Google // and Microsoft sign-in flows. -func (h *Handler) newOAuthState(w http.ResponseWriter) (string, error) { +// +// ⛔ The workspace rides the COOKIE, never the URL. The cookie value is +// `|` and only the nonce goes to the provider, so the value that +// decides which tenant a login lands in is one this server wrote and the browser only +// echoed back. A visitor can rewrite the `state` query parameter; doing so just fails the +// comparison. Putting the workspace there instead would let anyone choose the tenant their +// Google identity is admitted to. +// +// The workspace is known HERE and not at the callback: the person clicked "sign in with +// Google" on their own public host, and the callback arrives on the identity host, which +// names no tenant. That asymmetry is the whole reason this parameter exists (D11). +func (h *Handler) newOAuthState(w http.ResponseWriter, workspaceID string) (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } state := hex.EncodeToString(b) + cookieValue := state + if workspaceID != "" { + cookieValue = state + oauthStateSep + workspaceID + } http.SetCookie(w, &http.Cookie{ // #nosec G124 -- HttpOnly/SameSite/Secure are all set; Secure is h.secureCookie (dynamic on BASE_URL scheme), which gosec's static check can't verify Name: stateCookieName, - Value: state, + Value: cookieValue, Path: "/", MaxAge: int(stateDuration.Seconds()), HttpOnly: true, @@ -28,11 +49,26 @@ func (h *Handler) newOAuthState(w http.ResponseWriter) (string, error) { return state, nil } -// verifyOAuthState checks the ?state param against the state cookie and clears the -// cookie regardless (single use). Returns true when they match. -func (h *Handler) verifyOAuthState(w http.ResponseWriter, r *http.Request) bool { +// oauthStateSep separates the CSRF nonce from the workspace id inside the state cookie. +// A workspace id is [a-z0-9_-]{1,64} and a nonce is hex, so neither can contain it. +const oauthStateSep = "|" + +// verifyOAuthState checks the ?state param against the state cookie, clears the cookie +// regardless (single use), and returns the workspace the login was started from. +// +// The nonce is compared; the workspace is READ. That split is the security property: the +// query parameter only has to match, so an attacker rewriting it achieves a failed login, +// while the value that selects the tenant never left this server's cookie. +func (h *Handler) verifyOAuthState(w http.ResponseWriter, r *http.Request) (string, bool) { c, err := r.Cookie(stateCookieName) - ok := err == nil && c.Value != "" && r.URL.Query().Get("state") == c.Value + nonce, workspaceID := "", "" + if err == nil { + nonce = c.Value + if i := strings.Index(c.Value, oauthStateSep); i >= 0 { + nonce, workspaceID = c.Value[:i], c.Value[i+1:] + } + } + ok := err == nil && nonce != "" && r.URL.Query().Get("state") == nonce http.SetCookie(w, &http.Cookie{ // #nosec G124 -- HttpOnly/SameSite/Secure are all set; Secure is h.secureCookie (dynamic on BASE_URL scheme), which gosec's static check can't verify Name: stateCookieName, Value: "", @@ -42,18 +78,65 @@ func (h *Handler) verifyOAuthState(w http.ResponseWriter, r *http.Request) bool SameSite: http.SameSiteLaxMode, Secure: h.secureCookie, }) - return ok + return workspaceID, ok } -// finishOAuthLogin resolves an OAuth-verified email to an existing, non-archived -// user and starts a session, redirecting to /admin on success or /admin/login with -// an error otherwise. Self-registration is not allowed — only known emails sign in. -func (h *Handler) finishOAuthLogin(w http.ResponseWriter, r *http.Request, email string) { +// finishOAuthLogin resolves an OAuth-verified email to an existing, non-archived user and +// starts a session. Self-registration is not allowed — only known emails sign in. +// +// workspaceID is the tenant the login STARTED from, recovered from the state cookie. It is +// "" in single-tenant mode, where this function behaves exactly as it always has: look the +// email up, create a session, redirect to /admin. +// +// ⛔ In multi-tenant mode two things change, and both were bugs before D11 landed: +// +// 1. The lookup is scoped. `SELECT id FROM users WHERE email = ?` on the platform handle +// resolves an ARBITRARY workspace's user when the same address exists in several — which +// since D9 is legitimate and ordinary — and then starts a session for that stranger. +// 2. The session is not set here. This callback runs on the IDENTITY host +// (GET /v1/auth/callback, Platform), and a cookie for the identity host is no use to a +// person whose admin UI is on their own domain. So it mints a short-lived SSO token for +// the workspace and redirects to that workspace's public host, which is the only place +// the cookie can be set (D11). +// +// The MCP Connect return is the deliberate exception: /oauth/authorize is an identity-host +// endpoint whose consent-step cookies were set there, so sending it to a tenant's public +// host would arrive with none of them. That tail keeps its identity-host cookie. +func (h *Handler) finishOAuthLogin(w http.ResponseWriter, r *http.Request, email, workspaceID string) { + email = strings.ToLower(strings.TrimSpace(email)) + + ws := DefaultWorkspace + if h.multiTenant { + if workspaceID == "" { + // The state cookie carried no workspace, so the login did not start on a + // tenant's host. Nothing can be resolved from an OAuth identity alone. + h.logger.WarnContext(r.Context(), "auth: oauth login with no workspace in state") + http.Redirect(w, r, "/admin/login?error=workspace", http.StatusFound) + return + } + resolved, err := h.workspaceByID(r.Context(), workspaceID) + if err != nil { + h.logger.ErrorContext(r.Context(), "auth: resolve workspace from state", + "error", err, "workspace_id", workspaceID) + http.Redirect(w, r, "/admin/login?error=workspace", http.StatusFound) + return + } + if resolved.Suspended() { + http.Redirect(w, r, "/admin/login?error=suspended", http.StatusFound) + return + } + ws = resolved + } + var userID string var archivedAt sql.NullString + // workspace_id is named even in single-tenant mode, where it is 'default' and every row + // carries it: one statement, one behaviour, and no branch that could drift. if err := h.db.QueryRowContext(r.Context(), - `SELECT id, archived_at FROM users WHERE email = ?`, email).Scan(&userID, &archivedAt); err != nil { - h.logger.WarnContext(r.Context(), "auth: no account for email", "email", email) + `SELECT id, archived_at FROM users WHERE workspace_id = ? AND email = ?`, + ws.ID, email).Scan(&userID, &archivedAt); err != nil { + h.logger.WarnContext(r.Context(), "auth: no account for email", + "email", email, "workspace_id", ws.ID) http.Redirect(w, r, "/admin/login?error=no_account", http.StatusFound) return } @@ -61,16 +144,92 @@ func (h *Handler) finishOAuthLogin(w http.ResponseWriter, r *http.Request, email http.Redirect(w, r, "/admin/login?error=archived", http.StatusFound) return } - if err := h.createSession(r.Context(), w, userID); err != nil { - h.logger.ErrorContext(r.Context(), "auth: create session", "error", err) - http.Redirect(w, r, "/admin/login?error=session", http.StatusFound) - return - } - // If this login was initiated by an MCP "Connect" flow, return to /oauth/authorize - // (the consent step) rather than the admin home. + + // If this login was initiated by an MCP "Connect" flow, return to /oauth/authorize (the + // consent step) on THIS host, with the cookie set here — see the note above. if dest, ok := h.consumeOAuthReturn(w, r); ok { + if err := h.createSessionIn(r.Context(), w, userID, sessionWorkspace(h, ws)); err != nil { + h.logger.ErrorContext(r.Context(), "auth: create session", "error", err) + http.Redirect(w, r, "/admin/login?error=session", http.StatusFound) + return + } http.Redirect(w, r, dest, http.StatusFound) // #nosec G710 -- dest is validated by safeLocalPath (only "/oauth/authorize", never "//") both when set and when consumed; gosec's taint analysis can't trace through that check return } - http.Redirect(w, r, "/admin", http.StatusFound) + + if !h.multiTenant { + if err := h.createSessionIn(r.Context(), w, userID, ""); err != nil { + h.logger.ErrorContext(r.Context(), "auth: create session", "error", err) + http.Redirect(w, r, "/admin/login?error=session", http.StatusFound) + return + } + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + // Multi-tenant: hand off to the workspace's own host, which is where the cookie belongs. + token, err := h.mintSSOToken(ws, email, ssoHandoffName(r), "member") + if err != nil { + h.logger.ErrorContext(r.Context(), "auth: mint sso token", "error", err, + "workspace_id", ws.ID) + http.Redirect(w, r, "/admin/login?error=sso", http.StatusFound) + return + } + target := "https://" + ws.PublicHost + "/v1/auth/sso?token=" + url.QueryEscape(token) + + "&next=" + url.QueryEscape(ssoDefaultNext) + h.logger.InfoContext(r.Context(), "auth: handing off to the workspace host", + "workspace_id", ws.ID, "public_host", ws.PublicHost) + http.Redirect(w, r, target, http.StatusFound) // #nosec G710 -- ws.PublicHost comes from the workspaces row, not from the request +} + +// sessionWorkspace is the workspace id a session row should name: nothing in single-tenant +// mode (the column default is correct there and the statement stays as it was), the resolved +// workspace otherwise, because a Platform route's handle binds nothing. +func sessionWorkspace(h *Handler, ws *Workspace) string { + if !h.multiTenant { + return "" + } + return ws.ID +} + +// ssoHandoffName is the display name to create a user with, if the hand-off ends up creating +// one. The OAuth callback knows the verified email but not always a name, and a hand-off with +// an empty name is refused by the SSO endpoint's own validation — so the local part stands in +// until the person edits it. +func ssoHandoffName(r *http.Request) string { + if n := strings.TrimSpace(r.URL.Query().Get("name")); n != "" { + return n + } + return "New user" +} + +// mintSSOToken signs the hand-off token the SSO endpoint verifies (D11). +// +// ⛔ role is always "member". The OAuth callback is not an authority on roles: it knows that +// Google or Microsoft vouched for an email address, which says nothing about what that person +// may do here. ssoResolveUser leaves an existing user's role alone, so this value only ever +// applies to a user it creates. +// +// exp is 30s out, half the endpoint's 60s ceiling: the token is spent by a browser following +// a redirect it already has in hand. +func (h *Handler) mintSSOToken(ws *Workspace, email, name, role string) (string, error) { + if h.ssoSecret == "" { + // ⛔ Not a silent fallback. Without the shared secret the hand-off endpoint 404s, so + // there is nowhere for this login to land, and setting a cookie on the identity host + // instead would produce a session the person's admin UI cannot see. + return "", fmt.Errorf("CALNODE_SSO_SHARED_SECRET is not set, so a multi-tenant OAuth login has nowhere to hand off to") + } + now := time.Now().UTC() + claims := ssoClaims{ + Iss: h.baseURL, + Aud: "https://" + ws.PublicHost, + Sub: email, + Name: name, + Role: role, + Iat: now.Unix(), + Exp: now.Add(30 * time.Second).Unix(), + JTI: uid.New(), + WID: ws.ID, + } + return signSSOToken(claims, h.ssoSecret) } diff --git a/internal/handler/availability.go b/internal/handler/availability.go index 6577260..a147aee 100644 --- a/internal/handler/availability.go +++ b/internal/handler/availability.go @@ -4,8 +4,8 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -47,7 +47,7 @@ func (h *Handler) CreateAvailabilityRule(w http.ResponseWriter, r *http.Request) VALUES (?, ?, ?, ?, ?, ?)`, id, user.ID, req.EventTypeID, req.DayOfWeek, req.StartTime, req.EndTime) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } @@ -71,7 +71,7 @@ func (h *Handler) ListAvailabilityRules(w http.ResponseWriter, r *http.Request) user, _ := userFromContext(r.Context()) var ( - rows *sql.Rows + rows *db.Rows err error ) if etID := r.URL.Query().Get("event_type_id"); etID != "" { @@ -177,7 +177,7 @@ func (h *Handler) UpdateAvailabilityRule(w http.ResponseWriter, r *http.Request) `UPDATE availability_rules SET day_of_week=?, start_time=?, end_time=? WHERE id=? AND user_id=?`, current.DayOfWeek, current.StartTime, current.EndTime, id, user.ID) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a rule for this day and time already exists") return } diff --git a/internal/handler/booking_filter_test.go b/internal/handler/booking_filter_test.go index 249adbb..40d1c99 100644 --- a/internal/handler/booking_filter_test.go +++ b/internal/handler/booking_filter_test.go @@ -1,13 +1,13 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -53,7 +53,7 @@ func listIDs(r listResp) []string { } // seedBooking inserts one booking directly. start/end are RFC3339 UTC. -func seedBooking(t *testing.T, db *sql.DB, id, etID, hostID, start, end, status string) { +func seedBooking(t *testing.T, db *db.DB, id, etID, hostID, start, end, status string) { t.Helper() if _, err := db.Exec( `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status) diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go index 7f0c5d9..aa54a5a 100644 --- a/internal/handler/booking_handler.go +++ b/internal/handler/booking_handler.go @@ -15,8 +15,10 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/metrics" "github.com/calnode/calnode/internal/slots" "github.com/calnode/calnode/internal/uid" "github.com/calnode/calnode/internal/webhook" @@ -734,7 +736,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) { if err := h.db.QueryRowContext(r.Context(), ` SELECT COUNT(*) FROM bookings b JOIN booking_attendees a ON a.booking_id = b.id AND a.is_organizer = 1 - WHERE a.email = ? COLLATE NOCASE AND b.created_at > ?`, + WHERE LOWER(a.email) = LOWER(?) AND b.created_at > ?`, req.Email, windowStart).Scan(&recent); err != nil { h.logger.ErrorContext(r.Context(), "create booking: per-email throttle", "error", err) } else if recent >= maxBookingsPerEmailPerHour { @@ -1096,7 +1098,7 @@ func (h *Handler) createHostEventsAndNotify(ctx context.Context, b *booking.Book if livekitHostURL != "" { hd.LocationValue = livekitHostURL // host email gets the controls-enabled link } - if err := mailer.SendConfirmationToHost(ctx, h.mailer, hd); err != nil { + if err := mailer.SendConfirmationToHost(ctx, h.getMailer(), hd); err != nil { h.logger.Error("booking confirmation email (host)", "error", err, "booking_id", b.ID, "host", host.UserID) } } @@ -1161,10 +1163,14 @@ func (h *Handler) dispatchBookingConfirmation(b *booking.Booking, in bookingConf bData.AttachICS = h.noConnectedDestination(ctx, b.HostID) bData.ICSSequence = int(b.UpdatedAt.Unix()) if primaryPrefs.NotifyConfirmation { - if err := mailer.SendConfirmationToAttendee(ctx, h.mailer, bData); err != nil { + if err := mailer.SendConfirmationToAttendee(ctx, h.getMailer(), bData); err != nil { h.logger.Error("booking confirmation email (attendee)", "error", err, "booking_id", b.ID) } } + // Counted where the lifecycle event is dispatched, not where the webhook is enqueued: + // an instance with no webhooks configured still has bookings worth counting. This runs + // on every create path (REST + MCP) because dispatchBookingConfirmation is shared. + metrics.BookingEvent(metrics.BookingCreated) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.created", webhook.BookingPayload{ ID: b.ID, @@ -1574,7 +1580,7 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { } if prefs.NotifyHostCancel { hd := h.hostBookingData(ctx, d, host, b.UpdatedAt) - if err := mailer.SendCancellationToHost(ctx, h.mailer, hd); err != nil { + if err := mailer.SendCancellationToHost(ctx, h.getMailer(), hd); err != nil { h.logger.Error("booking cancellation email (host)", "error", err, "booking_id", b.ID, "host", host.UserID) } } @@ -1582,7 +1588,7 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { d.AttachICS = h.noConnectedDestination(ctx, b.HostID) d.ICSSequence = int(b.UpdatedAt.Unix()) if primaryPrefs.NotifyCancellation { - if err := mailer.SendCancellationToAttendee(ctx, h.mailer, d); err != nil { + if err := mailer.SendCancellationToAttendee(ctx, h.getMailer(), d); err != nil { h.logger.Error("booking cancellation email (attendee)", "error", err, "booking_id", b.ID) } } @@ -1592,6 +1598,7 @@ func (h *Handler) cancelSideEffects(b booking.Booking) { _ = h.db.QueryRowContext(ctx, `SELECT payment_status, amount_paid_cents, amount_paid_currency FROM bookings WHERE id = ?`, b.ID). Scan(&payStatus, &payAmt, &payCur) + metrics.BookingEvent(metrics.BookingCancelled) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.cancelled", webhook.BookingPayload{ ID: b.ID, @@ -1843,10 +1850,9 @@ func (h *Handler) loadHostPrefs(ctx context.Context, hostID string) (hostPrefs, return p, nil } -// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY constraint failure. -func isForeignKeyViolation(err error) bool { - return strings.Contains(err.Error(), "FOREIGN KEY constraint failed") -} +// isForeignKeyViolation reports whether err is a foreign-key violation, on either +// engine. A thin wrapper so the two call sites keep reading as a local predicate. +func isForeignKeyViolation(err error) bool { return db.IsForeignKeyViolation(err) } // enqueueReminder inserts a reminder.send job scheduled hoursBefore hours before startAt. // If the computed run_at has already passed, the job fires on the next poll cycle. @@ -1862,9 +1868,13 @@ func (h *Handler) enqueueReminder(ctx context.Context, bookingID string, startAt return fmt.Errorf("enqueue reminder: marshal payload: %w", err) } + // ON CONFLICT DO NOTHING is the portable spelling of SQLite's INSERT OR + // IGNORE, and drops the duplicate that jobs' UNIQUE(type, payload) rejects + // when the same reminder is enqueued twice. _, err = h.db.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT DO NOTHING`, uid.New(), string(payload), runAt.Format(time.RFC3339)) return err } @@ -1934,11 +1944,19 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin } defer tx.Rollback() //nolint:errcheck - if _, err := tx.ExecContext(ctx, ` - DELETE FROM jobs - WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? - AND status != 'running'`, bookingID); err != nil { + // jobs.payload is TEXT holding JSON, and extracting a field out of it is the one + // operation here with no spelling both engines accept: json_extract is SQLite's + // JSON1 function, ->> needs an explicit cast because the column is TEXT rather + // than json. Hence a dialect pair rather than one statement. + if _, err := tx.ExecContext(ctx, tx.Dialect().SQL( + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND json_extract(payload, '$.booking_id') = ? + AND status != 'running'`, + `DELETE FROM jobs + WHERE type = 'reminder.send' + AND payload::json ->> 'booking_id' = ? + AND status != 'running'`), bookingID); err != nil { return fmt.Errorf("replace reminder jobs: delete: %w", err) } @@ -1952,9 +1970,43 @@ func (h *Handler) replaceReminderJobs(ctx context.Context, bookingID, etID strin if err != nil { return fmt.Errorf("replace reminder jobs: marshal payload: %w", err) } + // ⛔ UPSERT, not DO NOTHING, and the difference is a silently wrong reminder. + // + // This runs in the detached rescheduleSideEffects goroutine, and the booking's + // CREATE path enqueues its reminders from a detached goroutine too. Both write + // the identical payload {booking_id, hours_before}, and jobs carries a unique on + // (workspace_id, type, payload) — so if the create-side INSERT lands after the + // DELETE above and before this statement, DO NOTHING would drop the rescheduled + // row and leave the reminder pinned to the ORIGINAL time, permanently and with + // no error anywhere. Measured at 2 failures in 60 runs on PostgreSQL; the window + // is one booking created and rescheduled inside the same second. + // + // DO UPDATE makes all three interleavings agree on the same answer: the + // reschedule's run_at. The create side deliberately keeps DO NOTHING — a create + // must never overwrite a reschedule, and by the time the two can collide the + // reschedule is the later fact. + // + // The arbiter is the index's exact column set, read from migration 00060 rather + // than assumed: both engines declare (workspace_id, type, payload) after it, and + // on PostgreSQL a target that does not match an index is a runtime error rather + // than a compile one. + // + // A `done` row for this payload never reaches this statement: the DELETE above + // spares only 'running', so an already-fired reminder is removed and re-inserted + // fresh. That is the intended semantics — the attendee was reminded about a time + // that has since moved, so they are owed another reminder at the new one. + // + // ⚠️ WHERE jobs.status <> 'running' preserves the invariant the DELETE already + // encodes: a job the worker has claimed is being executed right now, and + // resetting it to pending underneath would either double-send or be clobbered by + // the worker's own completion write. A running conflict therefore leaves the row + // alone, which is exactly what happens today. if _, err := tx.ExecContext(ctx, ` - INSERT OR IGNORE INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3)`, + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT (workspace_id, type, payload) DO UPDATE + SET run_at = excluded.run_at, status = 'pending', attempts = 0 + WHERE jobs.status <> 'running'`, uid.New(), string(payload), runAt.Format(time.RFC3339)); err != nil { return fmt.Errorf("replace reminder jobs: insert: %w", err) } diff --git a/internal/handler/branding_settings.go b/internal/handler/branding_settings.go index 98e3f18..b59cfc3 100644 --- a/internal/handler/branding_settings.go +++ b/internal/handler/branding_settings.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" "github.com/disintegration/imaging" @@ -219,8 +220,8 @@ func (h *Handler) PatchBranding(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET business_name = ?, logo_height = ?, logo_opacity = ?, - banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = datetime('now') - WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale); err != nil { + banner_opacity = ?, privacy_url = ?, terms_url = ?, fallback_locale = ?, updated_at = ? + WHERE id = 1`, req.BusinessName, req.LogoHeight, req.LogoOpacity, req.BannerOpacity, privacyURL, termsURL, req.FallbackLocale, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "branding settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -316,7 +317,7 @@ func (h *Handler) UploadBrandingLogo(w http.ResponseWriter, r *http.Request) { logoURL := fmt.Sprintf("%s?v=%d", logoServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = ?, updated_at = datetime('now') WHERE id = 1`, logoURL); err != nil { + `UPDATE server_settings SET logo_url = ?, updated_at = ? WHERE id = 1`, logoURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -331,7 +332,7 @@ func (h *Handler) DeleteBrandingLogo(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "logo.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET logo_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET logo_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "logo: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -443,7 +444,7 @@ func (h *Handler) UploadBrandingBanner(w http.ResponseWriter, r *http.Request) { bannerURL := fmt.Sprintf("%s?v=%d", bannerServePath, time.Now().Unix()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = ?, updated_at = datetime('now') WHERE id = 1`, bannerURL); err != nil { + `UPDATE server_settings SET banner_url = ?, updated_at = ? WHERE id = 1`, bannerURL, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: update db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -458,7 +459,7 @@ func (h *Handler) DeleteBrandingBanner(w http.ResponseWriter, r *http.Request) { } _ = os.Remove(filepath.Join(h.brandingDir(), "banner.png")) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET banner_url = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + `UPDATE server_settings SET banner_url = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "banner: delete db", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/calendar.go b/internal/handler/calendar.go index 2514781..50df347 100644 --- a/internal/handler/calendar.go +++ b/internal/handler/calendar.go @@ -91,6 +91,43 @@ func (h *Handler) CalendarCallback(w http.ResponseWriter, r *http.Request) { return } + // ⛔ The workspace comes from the STATE, via the user it names, and the exchange runs + // bound to it. This route is Platform-wrapped — the callback arrives on the identity host + // with no tenant Host and no session — so h and its calendar providers hold the UNBOUND + // handle, which under the policies writes nothing: the connection would appear to succeed + // and no calendar_connections row would exist. + // + // The state is encrypted (the provider's own EncryptState), so the user id inside it + // cannot be forged, and workspaceOfUser resolves the tenant from it on the platform + // handle. That is why the workspace is not ALSO carried in the state: it would be a second + // copy of a fact the user id already settles, and two sources of one truth is how they + // come to disagree. + scoped := h + if h.multiTenant { + wsID, wsErr := h.workspaceOfUser(r.Context(), userID) + if wsErr != nil { + h.logger.ErrorContext(r.Context(), "calendar callback: resolve workspace", + "error", wsErr, "user_id", userID) + h.writeError(w, http.StatusBadRequest, "invalid or missing state") + return + } + ws, wsErr := h.workspaceByID(r.Context(), wsID) + if wsErr != nil { + h.logger.ErrorContext(r.Context(), "calendar callback: read workspace", + "error", wsErr, "workspace_id", wsID) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + scoped = h.forWorkspace(ws) + // The provider has to be the workspace's own, not the process-wide registry's: each + // captures a *db.DB at construction (B5, calendar.Provider.ForDB). + if svcScoped := scoped.getCal(); svcScoped != nil { + if pr := svcScoped.Provider(p.Name()); pr != nil { + p = pr + } + } + } + if err := p.Exchange(r.Context(), userID, code, "primary"); err != nil { h.logger.ErrorContext(r.Context(), "calendar callback: exchange", "error", err, "user_id", userID) h.writeError(w, http.StatusInternalServerError, "internal error") @@ -99,7 +136,9 @@ func (h *Handler) CalendarCallback(w http.ResponseWriter, r *http.Request) { // Multi-calendar: connecting is additive. The first connection becomes the destination // (handled in the provider's saveToken); subsequent ones are conflict-check only. - http.Redirect(w, r, h.baseURL+"/admin/calendar?connected=true", http.StatusFound) + // Back to the workspace's own admin UI, which is on its public host — h.baseURL is the + // identity host and would send the person somewhere their session does not exist. + http.Redirect(w, r, scoped.publicURL()+"/admin/calendar?connected=true", http.StatusFound) } // ConnectCalDAV handles POST /v1/calendar/caldav/connect (auth required). CalDAV is diff --git a/internal/handler/calendar_reconcile.go b/internal/handler/calendar_reconcile.go index 5ff52c1..13ecc23 100644 --- a/internal/handler/calendar_reconcile.go +++ b/internal/handler/calendar_reconcile.go @@ -46,7 +46,48 @@ func (h *Handler) nudgeCalendarReconcile() { } } +// reconcileCalendar runs one pass per workspace. +// +// ⛔ The enumeration is on the PLATFORM handle and the passes are on bound ones. +// Every query in a pass reads bookings, booking_hosts and calendar_connections, +// which are tenant tables: one pass on the platform handle would reconcile every +// workspace's bookings against whichever workspace's calendar connections it +// happened to resolve, and one pass on the unbound application handle would read +// nothing at all and heal nothing, silently. Before this, on a multi-tenant +// instance, it was the second of those. func (h *Handler) reconcileCalendar() { + for _, scoped := range h.reconcileTargets() { + scoped.reconcileCalendarPass() + } +} + +// reconcileTargets returns one handler per workspace a pass should run for. +// +// Suspended workspaces are skipped: a suspended tenant answers 503 on its public +// and admin surfaces (D12), and healing its calendar in the background would be +// doing work on its behalf that it cannot see or stop. +func (h *Handler) reconcileTargets() []*Handler { + if !h.multiTenant { + return []*Handler{h} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ids, err := h.activeWorkspaceIDs(ctx) + if err != nil { + h.logger.Error("reconcile: enumerate workspaces", "error", err) + return nil + } + targets := make([]*Handler, 0, len(ids)) + for _, id := range ids { + targets = append(targets, h.forWorkspace(&Workspace{ID: id, Status: "active"})) + } + return targets +} + +// reconcileCalendarPass is one workspace's sweep. The receiver must already be +// bound; reconcileCalendar is the only caller. +func (h *Handler) reconcileCalendarPass() { gc := h.getCal() if gc == nil { return // calendar not configured — nothing to reconcile diff --git a/internal/handler/calendar_reconcile_tenancy_test.go b/internal/handler/calendar_reconcile_tenancy_test.go new file mode 100644 index 0000000..7cfd977 --- /dev/null +++ b/internal/handler/calendar_reconcile_tenancy_test.go @@ -0,0 +1,216 @@ +package handler + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/calnode/calnode/internal/caldav" + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// The reconciler half of Boundary 5. +// +// ⛔ Enumeration is on the platform handle and each pass is on a bound one. Every +// query in a pass reads bookings, booking_hosts and calendar_connections — tenant +// tables — so a pass on the platform handle would reconcile every workspace's +// bookings against whichever connections it resolved, and a pass on the UNBOUND +// application handle would read nothing and heal nothing. Before this it was the +// second of those, which is the failure the negative control below reproduces. + +func newReconcileHandler(t *testing.T) (*Handler, *db.DB) { + t.Helper() + app, platform := dbtest.RequireTenantPair(t) + + h := New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://app.calnode.test") + + base := calendar.NewService(app) + cdav, err := caldav.New(app, testEncKey) + if err != nil { + t.Fatalf("caldav.New: %v", err) + } + base.Register(cdav) + h.SetCalendar(base) + + return h, platform +} + +// seedOrphan creates a workspace with a CANCELLED booking that still carries a +// calendar event id — the exact divergence reconcileCancellations exists to heal. +// The host has no calendar connection, so CancelEvent has nothing to call and the +// pass clears the id without touching a network. +func seedOrphan(t *testing.T, h *Handler, platform *db.DB, wsID, status string) { + t.Helper() + ctx := context.Background() + + if _, err := platform.ExecContext(ctx, + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', ?)`, + wsID, wsID, wsID+".example.com", status); err != nil { + t.Fatalf("workspace %s: %v", wsID, err) + } + + bound := h.db.ForWorkspace(wsID) + userID := wsID + "-host" + if _, err := bound.ExecContext(ctx, + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + userID, wsID+"@example.com", wsID); err != nil { + t.Fatalf("user %s: %v", wsID, err) + } + if _, err := bound.ExecContext(ctx, + `INSERT INTO event_types (id, user_id, slug, name, duration_minutes, slot_interval_minutes) + VALUES (?, ?, ?, ?, 30, 30)`, + wsID+"-et", userID, wsID+"-intro", wsID+" intro"); err != nil { + t.Fatalf("event type %s: %v", wsID, err) + } + start := time.Now().UTC().Add(24 * time.Hour) + if _, err := bound.ExecContext(ctx, + `INSERT INTO bookings (id, event_type_id, host_id, start_at, end_at, status) + VALUES (?, ?, ?, ?, ?, 'cancelled')`, + wsID+"-booking", wsID+"-et", userID, + start.Format(time.RFC3339), start.Add(30*time.Minute).Format(time.RFC3339)); err != nil { + t.Fatalf("booking %s: %v", wsID, err) + } + if _, err := bound.ExecContext(ctx, + `INSERT INTO booking_hosts (id, booking_id, user_id, is_primary, external_event_id) + VALUES (?, ?, ?, 1, ?)`, + wsID+"-bh", wsID+"-booking", userID, wsID+"-stale-event"); err != nil { + t.Fatalf("booking host %s: %v", wsID, err) + } +} + +// eventID reads a workspace's stale event id through the platform handle, so the +// assertion is about the row's state rather than about what anyone can see. +func eventID(t *testing.T, platform *db.DB, wsID string) (string, bool) { + t.Helper() + var id *string + if err := platform.QueryRowContext(context.Background(), + `SELECT external_event_id FROM booking_hosts WHERE workspace_id = ?`, wsID).Scan(&id); err != nil { + t.Fatalf("read %s's event id: %v", wsID, err) + } + if id == nil { + return "", false + } + return *id, true +} + +// TestReconciler_enumeratesActiveWorkspacesOnly is the enumeration half, asserted +// exactly: which workspaces a sweep targets, and that each target is bound to its +// own. +func TestReconciler_enumeratesActiveWorkspacesOnly(t *testing.T) { + h, platform := newReconcileHandler(t) + + seedOrphan(t, h, platform, "acme", "active") + seedOrphan(t, h, platform, "globex", "suspended") + seedOrphan(t, h, platform, "initech", "active") + + targets := h.reconcileTargets() + + got := map[string]bool{} + for _, target := range targets { + id := target.Workspace().ID + if got[id] { + t.Errorf("workspace %s targeted twice", id) + } + got[id] = true + // The binding, not just the label: the handle each pass will query on has + // to be bound to the same workspace. + if bound := target.db.Workspace(); bound != id { + t.Errorf("target %s runs on a handle bound to %q", id, bound) + } + } + + if !got["acme"] || !got["initech"] { + t.Errorf("active workspaces missing from the sweep: %v", got) + } + if got["globex"] { + t.Error("a suspended workspace was included — it answers 503 on its own surfaces") + } + // The default workspace exists in every migrated database and is active, so it + // is expected; what must not appear is a suspended one. + t.Logf("sweep targets: %v", got) +} + +// TestReconciler_healsItsOwnWorkspaceAndSkipsSuspended is the effect half. +func TestReconciler_healsItsOwnWorkspaceAndSkipsSuspended(t *testing.T) { + h, platform := newReconcileHandler(t) + + seedOrphan(t, h, platform, "acme", "active") + seedOrphan(t, h, platform, "globex", "suspended") + + // Positive control on the fixture: both rows start stale. + for _, ws := range []string{"acme", "globex"} { + if id, ok := eventID(t, platform, ws); !ok || id == "" { + t.Fatalf("%s's fixture did not leave a stale event id (got %q, present=%v)", ws, id, ok) + } + } + + h.reconcileCalendar() + + if _, present := eventID(t, platform, "acme"); present { + t.Error("the active workspace's stale event id was not cleared") + } + id, present := eventID(t, platform, "globex") + if !present { + t.Error("the suspended workspace's row was reconciled; it should have been skipped") + } else if id != "globex-stale-event" { + t.Errorf("the suspended workspace's event id changed to %q", id) + } +} + +// TestReconciler_theOldShapeHealsNothing is the negative control: one pass on the +// UNBOUND application handle, which is what the reconciler was before this. Every +// query matches no row, so it heals nothing and reports nothing. +func TestReconciler_theOldShapeHealsNothing(t *testing.T) { + h, platform := newReconcileHandler(t) + + seedOrphan(t, h, platform, "acme", "active") + + // The old shape: one pass, on the handler as boot built it, unbound. + h.reconcileCalendarPass() + + id, present := eventID(t, platform, "acme") + if !present { + t.Fatal("the unbound pass cleared the row; the control proves nothing") + } + if id != "acme-stale-event" { + t.Errorf("the unbound pass changed the event id to %q", id) + } + t.Log("negative control: a reconciler pass on the unbound application handle healed 0 of 1 " + + "diverged bookings, with no error — every stale calendar event would stay stale forever") + + // And the fixed shape does heal it, so the difference is the binding and not the + // fixture. + h.reconcileCalendar() + if _, stillThere := eventID(t, platform, "acme"); stillThere { + t.Error("the per-workspace sweep did not heal what the unbound pass missed") + } +} + +// TestReconciler_singleTenantIsOneUnenumeratedPass: with MULTI_TENANT unset there is +// no query per sweep and the target is the handler itself, so an existing deployment +// does the same work it always did. +func TestReconciler_singleTenantIsOneUnenumeratedPass(t *testing.T) { + database := dbtest.Open(t) + h := New(database, slog.New(slog.DiscardHandler)) + + targets := h.reconcileTargets() + if len(targets) != 1 { + t.Fatalf("single-tenant sweep has %d targets; want 1", len(targets)) + } + if targets[0] != h { + t.Error("the single-tenant target is not the handler itself") + } + + ids, err := h.activeWorkspaceIDs(context.Background()) + if err != nil { + t.Fatalf("activeWorkspaceIDs: %v", err) + } + if len(ids) != 1 || ids[0] != db.DefaultWorkspaceID { + t.Errorf("activeWorkspaceIDs = %v; want [%s]", ids, db.DefaultWorkspaceID) + } +} diff --git a/internal/handler/calendar_tenancy_test.go b/internal/handler/calendar_tenancy_test.go new file mode 100644 index 0000000..2b795e7 --- /dev/null +++ b/internal/handler/calendar_tenancy_test.go @@ -0,0 +1,260 @@ +package handler + +import ( + "context" + "log/slog" + "testing" + + "github.com/calnode/calnode/internal/caldav" + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" +) + +// The calendar half of Boundary 5. +// +// Providers are the one integration whose per-tenant state is not a credential in +// server_settings but a ROW in calendar_connections — so the failure mode is not +// "one tenant's API key sends another's email", it is "one tenant's free/busy +// decides another's availability". A provider built at boot holds the handle boot +// gave it, which on a multi-tenant instance is the unbound one; before ForDB +// existed that made the whole integration inert. + +// testEncKey is a fixed 64-hex AES-256 key. The provider needs one to construct; +// nothing here encrypts or decrypts, because what is under test is which ROWS a +// provider can reach, not what is in them. +const testEncKey = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + +// newCalHandler builds a handler over a real pair with a CalDAV provider +// registered. CalDAV is the provider used here because it needs no instance-level +// OAuth app — so the test is about the handle, not about credentials. +func newCalHandler(t *testing.T) (*Handler, *db.DB) { + t.Helper() + app, platform := dbtest.RequireTenantPair(t) + + h := New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://app.calnode.test") + + // The platform-level registry: instance credentials, boot's handle. Exactly + // what server.New installs. + base := calendar.NewService(app) + cdav, err := caldav.New(app, testEncKey) + if err != nil { + t.Fatalf("caldav.New: %v", err) + } + base.Register(cdav) + h.SetCalendar(base) + + return h, platform +} + +// seedCalWorkspace creates a workspace, its owner, and one CalDAV connection row. +func seedCalWorkspace(t *testing.T, h *Handler, platform *db.DB, wsID string) string { + t.Helper() + if _, err := platform.Exec( + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', 'active')`, + wsID, wsID, wsID+".example.com"); err != nil { + t.Fatalf("seed workspace %s: %v", wsID, err) + } + + userID := wsID + "-user" + bound := h.db.ForWorkspace(wsID) + if _, err := bound.Exec( + `INSERT INTO users (id, email, name) VALUES (?, ?, ?)`, + userID, wsID+"@example.com", wsID); err != nil { + t.Fatalf("seed user for %s: %v", wsID, err) + } + // A connection row with a destination, which is what Connected and + // HasDestination read. The token columns hold ciphertext the test never + // decrypts; what is under test is which ROWS a provider can see. + if _, err := bound.Exec( + `INSERT INTO calendar_connections + (id, user_id, provider, access_token_enc, refresh_token_enc, calendar_id, check_conflicts, is_destination, created_at, account_email) + VALUES (?, ?, 'caldav', 'x', 'y', ?, 1, 1, '2026-01-01T00:00:00Z', ?)`, + wsID+"-conn", userID, wsID+"-calendar", wsID+"@example.com"); err != nil { + t.Fatalf("seed connection for %s: %v", wsID, err) + } + return userID +} + +// TestCalendar_providersSeeOnlyTheirWorkspace is the positive half: each +// workspace's service reports its OWN user connected, and reports the other +// workspace's user as not connected even though that row exists. +func TestCalendar_providersSeeOnlyTheirWorkspace(t *testing.T) { + h, platform := newCalHandler(t) + ctx := context.Background() + + userA := seedCalWorkspace(t, h, platform, "acme") + userB := seedCalWorkspace(t, h, platform, "globex") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + + calA, calB := a.getCal(), b.getCal() + if calA == nil || calB == nil { + t.Fatal("a workspace got no calendar service") + } + if calA == calB { + t.Fatal("both workspaces got the same calendar service — ForDB did not run") + } + + // Positive control on the data: through the platform handle both rows exist, so + // a false below is the policy and not an empty table. + var rows int + if err := platform.QueryRowContext(ctx, `SELECT COUNT(*) FROM calendar_connections`).Scan(&rows); err != nil { + t.Fatalf("count connections: %v", err) + } + if rows != 2 { + t.Fatalf("the fixture left %d connection rows; want 2", rows) + } + + for _, tc := range []struct { + name string + svc *calendar.Service + own string + other string + otherWS string + }{ + {name: "acme", svc: calA, own: userA, other: userB, otherWS: "globex"}, + {name: "globex", svc: calB, own: userB, other: userA, otherWS: "acme"}, + } { + t.Run(tc.name, func(t *testing.T) { + connected, _, err := tc.svc.Connected(ctx, tc.own) + if err != nil { + t.Fatalf("Connected(own): %v", err) + } + if !connected { + t.Errorf("%s cannot see its own connection", tc.name) + } + + leaked, _, err := tc.svc.Connected(ctx, tc.other) + if err != nil { + t.Fatalf("Connected(other): %v", err) + } + if leaked { + t.Errorf("%s can see %s's connection", tc.name, tc.otherWS) + } + + hasDest, err := tc.svc.HasDestination(ctx, tc.own) + if err != nil { + t.Fatalf("HasDestination(own): %v", err) + } + if !hasDest { + t.Errorf("%s cannot see its own destination", tc.name) + } + otherDest, err := tc.svc.HasDestination(ctx, tc.other) + if err != nil { + t.Fatalf("HasDestination(other): %v", err) + } + if otherDest { + t.Errorf("%s can see %s's destination", tc.name, tc.otherWS) + } + }) + } +} + +// TestCalendar_disconnectCannotReachAnotherWorkspace is the write half. Disconnect +// deletes rows; on the wrong handle it would delete another tenant's connection. +func TestCalendar_disconnectCannotReachAnotherWorkspace(t *testing.T) { + h, platform := newCalHandler(t) + ctx := context.Background() + + _ = seedCalWorkspace(t, h, platform, "acme") + userB := seedCalWorkspace(t, h, platform, "globex") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + + // A asks to disconnect B's user. It must not matter whether this errors or + // silently affects nothing — what matters is that B's row survives. + _ = a.getCal().Disconnect(ctx, userB) + + var n int + if err := platform.QueryRowContext(ctx, + `SELECT COUNT(*) FROM calendar_connections WHERE workspace_id = ?`, "globex").Scan(&n); err != nil { + t.Fatalf("count B's connections: %v", err) + } + if n != 1 { + t.Errorf("B has %d connections after A tried to disconnect its user; want 1", n) + } +} + +// TestCalendar_keyIsWhatSeparatesThem is the negative control, in the same shape as +// the mailer one: with the cache key stubbed to "", both workspaces share one +// service and whichever built first decides what the other can see. +func TestCalendar_keyIsWhatSeparatesThem(t *testing.T) { + h, platform := newCalHandler(t) + ctx := context.Background() + + userA := seedCalWorkspace(t, h, platform, "acme") + userB := seedCalWorkspace(t, h, platform, "globex") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + + build := func(scoped *Handler) func() *calendar.Service { + return func() *calendar.Service { + h.calMu.RLock() + base := h.calBase + h.calMu.RUnlock() + return base.ForDB(scoped.db) + } + } + + // Real keys: each sees only its own. + realA := h.calCache.get(a.cacheKey(), build(a)) + realB := h.calCache.get(b.cacheKey(), build(b)) + if leaked, _, _ := realA.Connected(ctx, userB); leaked { + t.Fatal("with real keys, A can already see B") + } + if leaked, _, _ := realB.Connected(ctx, userA); leaked { + t.Fatal("with real keys, B can already see A") + } + + // Key stubbed to "": B is handed the service built for A. + collapsed := newTenantCache[*calendar.Service]() + stubbedA := collapsed.get("", build(a)) + stubbedB := collapsed.get("", build(b)) + if stubbedA != stubbedB { + t.Fatal("a shared key should have handed out one service") + } + sawA, _, err := stubbedB.Connected(ctx, userA) + if err != nil { + t.Fatalf("Connected: %v", err) + } + if !sawA { + t.Fatal("the collapsed service should have seen A's connection — the control proves nothing otherwise") + } + sawOwn, _, _ := stubbedB.Connected(ctx, userB) + t.Logf("key stubbed to \"\": B's service sees A's connection = %v, its own = %v", sawA, sawOwn) + if sawOwn { + t.Error("the collapsed service saw both workspaces; expected only the one it was built for") + } +} + +// TestCalendar_singleTenantReusesTheRegistry: no rebinding, no second allocation, +// so an existing deployment runs the same objects it always did. +func TestCalendar_singleTenantReusesTheRegistry(t *testing.T) { + database := dbtest.Open(t) + h := New(database, slog.New(slog.DiscardHandler)) + + base := calendar.NewService(database) + cdav, err := caldav.New(database, testEncKey) + if err != nil { + t.Fatalf("caldav.New: %v", err) + } + base.Register(cdav) + h.SetCalendar(base) + + if got := h.getCal(); got != base { + t.Error("single-tenant getCal returned a rebound copy; it should be the registry itself") + } + if got := h.calCache.size(); got != 1 { + t.Errorf("calendar cache holds %d entries in single-tenant mode; want 1", got) + } + // SetCalendar(nil) must be visible immediately, not shadowed by the cache. + h.SetCalendar(nil) + if h.getCal() != nil { + t.Error("SetCalendar(nil) left a cached service behind") + } +} diff --git a/internal/handler/calendar_test.go b/internal/handler/calendar_test.go index 5358ff2..fb112b2 100644 --- a/internal/handler/calendar_test.go +++ b/internal/handler/calendar_test.go @@ -9,7 +9,7 @@ import ( "testing" "github.com/calnode/calnode/internal/calendar" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/handler" ) @@ -21,13 +21,7 @@ const testGCalKeyHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef // Returns (handler, gcalClient, plainAPIKey, userID). func newHandlerWithGCal(t *testing.T) (*handler.Handler, *gcal.Client, string, string) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) h := handler.New(database, slog.Default()) diff --git a/internal/handler/checkbox_answer_test.go b/internal/handler/checkbox_answer_test.go index 335a66e..9e0714a 100644 --- a/internal/handler/checkbox_answer_test.go +++ b/internal/handler/checkbox_answer_test.go @@ -1,7 +1,6 @@ package handler_test import ( - "database/sql" "encoding/json" "fmt" "net/http" @@ -9,6 +8,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/handler" ) @@ -118,7 +118,7 @@ func TestCheckboxAnswer_normalisedToYesNo(t *testing.T) { } // ownerIDOf returns the sole workspace user's id. -func ownerIDOf(t *testing.T, database *sql.DB) string { +func ownerIDOf(t *testing.T, database *db.DB) string { t.Helper() var id string if err := database.QueryRow(`SELECT id FROM users ORDER BY created_at LIMIT 1`).Scan(&id); err != nil { diff --git a/internal/handler/email_settings.go b/internal/handler/email_settings.go index b8adc73..df6ec28 100644 --- a/internal/handler/email_settings.go +++ b/internal/handler/email_settings.go @@ -10,6 +10,8 @@ import ( "strconv" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/secret" ) @@ -67,7 +69,7 @@ func BuildMailer(cfg SMTPConfig) (mailer.Mailer, EmailTransport) { // LoadEmailSettingsFromDB reads SMTP settings from server_settings and decrypts // the password. Returns nil (not an error) when smtp_host is empty — meaning // the settings have not been configured yet. -func LoadEmailSettingsFromDB(db *sql.DB, encKey [32]byte) (*SMTPConfig, error) { +func LoadEmailSettingsFromDB(db *db.DB, encKey [32]byte) (*SMTPConfig, error) { var host, port, user, passEnc, from, fromName, resendEnc string var smtpTLS, startTLS int err := db.QueryRow(` @@ -163,7 +165,7 @@ func (s emailSecret) String() string { return "smtp_pass_enc" } -// execer is the subset of *sql.DB / *sql.Tx the settings writes need, so they can be run +// execer is the subset of *db.DB / *db.Tx the settings writes need, so they can be run // inside a transaction. Saving email settings touches up to three columns across separate // statements; without a transaction a failure partway leaves the instance holding, say, a // new SMTP host with the previous password. @@ -185,14 +187,14 @@ func (h *Handler) storeEmailSecret(ctx context.Context, db execer, which emailSe var q string switch which { case secretResendAPIKey: - q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET resend_api_key_enc = ?, updated_at = ? WHERE id = 1` case secretSMTPPass: - q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = datetime('now') WHERE id = 1` + q = `UPDATE server_settings SET smtp_pass_enc = ?, updated_at = ? WHERE id = 1` default: return fmt.Errorf("unknown email secret %d", int(which)) } - if _, err := db.ExecContext(ctx, q, enc); err != nil { + if _, err := db.ExecContext(ctx, q, enc, dbtime.Now()); err != nil { return fmt.Errorf("store %s: %w", which, err) } return nil @@ -265,11 +267,11 @@ func (h *Handler) PatchEmailSettings(w http.ResponseWriter, r *http.Request) { smtp_host = ?, smtp_port = ?, smtp_user = ?, smtp_tls = ?, smtp_starttls = ?, email_from = ?, email_from_name = ?, - updated_at = datetime('now') + updated_at = ? WHERE id = 1`, req.SMTPHost, req.SMTPPort, req.SMTPUser, boolToInt(req.SMTPTLS), boolToInt(req.SMTPStartTLS), - req.EmailFrom, req.EmailFromName); err != nil { + req.EmailFrom, req.EmailFromName, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "email settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -394,7 +396,7 @@ func (h *Handler) TestEmailConnection(w http.ResponseWriter, r *http.Request) { _, transport = BuildMailer(*cfg) } - if err := h.mailer.Send(ctx, mailer.Message{ + if err := h.getMailer().Send(ctx, mailer.Message{ To: []string{user.Email}, Subject: "[TEST] Calnode email configuration", Text: "This is a test email from Calnode. If you received this, your email settings are working correctly.", diff --git a/internal/handler/email_settings_test.go b/internal/handler/email_settings_test.go index c290685..3df55f1 100644 --- a/internal/handler/email_settings_test.go +++ b/internal/handler/email_settings_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/mailer" ) @@ -61,7 +62,7 @@ func TestGetEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-get-email-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u4','other3@example.com','Other3','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k4','u4','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/email", "", rawKey) rec := httptest.NewRecorder() @@ -216,7 +217,7 @@ func TestPatchEmailSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-test-key-xyz" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u2','other@example.com','Other','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k2','u2','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPatch, "/v1/settings/email", `{"smtp_host":"evil.smtp.example.com"}`, rawKey) rec := httptest.NewRecorder() @@ -286,7 +287,7 @@ func TestTestEmailConnection_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-conn-test-key" hash := sha256HexForTest(rawKey) db.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u3','other2@example.com','Other2','UTC',0)`) - db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,datetime('now'))`, hash) + db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k3','u3','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodPost, "/v1/settings/email/test", "", rawKey) rec := httptest.NewRecorder() diff --git a/internal/handler/embed_handler_test.go b/internal/handler/embed_handler_test.go index 42654eb..ff12ec9 100644 --- a/internal/handler/embed_handler_test.go +++ b/internal/handler/embed_handler_test.go @@ -12,7 +12,7 @@ import ( // (b) answer If-None-Match with a 304 — that's what lets a redeploy propagate within // minutes instead of being pinned for the full max-age. func TestEmbedJS_etagRevalidation(t *testing.T) { - h := &Handler{} + h := &Handler{shared: &shared{}} rec := httptest.NewRecorder() h.EmbedJS(rec, httptest.NewRequest(http.MethodGet, "/embed.js", nil)) @@ -53,7 +53,7 @@ func TestEmbedJS_etagRevalidation(t *testing.T) { // disclosure element pulls from the fetched i18n map (t(this.i18n, 'assistant_disclosure')), // not a hardcoded literal that could silently diverge again. func TestEmbedJS_assistantDisclosure(t *testing.T) { - h := &Handler{} + h := &Handler{shared: &shared{}} rec := httptest.NewRecorder() h.EmbedJS(rec, httptest.NewRequest(http.MethodGet, "/embed.js", nil)) body := rec.Body.String() @@ -67,7 +67,7 @@ func TestEmbedJS_assistantDisclosure(t *testing.T) { } func TestBookingCSS_cacheModes(t *testing.T) { - h := &Handler{} + h := &Handler{shared: &shared{}} // Unversioned: short cache + revalidate + ETag. rec := httptest.NewRecorder() diff --git a/internal/handler/event_type.go b/internal/handler/event_type.go index 40a9e98..b97ae09 100644 --- a/internal/handler/event_type.go +++ b/internal/handler/event_type.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/uid" ) @@ -160,8 +162,12 @@ const selectETCols = "SELECT " + etColumns + " FROM event_types" // listEventTypesQuery returns every event type the user owns OR is an assigned // host on, with an `owned` flag (the owner is also seeded into event_type_hosts, // so ownership is keyed on event_types.user_id, not host membership). -const listEventTypesQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, - (archived_at IS NOT NULL) AS archived +// +// The two flags are CASE expressions rather than bare comparisons because a bare +// comparison's type is engine-dependent: SQLite yields 0/1 and PostgreSQL yields a +// boolean, and the boolean does not scan into the int the 0/1 columns beside it use. +const listEventTypesQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?) @@ -170,10 +176,10 @@ ORDER BY created_at` // getEventTypeQuery fetches one event type by slug if the user owns it or hosts // it, with the `owned` flag and the owner's name/email (so a read-only host knows // who to contact for changes). -const getEventTypeQuery = "SELECT " + etColumns + `, (user_id = ?) AS owned, +const getEventTypeQuery = "SELECT " + etColumns + `, CASE WHEN user_id = ? THEN 1 ELSE 0 END AS owned, (SELECT name FROM users WHERE id = event_types.user_id) AS owner_name, (SELECT email FROM users WHERE id = event_types.user_id) AS owner_email, - (archived_at IS NOT NULL) AS archived + CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END AS archived FROM event_types WHERE slug = ? AND (user_id = ? OR id IN (SELECT event_type_id FROM event_type_hosts WHERE user_id = ?))` @@ -315,11 +321,11 @@ func (h *Handler) CreateEventType(w http.ResponseWriter, r *http.Request) { routingMode, bufBefore, bufAfter, minNotice, maxFuture, maxActive, showTaken, defaultMsgConfirmation, defaultMsgCancellation, defaultMsgReschedule, defaultMsgReminder) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "slug already in use") return } - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -596,9 +602,10 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { } if req.Archived != nil { if *req.Archived { - // strftime literal (no bound value) — matches the DB's timestamp format; - // also force is_active off so the archived type stops taking bookings. - setClauses = append(setClauses, "archived_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") + // Bound value in the DB's millisecond timestamp format, which is what + // strftime('%Y-%m-%dT%H:%M:%fZ','now') wrote here before; also force + // is_active off so the archived type stops taking bookings. + set("archived_at", dbtime.NowMilli()) set("is_active", 0) } else { setClauses = append(setClauses, "archived_at = NULL") @@ -701,7 +708,7 @@ func (h *Handler) PatchEventType(w http.ResponseWriter, r *http.Request) { "UPDATE event_types SET "+strings.Join(setClauses, ", ")+" WHERE slug = ? AND user_id = ?", // #nosec G202 -- setClauses is built by set()/the literal col list above; every column name is a hardcoded string, every value is bound via args... args...) if err != nil { - if strings.Contains(err.Error(), "CHECK constraint failed") { + if db.IsCheckViolation(err) { h.writeError(w, http.StatusBadRequest, "invalid location_type or routing_mode value") return } @@ -780,7 +787,7 @@ func (h *Handler) DeleteEventType(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), `DELETE FROM event_types WHERE slug = ? AND user_id = ?`, slug, user.ID) if err != nil { - if strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + if db.IsForeignKeyViolation(err) { h.writeError(w, http.StatusConflict, "this event type has bookings in its history (including cancelled ones) and can't be deleted — deactivate it instead") return } diff --git a/internal/handler/export_internal_test.go b/internal/handler/export_internal_test.go new file mode 100644 index 0000000..b46ee82 --- /dev/null +++ b/internal/handler/export_internal_test.go @@ -0,0 +1,80 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "time" +) + +// Test-only bridges. This file is a _test.go, so these identifiers exist for +// handler_test and are absent from any real build of the package — the alternative, +// exporting replaceReminderJobs, would widen the production API to serve one test. + +// ReplaceReminderJobsForTest calls the unexported reminder replacement, so the external +// test package can drive the exact statement the reschedule path runs rather than a +// copy of it. A test that re-typed the SQL would pass with the production code broken. +func ReplaceReminderJobsForTest(h *Handler, ctx context.Context, bookingID, etID string, newStart time.Time) error { + return h.replaceReminderJobs(ctx, bookingID, etID, newStart) +} + +// FinishOAuthLoginForTest drives the OAuth callback's tail and returns the Location it +// redirected to, so the external test package can spend the token it minted against the real +// SSO endpoint. It returns "" if no redirect was issued. +// +// The tail is what D11 changed, and it is reachable no other way without standing up a fake +// Google: the exchange above it needs a provider. Driving it directly keeps the test about the +// hand-off rather than about oauth2. +func FinishOAuthLoginForTest(h *Handler, email, workspaceID string) string { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/auth/callback", nil) + // ⛔ Through Platform, because that is how the route is registered and it decides which + // handle the tail runs on. Called on the bare handler the lookup would use the UNBOUND + // application handle, which under the policies matches no row — the login would report + // "no account" for a user that exists, which is precisely the failure mode this bridge + // would otherwise hide. + h.Platform(func(p *Handler, w http.ResponseWriter, r *http.Request) { + p.finishOAuthLogin(w, r, email, workspaceID) + })(rec, req) + return rec.Header().Get("Location") +} + +// LiveKitEventWorkspaceForTest and StripeEventWorkspaceForTest expose the vendor-webhook +// resolvers, which is where the tenancy decision is actually made. +// +// ⛔ They are bridged rather than tested through the HTTP handler because verification needs a +// real vendor secret: an end-to-end test can only ever observe the 403, which says nothing +// about WHICH workspace the event resolved to. Both run through Platform, because that is the +// handle the route gives them — on the bare handler the reads would use the unbound application +// handle and resolve nothing. +func LiveKitEventWorkspaceForTest(h *Handler, egressID, room string) (string, bool) { + var id string + var found bool + h.Platform(func(p *Handler, _ http.ResponseWriter, r *http.Request) { + scoped, ok := p.livekitEventWorkspace(r.Context(), egressID, room) + found = ok + if ok { + id = scoped.Workspace().ID + } + })(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/livekit/webhook", nil)) + return id, found +} + +func StripeEventWorkspaceForTest(h *Handler, sessionID, metadataBookingID string) (string, bool) { + var id string + var found bool + h.Platform(func(p *Handler, _ http.ResponseWriter, r *http.Request) { + scoped, ok := p.stripeEventWorkspace(r.Context(), sessionID, metadataBookingID) + found = ok + if ok { + id = scoped.Workspace().ID + } + })(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/stripe/webhook", nil)) + return id, found +} + +// STTBaseURLForWorkspaceForTest answers what the notetaker would use for one +// workspace: the scoped handler's resolution, per-tenant column first. +func STTBaseURLForWorkspaceForTest(h *Handler, workspaceID string) string { + return h.forWorkspace(&Workspace{ID: workspaceID}).sttBaseURL() +} diff --git a/internal/handler/google_settings.go b/internal/handler/google_settings.go index 965aa5e..cd70949 100644 --- a/internal/handler/google_settings.go +++ b/internal/handler/google_settings.go @@ -8,6 +8,8 @@ import ( "net/http" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/gcal" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type GoogleOAuthConfig struct { // LoadGoogleSettingsFromDB reads Google OAuth credentials from server_settings // and decrypts the client secret. Returns nil (not an error) when client_id is empty. -func LoadGoogleSettingsFromDB(db *sql.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { +func LoadGoogleSettingsFromDB(db *db.DB, encKey [32]byte) (*GoogleOAuthConfig, error) { var clientID, secretEnc string err := db.QueryRow(` SELECT google_client_id, google_client_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = '', google_client_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -121,8 +123,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, google_client_secret_enc = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID, enc); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -131,8 +133,8 @@ func (h *Handler) PatchGoogleSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET google_client_id = ?, - updated_at = datetime('now') - WHERE id = 1`, req.ClientID); err != nil { + updated_at = ? + WHERE id = 1`, req.ClientID, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "google settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/google_settings_test.go b/internal/handler/google_settings_test.go index 0b8554d..56b012f 100644 --- a/internal/handler/google_settings_test.go +++ b/internal/handler/google_settings_test.go @@ -1,19 +1,20 @@ package handler_test import ( - "database/sql" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/handler" ) // newGoogleHandler sets up a handler with a valid enc key and returns it // alongside the DB and API key for an admin user. -func newGoogleHandler(t *testing.T) (*handler.Handler, *sql.DB, string) { +func newGoogleHandler(t *testing.T) (*handler.Handler, *db.DB, string) { t.Helper() h, database, apiKey, _ := setupWorkspaceWithDB(t) // Use the same key as calendar tests so gcal.New works if needed. @@ -80,7 +81,7 @@ func TestGetGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-google-test-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-ng','ng@example.com','NG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-ng','u-ng','test',?,?)`, hash, dbtime.Now()) req := authReq(http.MethodGet, "/v1/settings/google", "", rawKey) rec := httptest.NewRecorder() @@ -245,7 +246,7 @@ func TestPatchGoogleSettings_nonAdminForbidden(t *testing.T) { rawKey := "non-admin-patch-google-key" hash := sha256HexForTest(rawKey) database.Exec(`INSERT INTO users (id, email, name, iana_timezone, is_admin) VALUES ('u-npg','npg@example.com','NPG','UTC',0)`) - database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,datetime('now'))`, hash) + database.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, created_at) VALUES ('k-npg','u-npg','test',?,?)`, hash, dbtime.Now()) rec := patchGoogleSettings(t, h, `{"client_id":"evil","client_secret":"evil"}`, rawKey) if rec.Code != http.StatusForbidden { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 6adbb08..af09026 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -1,142 +1,205 @@ package handler import ( - "database/sql" "encoding/hex" "log/slog" "net/http" - "sync" "time" "golang.org/x/oauth2" "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/mailer" "github.com/calnode/calnode/internal/stripe" + "github.com/calnode/calnode/internal/stt" "github.com/calnode/calnode/internal/webhook" "github.com/calnode/calnode/internal/zoom" ) +// Handler is a per-request value: the process-wide state behind *shared, plus the +// database handle and workspace this particular request is scoped to. +// +// In single-tenant mode there is one Handler and one workspace ("default"), and +// nothing copies it. In multi-tenant mode Scoped makes a copy per request whose +// db is bound to the resolved workspace, so a method body that reads h.db is +// tenant-scoped without being edited. The copy is a value with no locks in it, +// which is what keeps go vet copylocks clean — and it pins no connection, so a +// fire-and-forget goroutine may keep the copy it was started with (see +// db.DB.ForWorkspace). type Handler struct { - db *sql.DB - logger *slog.Logger - bookingSvc *booking.Service - mailer mailer.Mailer - live *mailer.Live // non-nil in production; nil in tests using a direct stub - encKey [32]byte // AES-256 key for encrypting secrets stored in the DB - calMu sync.RWMutex - cal *calendar.Service - calNudge chan struct{} // buffered(1): wakes the calendar reconciler after a failed inline op - webhookSvc *webhook.Service - baseURL string - publicBaseURL string - dataDir string - authMu sync.RWMutex - googleAuth *oauth2.Config - microsoftAuth *oauth2.Config - secureCookie bool - llmMu sync.RWMutex - llm *llm.Client // nil when the optional LLM layer is off/unconfigured - zoomMu sync.RWMutex - zoom *zoom.Client // nil when no Zoom app is configured - stripeMu sync.RWMutex - stripe *stripe.Client // nil when payments are unconfigured - livekitMu sync.RWMutex - livekit *livekit.Client // nil when LiveKit video is unconfigured - demoMode bool // true on the public demo instance: disables calendar/Zoom connect - demoResetInterval time.Duration - demoMu sync.RWMutex - demoNextResetAt time.Time + *shared + + db *db.DB + ws *Workspace + + // bookingSvc and webhookSvc wrap a *db.DB, so they are per-request too: + // forWorkspace rebuilds each from the scoped handle. Both are cheap structs + // over a pool, not connections. + bookingSvc *booking.Service + webhookSvc *webhook.Service } // SetLiveKit swaps the active LiveKit client (nil disables built-in video rooms). // Hot-reloadable from the LiveKit settings page. func (h *Handler) SetLiveKit(c *livekit.Client) { - h.livekitMu.Lock() - h.livekit = c - h.livekitMu.Unlock() + h.livekitCache.set(h.cacheKey(), c) // Self-heal: any recording still 'active' is an orphan from before this restart (its egress // is no longer tracked), and would otherwise block the idempotent guard on its room forever. if c != nil { if _, err := h.db.Exec( - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') WHERE status = 'active'`); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? WHERE status = 'active'`, + dbtime.Now()); err != nil { h.logger.Warn("livekit: sweep stale recordings", "error", err) } } } -// getLiveKit returns the active LiveKit client, or nil when video is unconfigured. +// getLiveKit returns this workspace's LiveKit client, or nil when video is +// unconfigured for it. Built lazily from the workspace's own settings row. func (h *Handler) getLiveKit() *livekit.Client { - h.livekitMu.RLock() - defer h.livekitMu.RUnlock() - return h.livekit + return h.livekitCache.get(h.cacheKey(), func() *livekit.Client { + cfg, err := LoadLiveKitSettingsFromDB(h.db, h.encKey) + if err != nil { + h.logger.Warn("livekit: could not load settings", "workspace", h.cacheKey(), "error", err) + return nil + } + if cfg == nil { + return nil + } + return livekit.New(cfg.URL, cfg.APIKey, cfg.APISecret, h.encKey) + }) } // SetStripe swaps the active Stripe client (nil disables paid bookings). Hot-reloadable // from the Payments settings page. func (h *Handler) SetStripe(c *stripe.Client) { - h.stripeMu.Lock() - h.stripe = c - h.stripeMu.Unlock() + h.stripeCache.set(h.cacheKey(), c) } -// getStripe returns the active Stripe client, or nil when payments are unconfigured. +// getStripe returns this workspace's Stripe client, or nil when payments are +// unconfigured for it. func (h *Handler) getStripe() *stripe.Client { - h.stripeMu.RLock() - defer h.stripeMu.RUnlock() - return h.stripe + return h.stripeCache.get(h.cacheKey(), func() *stripe.Client { + cfg, err := LoadStripeSettingsFromDB(h.db, h.encKey) + if err != nil { + h.logger.Warn("stripe: could not load settings", "workspace", h.cacheKey(), "error", err) + return nil + } + if cfg == nil { + return nil + } + sc, err := stripe.New(cfg.SecretKey, cfg.PublishableKey, cfg.WebhookSecret) + if err != nil { + h.logger.Warn("stripe: init failed", "workspace", h.cacheKey(), "error", err) + return nil + } + return sc + }) } // SetZoom swaps the active Zoom client (nil disables Zoom auto-minting). Hot-reloadable // from the Zoom settings page. func (h *Handler) SetZoom(c *zoom.Client) { - h.zoomMu.Lock() - h.zoom = c - h.zoomMu.Unlock() + h.zoomCache.set(h.cacheKey(), c) } -// getZoom returns the active Zoom client, or nil when no Zoom app is configured. +// getZoom returns this workspace's Zoom client, or nil when it has no Zoom app. +// +// zoom.New captures the handle it is given, so it gets h.db — the BOUND one — +// and the per-host tokens it later reads are this workspace's. func (h *Handler) getZoom() *zoom.Client { - h.zoomMu.RLock() - defer h.zoomMu.RUnlock() - return h.zoom + return h.zoomCache.get(h.cacheKey(), func() *zoom.Client { + cfg, err := LoadZoomSettingsFromDB(h.db, h.encKey) + if err != nil { + h.logger.Warn("zoom: could not load settings", "workspace", h.cacheKey(), "error", err) + return nil + } + if cfg == nil || cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil + } + zc, err := zoom.New(h.db, cfg.ClientID, cfg.ClientSecret, h.baseURL+"/v1/zoom/callback", hex.EncodeToString(h.encKey[:])) + if err != nil { + h.logger.Warn("zoom: init failed", "workspace", h.cacheKey(), "error", err) + return nil + } + return zc + }) } // SetLLM swaps the active LLM client (nil disables AI features). Hot-reloadable from // the settings page. func (h *Handler) SetLLM(c *llm.Client) { - h.llmMu.Lock() - h.llm = c - h.llmMu.Unlock() + h.llmCache.set(h.cacheKey(), c) } -// getLLM returns the active LLM client, or nil when AI is off — callers MUST nil-check -// and fall back to the deterministic path. +// getLLM returns this workspace's LLM client, or nil when AI is off for it — +// callers MUST nil-check and fall back to the deterministic path. func (h *Handler) getLLM() *llm.Client { - h.llmMu.RLock() - defer h.llmMu.RUnlock() - return h.llm + return h.llmCache.get(h.cacheKey(), func() *llm.Client { + cfg, err := LoadLLMSettingsFromDB(h.db, h.encKey) + if err != nil { + h.logger.Warn("llm: could not load settings", "workspace", h.cacheKey(), "error", err) + return nil + } + if cfg == nil || !cfg.Enabled || cfg.Endpoint == "" { + return nil + } + return llm.New(llm.Config{Endpoint: cfg.Endpoint, Model: cfg.Model, APIKey: cfg.APIKey}) + }) +} + +// getMailer returns this workspace's mailer. +// +// In single-tenant mode the entry is the process-wide *mailer.Live that boot +// installed, so nothing changes. In multi-tenant mode each workspace's transport +// is chosen by BuildMailer from its OWN settings row — which is what keeps one +// tenant's SMTP credentials and From address out of another's email. +func (h *Handler) getMailer() mailer.Mailer { + return h.mailerCache.get(h.cacheKey(), func() mailer.Mailer { + cfg, err := LoadEmailSettingsFromDB(h.db, h.encKey) + if err != nil { + h.logger.Warn("mailer: could not load settings", "workspace", h.cacheKey(), "error", err) + return &mailer.Noop{} + } + if cfg == nil { + return &mailer.Noop{} + } + m, _ := BuildMailer(*cfg) + return m + }) } -func New(db *sql.DB, logger *slog.Logger) *Handler { - whs, _ := webhook.New(db, "") // ephemeral key when no encryption key configured +func New(database *db.DB, logger *slog.Logger) *Handler { + whs, _ := webhook.New(database, "") // ephemeral key when no encryption key configured return &Handler{ - db: db, - logger: logger, - bookingSvc: booking.New(db), - mailer: &mailer.Noop{}, + shared: &shared{ + logger: logger, + calNudge: make(chan struct{}, 1), + mailerCache: newTenantCache[mailer.Mailer](), + calCache: newTenantCache[*calendar.Service](), + llmCache: newTenantCache[*llm.Client](), + zoomCache: newTenantCache[*zoom.Client](), + stripeCache: newTenantCache[*stripe.Client](), + livekitCache: newTenantCache[*livekit.Client](), + settingsCache: newTenantCache[tenantSettings](), + appDB: database, + }, + db: database, + ws: DefaultWorkspace, + bookingSvc: booking.New(database), webhookSvc: whs, - calNudge: make(chan struct{}, 1), } } // SetMailer configures the email sender and the base URL used in email links. // If m is a *mailer.Live, it is also stored as h.live for hot-swap support. func (h *Handler) SetMailer(m mailer.Mailer, baseURL string) { - h.mailer = m + h.mailerCache.set(h.cacheKey(), m) h.baseURL = baseURL if l, ok := m.(*mailer.Live); ok { h.live = l @@ -163,15 +226,51 @@ func (h *Handler) SetPublicBaseURL(url string) { h.publicBaseURL = url } -// publicURL returns the booker-facing base URL, defaulting to the identity host -// (baseURL) when no public host has been configured. +// publicURL returns the booker-facing base URL. +// +// In multi-tenant mode each workspace has its own public host and that replaces +// PUBLIC_BASE_URL entirely (D11): booking links, emails, embed snippets and the +// admin UI all live there, while BASE_URL stays the identity host of the whole +// process for OAuth callbacks, /.well-known/*, /oauth/*, /mcp and the platform +// API. Single-tenant behaviour is unchanged: PUBLIC_BASE_URL if set, else +// BASE_URL. func (h *Handler) publicURL() string { + if h.multiTenant && h.ws != nil && h.ws.PublicHost != "" { + return "https://" + h.ws.PublicHost + } if h.publicBaseURL != "" { return h.publicBaseURL } return h.baseURL } +// SetSTTBaseURL sets the speech-to-text endpoint host used by the notetaker. Empty keeps +// the provider default. +func (h *Handler) SetSTTBaseURL(url string) { + h.sttBaseURLCfg = url +} + +// sttBaseURL returns the effective endpoint host. Resolved on read rather than at set +// time so a Handler built without SetSTTBaseURL (every test) still reports the real +// default rather than an empty string. +// +// In multi-tenant mode a workspace's own `stt_base_url` (written by the platform +// API, see tenant_settings.go) comes first, so an EU tenant's recordings go to the +// EU speech-to-text host regardless of what the process was booted with. Empty +// falls through to the process value, then the provider default — the same ladder +// single-tenant mode has always had, with one rung added on top of it. +func (h *Handler) sttBaseURL() string { + if h.multiTenant && h.ws != nil { + if v := h.tenantSettings().sttBaseURL; v != "" { + return v + } + } + if h.sttBaseURLCfg != "" { + return h.sttBaseURLCfg + } + return stt.DefaultBaseURL +} + // SetDataDir sets the directory used for file uploads (avatars, etc.). func (h *Handler) SetDataDir(dir string) { h.dataDir = dir @@ -207,18 +306,39 @@ func (h *Handler) getDemoNextResetAt() time.Time { return h.demoNextResetAt } -// SetCalendar configures the multi-provider calendar service. +// SetCalendar installs the platform-level provider registry (nil disables calendar +// integration). Every cached per-workspace copy is dropped, because each was +// derived from the registry being replaced. func (h *Handler) SetCalendar(c *calendar.Service) { h.calMu.Lock() - h.cal = c + h.calBase = c h.calMu.Unlock() + h.calCache.invalidate(h.cacheKey()) } -// getCal returns the current calendar service under a read lock (nil if unconfigured). +// getCal returns this workspace's calendar service, or nil when the instance has +// no providers configured. +// +// ⛔ It must not return calBase. Every provider operation reads +// calendar_connections and connection_calendars, which are TENANT tables, and the +// registry holds whichever handle boot gave it — the unbound one on a multi-tenant +// instance, which matches no row. Service.ForDB rebinds the Service and every +// provider in it, keeping the OAuth app configuration (D7). func (h *Handler) getCal() *calendar.Service { - h.calMu.RLock() - defer h.calMu.RUnlock() - return h.cal + return h.calCache.get(h.cacheKey(), func() *calendar.Service { + h.calMu.RLock() + base := h.calBase + h.calMu.RUnlock() + if base == nil { + return nil + } + if !h.multiTenant { + // One workspace, one handle: rebinding would allocate a second Service + // and every provider in it for no behaviour change. + return base + } + return base.ForDB(h.db) + }) } // getGoogleAuth returns the current Google OAuth config under a read lock. @@ -246,8 +366,9 @@ func (h *Handler) isEmailEnabled() bool { if h.live != nil { return h.live.IsEnabled() } - // Fallback for tests that inject a direct stub mailer (not wrapped in Live). - _, isNoop := h.mailer.(*mailer.Noop) + // Fallback for tests that inject a direct stub mailer (not wrapped in Live), + // and the multi-tenant path, where each workspace has its own. + _, isNoop := h.getMailer().(*mailer.Noop) return !isNoop } diff --git a/internal/handler/health.go b/internal/handler/health.go index 2d9d007..43ccf2d 100644 --- a/internal/handler/health.go +++ b/internal/handler/health.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/calnode/calnode/internal/buildinfo" - "github.com/calnode/calnode/internal/db" ) func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) { @@ -32,7 +31,7 @@ func (h *Handler) Readyz(w http.ResponseWriter, r *http.Request) { // Gate readiness on migrations: report not-ready until the schema is at the // embedded target version, so a provisioner polling /readyz never routes // traffic to an instance still mid-migration (or one that failed to migrate). - ready, err := db.SchemaReady(r.Context(), h.db) + ready, err := h.db.SchemaReady(r.Context()) if err != nil || !ready { if err != nil { h.logger.ErrorContext(r.Context(), "readyz: migration check failed", "error", err) diff --git a/internal/handler/health_test.go b/internal/handler/health_test.go index 08ae220..596d08a 100644 --- a/internal/handler/health_test.go +++ b/internal/handler/health_test.go @@ -8,18 +8,13 @@ import ( "testing" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" ) func newTestHandler(t *testing.T) *handler.Handler { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()) } @@ -76,7 +71,7 @@ func TestReadyz_returns200_whenDBHealthy(t *testing.T) { func TestReadyz_returns503_whenNotMigrated(t *testing.T) { // Open a DB but do NOT migrate it — the goose bookkeeping table is absent, // so the schema-readiness gate must report not-ready. - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } @@ -115,7 +110,7 @@ func TestVersion_returns200(t *testing.T) { } func TestReadyz_returns503_whenDBClosed(t *testing.T) { - database, err := db.Open("sqlite://:memory:") + database, err := db.OpenDB("sqlite://:memory:") if err != nil { t.Fatalf("db.Open: %v", err) } diff --git a/internal/handler/idempotency.go b/internal/handler/idempotency.go index 91717f9..8991666 100644 --- a/internal/handler/idempotency.go +++ b/internal/handler/idempotency.go @@ -5,8 +5,9 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" - "strings" "time" + + "github.com/calnode/calnode/internal/db" ) // idempotencyRecord is a previously-seen Idempotency-Key's stored outcome. @@ -38,7 +39,7 @@ func (h *Handler) claimIdempotencyKey(ctx context.Context, key, reqHash string) if err == nil { return nil, false, nil } - if !strings.Contains(err.Error(), "UNIQUE constraint failed") { + if !db.IsUniqueViolation(err) { return nil, false, err } diff --git a/internal/handler/invites.go b/internal/handler/invites.go index 6d55733..8354e39 100644 --- a/internal/handler/invites.go +++ b/internal/handler/invites.go @@ -103,7 +103,7 @@ func (h *Handler) issueInvite(ctx context.Context, email, adminName, adminID str inviteURL = h.baseURL + "/admin/invite/" + token if h.isEmailEnabled() { - _ = h.mailer.Send(ctx, mailer.Message{ + _ = h.getMailer().Send(ctx, mailer.Message{ To: []string{email}, Subject: "You've been invited to Calnode", Text: "You've been invited to join Calnode by " + adminName + ".\n\n" + diff --git a/internal/handler/livekit_recording.go b/internal/handler/livekit_recording.go index 33eafb4..726bab0 100644 --- a/internal/handler/livekit_recording.go +++ b/internal/handler/livekit_recording.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/uid" "github.com/calnode/calnode/internal/webhook" @@ -117,10 +119,13 @@ func (h *Handler) RecordStart(w http.ResponseWriter, r *http.Request) { } h.logger.InfoContext(r.Context(), "livekit: egress started", "room", room, "egress_id", egressID, "filepath", filepath) bookingID := strings.TrimPrefix(room, "booking-") + // created_at keeps the datetime('now') shape: parseRecordingTime reads it back + // and consentWindow turns it into the millisecond form the consent rows use. + now := dbtime.Now() if _, err := h.db.ExecContext(r.Context(), ` INSERT INTO recordings (id, booking_id, room, egress_id, status, object_key, created_at, updated_at) - VALUES (?, ?, ?, ?, 'active', ?, datetime('now'), datetime('now'))`, - uid.New(), bookingID, room, egressID, filepath); err != nil { + VALUES (?, ?, ?, ?, 'active', ?, ?, ?)`, + uid.New(), bookingID, room, egressID, filepath, now, now); err != nil { h.logger.ErrorContext(r.Context(), "livekit: save recording", "error", err) } h.mergeRoomMeta(r.Context(), room, "recording", true) // drives the consent banner @@ -167,8 +172,8 @@ func (h *Handler) finalizeActiveRecording(ctx context.Context, room string) { h.logger.ErrorContext(ctx, "livekit: stop egress", "error", err, "egress", egressID) } if _, err := h.db.ExecContext(ctx, - `UPDATE recordings SET status = 'complete', updated_at = datetime('now') - WHERE room = ? AND status = 'active'`, room); err != nil { + `UPDATE recordings SET status = 'complete', updated_at = ? + WHERE room = ? AND status = 'active'`, dbtime.Now(), room); err != nil { h.logger.ErrorContext(ctx, "livekit: close recording row", "error", err, "room", room) } } @@ -211,8 +216,8 @@ func (h *Handler) RecordConsent(w http.ResponseWriter, r *http.Request) { VALUES (?, ?, ?, ?) ON CONFLICT(room, participant_identity) DO UPDATE SET name = excluded.name, decision = excluded.decision, - decided_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - room, identity, name, decision); err != nil { + decided_at = ?`, + room, identity, name, decision, dbtime.NowMilli()); err != nil { h.logger.ErrorContext(r.Context(), "livekit: record consent", "error", err, "room", room) h.writeError(w, http.StatusInternalServerError, "could not record consent") return @@ -295,7 +300,7 @@ func (h *Handler) ListRecordingConsent(w http.ResponseWriter, r *http.Request) { DecidedAt string `json:"decided_at"` } out := []consent{} - var rows *sql.Rows + var rows *db.Rows var err error if scoped { rows, err = h.db.QueryContext(r.Context(), ` @@ -540,16 +545,7 @@ var timeNow = func() time.Time { return time.Now() } // egress) — and 200-ACKs everything else (room_started, participant_joined/left, track_*, …) // without acting on them. Lifecycle events (attendance, duration, etc.) are not yet wired up. func (h *Handler) LiveKitWebhook(w http.ResponseWriter, r *http.Request) { - lk := h.getLiveKit() - if lk == nil { - w.WriteHeader(http.StatusOK) - return - } body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err := lk.VerifyWebhook(r.Header.Get("Authorization"), body); err != nil { - h.writeError(w, http.StatusForbidden, "invalid webhook signature") - return - } var ev struct { Event string `json:"event"` Room struct { @@ -566,6 +562,44 @@ func (h *Handler) LiveKitWebhook(w http.ResponseWriter, r *http.Request) { } `json:"egressInfo"` } _ = json.Unmarshal(body, &ev) + + // ⛔ Resolve the tenant, then verify with ITS credentials, then act — and in + // single-tenant mode verify first, exactly as before. The order differs by mode because + // the LiveKit API secret lives in server_settings, i.e. per workspace: on this + // Platform-wrapped route the handle bypasses the policies, so loading "the" settings row + // would hand back an arbitrary tenant's secret and verifying against that is not + // verification. See internal/handler/vendor_webhook.go for the full argument, including + // the two properties that make an unverified resolve safe (no write, no disclosure). + room := ev.Room.Name + if room == "" { + room = ev.EgressInfo.RoomName + } + scoped := h + if h.multiTenant { + resolved, ok := h.livekitEventWorkspace(r.Context(), ev.EgressInfo.EgressID, room) + if !ok { + // No row owns this event: a stale egress from a deleted workspace, or a room + // this instance never created. 200, because a retry cannot make it ours and a + // 4xx would make LiveKit retry it forever. + h.logger.InfoContext(r.Context(), "livekit: event for no known workspace", + "event", ev.Event, "room", room, "egress_id", ev.EgressInfo.EgressID) + w.WriteHeader(http.StatusOK) + return + } + scoped = resolved + } + lk := scoped.getLiveKit() + if lk == nil { + w.WriteHeader(http.StatusOK) + return + } + if err := lk.VerifyWebhook(r.Header.Get("Authorization"), body); err != nil { + h.writeError(w, http.StatusForbidden, "invalid webhook signature") + return + } + // From here every read and write is the workspace's own. + h = scoped + // Self-diagnose a future field-name drift: log the raw body if an egress event parsed no id. if ev.EgressInfo.EgressID == "" && strings.HasPrefix(ev.Event, "egress_") { raw := string(body) @@ -603,8 +637,8 @@ func (h *Handler) LiveKitWebhook(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), ` UPDATE recordings SET status = ?, object_key = COALESCE(NULLIF(?,''), object_key), - duration_s = ?, updated_at = datetime('now') WHERE egress_id = ?`, - status, key, durSec, info.EgressID); err != nil { + duration_s = ?, updated_at = ? WHERE egress_id = ?`, + status, key, durSec, dbtime.Now(), info.EgressID); err != nil { h.logger.ErrorContext(r.Context(), "livekit: finalize recording", "error", err) } if info.RoomName != "" { diff --git a/internal/handler/livekit_settings.go b/internal/handler/livekit_settings.go index daf4965..9606ccf 100644 --- a/internal/handler/livekit_settings.go +++ b/internal/handler/livekit_settings.go @@ -8,6 +8,8 @@ import ( "encoding/json" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/livekit" "github.com/calnode/calnode/internal/secret" ) @@ -21,7 +23,7 @@ type LiveKitConfig struct { // LoadLiveKitSettingsFromDB reads the LiveKit server config from server_settings and decrypts // the API secret. Returns nil (not an error) when the URL or key is empty (= not configured). -func LoadLiveKitSettingsFromDB(db *sql.DB, encKey [32]byte) (*LiveKitConfig, error) { +func LoadLiveKitSettingsFromDB(db *db.DB, encKey [32]byte) (*LiveKitConfig, error) { var url, apiKey, secretEnc string err := db.QueryRow(` SELECT livekit_url, livekit_api_key, livekit_api_secret_enc @@ -90,7 +92,7 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { if req.URL == "" { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = '', livekit_api_key = '', - livekit_api_secret_enc = '', updated_at = datetime('now') WHERE id = 1`); err != nil { + livekit_api_secret_enc = '', updated_at = ? WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -118,15 +120,15 @@ func (h *Handler) PatchLiveKitSettings(w http.ResponseWriter, r *http.Request) { } if _, err = h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - livekit_api_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, - req.URL, req.APIKey, enc); err != nil { + livekit_api_secret_enc = ?, updated_at = ? WHERE id = 1`, + req.URL, req.APIKey, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return } } else if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET livekit_url = ?, livekit_api_key = ?, - updated_at = datetime('now') WHERE id = 1`, req.URL, req.APIKey); err != nil { + updated_at = ? WHERE id = 1`, req.URL, req.APIKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "livekit settings: update (keep secret)", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/llm_settings.go b/internal/handler/llm_settings.go index c5019f9..97bf4e8 100644 --- a/internal/handler/llm_settings.go +++ b/internal/handler/llm_settings.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" ) @@ -34,7 +36,7 @@ type LLMConfig struct { // LoadLLMSettingsFromDB reads the optional LLM settings from server_settings and decrypts // the api key. Returns nil (not an error) when the endpoint is empty (unconfigured). -func LoadLLMSettingsFromDB(db *sql.DB, encKey [32]byte) (*LLMConfig, error) { +func LoadLLMSettingsFromDB(db *db.DB, encKey [32]byte) (*LLMConfig, error) { var endpoint, model, keyEnc string var enabled int err := db.QueryRow(` @@ -154,8 +156,11 @@ func (h *Handler) PatchLLMSettings(w http.ResponseWriter, r *http.Request) { args = append(args, v) } if len(set) > 0 { + // updated_at's placeholder trails every "col = ?" in set, so its value + // trails every value in args. + args = append(args, dbtime.Now()) if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = datetime('now') WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... + `UPDATE server_settings SET `+strings.Join(set, ", ")+`, updated_at = ? WHERE id = 1`, args...); err != nil { // #nosec G202 -- set is built above from hardcoded "col = ?" literals only; every value is bound via args... h.llmDBError(w, r, err) return } diff --git a/internal/handler/magic_link.go b/internal/handler/magic_link.go index 7c69bf9..386bd65 100644 --- a/internal/handler/magic_link.go +++ b/internal/handler/magic_link.go @@ -72,8 +72,8 @@ func (h *Handler) sendMagicLink(ctx context.Context, userID, email string) { } link := h.baseURL + "/v1/auth/magic-link/verify?token=" + raw - if h.mailer != nil { - if err := h.mailer.Send(ctx, magicLinkMessage(email, link)); err != nil { + if h.getMailer() != nil { + if err := h.getMailer().Send(ctx, magicLinkMessage(email, link)); err != nil { h.logger.ErrorContext(ctx, "magic link: send email", "error", err, "user_id", userID) } } diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index 401d1ff..e116509 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -13,6 +13,7 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/i18n" "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/metrics" "github.com/calnode/calnode/internal/webhook" ) @@ -297,16 +298,21 @@ func (h *Handler) rescheduleSideEffects(bCopy booking.Booking, capturedEtID stri d.SubjectOverride = subjNote.String } if prefs.NotifyReschedule { - if err := mailer.SendRescheduleToAttendee(ctx, h.mailer, d); err != nil { + if err := mailer.SendRescheduleToAttendee(ctx, h.getMailer(), d); err != nil { h.logger.Error("reschedule email (attendee)", "error", err, "booking_id", bCopy.ID) } } if prefs.NotifyHostReschedule { - if err := mailer.SendRescheduleToHost(ctx, h.mailer, d); err != nil { + if err := mailer.SendRescheduleToHost(ctx, h.getMailer(), d); err != nil { h.logger.Error("reschedule email (host)", "error", err, "booking_id", bCopy.ID) } } + // ⚠️ Only a real time change is counted. A host reassignment (reassign.go) also fires + // the booking.rescheduled webhook, because a subscriber does need to hear about it, but + // it does not move the meeting — counting it here would make "reschedules" answer a + // question nobody asked. + metrics.BookingEvent(metrics.BookingRescheduled) if h.webhookSvc != nil { if err := h.webhookSvc.Enqueue(ctx, "booking.rescheduled", webhook.BookingPayload{ ID: bCopy.ID, diff --git a/internal/handler/manage_test.go b/internal/handler/manage_test.go index 505ec4b..d71335a 100644 --- a/internal/handler/manage_test.go +++ b/internal/handler/manage_test.go @@ -2,7 +2,6 @@ package handler_test import ( "context" - "database/sql" "encoding/json" "fmt" "log/slog" @@ -13,27 +12,22 @@ import ( "github.com/calnode/calnode/internal/booking" "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/handler" "github.com/calnode/calnode/internal/uid" ) // newTestHandlerDB creates a test handler and returns both the handler and the // underlying DB so tests can interact with the DB directly (e.g. to issue tokens). -func newTestHandlerDB(t *testing.T) (*handler.Handler, *sql.DB) { +func newTestHandlerDB(t *testing.T) (*handler.Handler, *db.DB) { t.Helper() - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) t.Cleanup(func() { database.Close() }) return handler.New(database, slog.Default()), database } // setupWorkspaceWithDB bootstraps a workspace and returns (handler, db, apiKey, userID). -func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, string) { +func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *db.DB, string, string) { t.Helper() h, database := newTestHandlerDB(t) @@ -59,7 +53,7 @@ func setupWorkspaceWithDB(t *testing.T) (*handler.Handler, *sql.DB, string, stri // seedFullAvailabilityDB is seedFullAvailability's DB-direct sibling, for secondary // hosts (e.g. a round-robin rotation member) inserted straight into the DB rather than // through /v1/setup — they have no API key of their own to call the HTTP endpoint with. -func seedFullAvailabilityDB(t *testing.T, database *sql.DB, userID string) { +func seedFullAvailabilityDB(t *testing.T, database *db.DB, userID string) { t.Helper() for day := 0; day < 7; day++ { if _, err := database.Exec( @@ -95,7 +89,7 @@ func createBookingViaHTTP(t *testing.T, h *handler.Handler, slug, startAt string } // issueTestToken issues a manage token for bookingID via the booking service. -func issueTestToken(t *testing.T, database *sql.DB, bookingID string) string { +func issueTestToken(t *testing.T, database *db.DB, bookingID string) string { t.Helper() svc := booking.New(database) tok, err := svc.IssueManageToken(context.Background(), bookingID) diff --git a/internal/handler/mcp.go b/internal/handler/mcp.go index 63d886d..c10b561 100644 --- a/internal/handler/mcp.go +++ b/internal/handler/mcp.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "strings" "time" @@ -20,6 +21,64 @@ import ( // transports: stdio (the `calnode mcp` subcommand, for local agents) and Streamable // HTTP (mounted at POST /mcp behind API-key auth, for remote agents). Tools call the // same internal services the REST handlers use — no parallel code path. +// MCPServerForRequest returns the MCP server for the workspace the request's +// bearer credential belongs to. +// +// ⛔ The tools close over their handler, so a single cached server would run every +// workspace's tool calls on whichever handler built it. /mcp is mounted on the +// identity host and carries no tenant Host, so the credential is the only source — +// MCPCallerMiddleware has already resolved it into the request context by the time +// this runs. +// +// One server per workspace, cached: building one allocates eight tool +// registrations and their JSON schemas, which is not something to do per request. +// In single-tenant mode there is exactly one entry, keyed "", so this is the old +// "one instance reused across requests" with a map in front of it. +func (h *Handler) MCPServerForRequest(r *http.Request) *mcp.Server { + wsID := "" + if h.multiTenant { + wsID = mcpCallerWorkspace(r.Context()) + } + + h.mcpMu.RLock() + cached := h.mcpServers[wsID] + h.mcpMu.RUnlock() + if cached != nil { + return cached + } + + scoped := h + if wsID != "" { + // A workspace the credential named; resolution already happened, so a + // failure here means the row went away between the two reads. + ws, err := h.workspaceByID(r.Context(), wsID) + if err != nil { + h.logger.ErrorContext(r.Context(), "mcp: resolve caller workspace", "error", err) + // Bound to a workspace id that does not exist: under RLS this handle + // matches no row, which is the safe answer. + scoped = h.forWorkspace(&Workspace{ID: wsID, Status: "active"}) + } else { + scoped = h.forWorkspace(ws) + } + } + + built := scoped.MCPServer() + + h.mcpMu.Lock() + if h.mcpServers == nil { + h.mcpServers = map[string]*mcp.Server{} + } + // Another request may have built it first; either is correct, keep one. + if existing := h.mcpServers[wsID]; existing != nil { + built = existing + } else { + h.mcpServers[wsID] = built + } + h.mcpMu.Unlock() + + return built +} + func (h *Handler) MCPServer() *mcp.Server { s := mcp.NewServer(&mcp.Implementation{ Name: "calnode", diff --git a/internal/handler/mcp_oauth.go b/internal/handler/mcp_oauth.go index 330b9f1..7a2629e 100644 --- a/internal/handler/mcp_oauth.go +++ b/internal/handler/mcp_oauth.go @@ -37,6 +37,12 @@ const ( type mcpCaller struct { UserID string IsAdmin bool // owner or admin → full workspace access + + // WorkspaceID is the tenant the bearer credential belongs to. The /mcp mount + // is on the identity host, so there is no Host to resolve from: the credential + // is the only source (D10). MCPServerForRequest reads it to hand the tools a + // handler bound to that workspace. + WorkspaceID string } type mcpCallerKey struct{} @@ -50,6 +56,15 @@ func mcpCallerFromContext(ctx context.Context) (mcpCaller, bool) { return c, ok } +// mcpCallerWorkspace returns the workspace of the bound /mcp caller, or "" when +// there is none (the stdio transport, run by the local operator). +func mcpCallerWorkspace(ctx context.Context) string { + if c, ok := mcpCallerFromContext(ctx); ok { + return c.WorkspaceID + } + return "" +} + // mcpCallerScope returns the calling user's id and whether they have full-workspace // access. No bound caller (the stdio transport, run by the local operator) → full // access; an admin/owner over HTTP → full access; a member → scoped to their own @@ -83,9 +98,15 @@ func (h *Handler) MCPCallerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if ti := auth.TokenInfoFromContext(r.Context()); ti != nil && ti.UserID != "" { var owner, admin bool - _ = h.db.QueryRowContext(r.Context(), - `SELECT is_owner, is_admin FROM users WHERE id = ?`, ti.UserID).Scan(&owner, &admin) - r = r.WithContext(withMCPCaller(r.Context(), mcpCaller{UserID: ti.UserID, IsAdmin: owner || admin})) + var workspaceID string + // platformDB: this read resolves the workspace, so it cannot be bound + // to one. See RequireAuth for the same reasoning. + _ = h.platformDB().QueryRowContext(r.Context(), + `SELECT is_owner, is_admin, workspace_id FROM users WHERE id = ?`, ti.UserID). + Scan(&owner, &admin, &workspaceID) + r = r.WithContext(withMCPCaller(r.Context(), mcpCaller{ + UserID: ti.UserID, IsAdmin: owner || admin, WorkspaceID: workspaceID, + })) } next.ServeHTTP(w, r) }) @@ -99,9 +120,16 @@ func (h *Handler) MCPCallerMiddleware(next http.Handler) http.Handler { func (h *Handler) VerifyMCPBearer(ctx context.Context, token string, _ *http.Request) (*auth.TokenInfo, error) { hash := hashAPIKey(token) + // ⛔ platformDB, not h.db, for every read below. oauth_access_tokens.token_hash + // and api_keys.key_hash are globally unique precisely so a bearer token + // resolves before a tenant is known — which is what these reads DO. On the + // application handle they run bound to the workspace of the request, and /mcp + // is on the identity host so there is no workspace bound at all: every valid + // token would be reported Unauthorized. + // OAuth access token? var userID, expiresAt string - err := h.db.QueryRowContext(ctx, + err := h.platformDB().QueryRowContext(ctx, `SELECT user_id, expires_at FROM oauth_access_tokens WHERE token_hash = ?`, hash). Scan(&userID, &expiresAt) if err == nil { @@ -110,17 +138,17 @@ func (h *Handler) VerifyMCPBearer(ctx context.Context, token string, _ *http.Req return nil, auth.ErrInvalidToken // expired — client should refresh } now := time.Now().UTC().Format(time.RFC3339Nano) - _, _ = h.db.ExecContext(ctx, `UPDATE oauth_access_tokens SET last_used_at = ? WHERE token_hash = ?`, now, hash) + _, _ = h.platformDB().ExecContext(ctx, `UPDATE oauth_access_tokens SET last_used_at = ? WHERE token_hash = ?`, now, hash) return &auth.TokenInfo{UserID: userID, Expiration: exp}, nil } // API key fallback (programmatic callers). var keyUser string - if err := h.db.QueryRowContext(ctx, ` + if err := h.platformDB().QueryRowContext(ctx, ` SELECT u.id FROM api_keys ak JOIN users u ON u.id = ak.user_id WHERE ak.key_hash = ? AND u.archived_at IS NULL`, hash).Scan(&keyUser); err == nil { now := time.Now().UTC().Format(time.RFC3339Nano) - _, _ = h.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE key_hash = ?`, now, hash) + _, _ = h.platformDB().ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE key_hash = ?`, now, hash) // API keys don't expire; report a far-future expiry so the SDK doesn't reject it. return &auth.TokenInfo{UserID: keyUser, Expiration: time.Now().Add(mcpAccessTokenTTL)}, nil } diff --git a/internal/handler/mcp_oauth_authorize.go b/internal/handler/mcp_oauth_authorize.go index 8ab139f..0a94fc9 100644 --- a/internal/handler/mcp_oauth_authorize.go +++ b/internal/handler/mcp_oauth_authorize.go @@ -159,10 +159,19 @@ func (h *Handler) AuthorizeMCPDecision(w http.ResponseWriter, r *http.Request) { code := "mcac_" + randHex(32) expires := time.Now().UTC().Add(mcpAuthCodeTTL).Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339Nano) + // ⛔ workspace_id is NAMED because this route is Platform-wrapped: the platform + // handle binds '', and the column default would put the row in the DEFAULT + // workspace rather than the consenting user's. + workspaceID, wsErr := h.workspaceOfUser(r.Context(), userID) + if wsErr != nil { + h.logger.ErrorContext(r.Context(), "oauth: resolve consenting user's workspace", "error", wsErr) + h.redirectAuthError(w, r, ar, "server_error", "could not issue code") + return + } if _, err := h.db.ExecContext(r.Context(), ` - INSERT INTO oauth_auth_codes (code_hash, client_id, user_id, redirect_uri, code_challenge, scope, resource, expires_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - hashAPIKey(code), ar.ClientID, userID, ar.RedirectURI, ar.CodeChallenge, ar.Scope, ar.Resource, expires, now); err != nil { + INSERT INTO oauth_auth_codes (workspace_id, code_hash, client_id, user_id, redirect_uri, code_challenge, scope, resource, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, hashAPIKey(code), ar.ClientID, userID, ar.RedirectURI, ar.CodeChallenge, ar.Scope, ar.Resource, expires, now); err != nil { h.logger.ErrorContext(r.Context(), "oauth: store auth code", "error", err) h.redirectAuthError(w, r, ar, "server_error", "could not issue code") return @@ -275,10 +284,21 @@ func (h *Handler) issueAndWriteTokens(w http.ResponseWriter, r *http.Request, cl UPDATE oauth_access_tokens SET token_hash = ?, refresh_hash = ?, expires_at = ?, last_used_at = NULL WHERE id = ?`, hashAPIKey(access), hashAPIKey(refresh), exp.Format(time.RFC3339), replaceID) } else { + // ⛔ workspace_id NAMED, for the same reason as the auth code above: this + // runs on a Platform-wrapped route, so an omitted column lands the grant in + // the default workspace — where MCP would still verify it (the platform + // handle bypasses) but the owning workspace's Connected-apps page could + // neither list nor revoke it, and deleting the workspace would not remove it. + workspaceID, wsErr := h.workspaceOfUser(r.Context(), userID) + if wsErr != nil { + h.logger.ErrorContext(r.Context(), "oauth: resolve token owner's workspace", "error", wsErr) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not issue token") + return + } _, err = h.db.ExecContext(r.Context(), ` - INSERT INTO oauth_access_tokens (id, token_hash, refresh_hash, client_id, user_id, scope, resource, expires_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - uid.New(), hashAPIKey(access), hashAPIKey(refresh), clientID, userID, scope, resource, exp.Format(time.RFC3339), now.Format(time.RFC3339Nano)) + INSERT INTO oauth_access_tokens (workspace_id, id, token_hash, refresh_hash, client_id, user_id, scope, resource, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspaceID, uid.New(), hashAPIKey(access), hashAPIKey(refresh), clientID, userID, scope, resource, exp.Format(time.RFC3339), now.Format(time.RFC3339Nano)) } if err != nil { h.logger.ErrorContext(r.Context(), "oauth: issue token", "error", err) diff --git a/internal/handler/mcp_scope_test.go b/internal/handler/mcp_scope_test.go index 5d1aa2b..568aed6 100644 --- a/internal/handler/mcp_scope_test.go +++ b/internal/handler/mcp_scope_test.go @@ -11,20 +11,13 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // verifies MCP tools scope by role: a member sees/controls only the bookings they host, // while an admin/owner (and the unauthenticated stdio operator) see the whole workspace. func TestMCP_roleScoping(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db open: %v", err) - } - t.Cleanup(func() { database.Close() }) - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) // Owner (first user → owner+admin). diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go new file mode 100644 index 0000000..30edb4a --- /dev/null +++ b/internal/handler/metrics.go @@ -0,0 +1,99 @@ +package handler + +import ( + "crypto/sha256" + "crypto/subtle" + "net/http" + "strings" + + "github.com/calnode/calnode/internal/metrics" +) + +// SetMetricsToken configures the bearer token that authorises GET /metrics. Empty leaves +// the endpoint off. Set once at boot from config, like SetSSOSecret. +func (h *Handler) SetMetricsToken(token string) { + h.metricsToken = token +} + +// Metrics handles GET /metrics — Prometheus text exposition of the counters in +// internal/metrics plus the job-queue depth read from the database. +// +// ⛔ Gated on `Authorization: Bearer `, and it answers **404** — byte-identical +// to the mux's own not-found — when the token is unset or wrong. Not 401: a 401 confirms the +// endpoint exists and invites a guess, and the numbers here are a business feed (bookings +// created per hour, request volume by surface) on an instance whose whole point is being +// publicly reachable. An operator who has not configured a token has not opted in to +// publishing any of that, so there is nothing to advertise. +// +// The response is not rate-limited: a scrape runs every few seconds by design, and a +// limiter tuned for humans would drop samples and produce gaps that look like downtime. +// The token is the control. +func (h *Handler) Metrics(w http.ResponseWriter, r *http.Request) { + if !h.metricsAuthorized(r) { + http.NotFound(w, r) + return + } + + // Job depth lives in the database because any instance can claim any job, so it is + // read per scrape rather than counted in this process. One grouped query; a failure is + // logged and reported as zero rather than failing the whole scrape, since the process + // counters are still worth having when the database is the thing that is unwell (and + // /readyz is the endpoint that answers "is the database reachable"). + var q metrics.Queue + // ⛔ Platform(), not h.db. jobs is a tenant table (00060): the queue depth of an + // INSTANCE is an instance-level number, so a bound read would report one + // workspace's backlog as if it were the whole queue and the unbound handle would + // report zero. Both are wrong in a way a dashboard cannot show you. /metrics is + // registered through h.Platform, so h.db is already the platform handle — this is + // belt and braces against someone re-registering the route Scoped. + rows, err := h.db.Platform().QueryContext(r.Context(), + `SELECT status, COUNT(*) FROM jobs WHERE status IN ('pending', 'failed') GROUP BY status`) + if err != nil { + h.logger.ErrorContext(r.Context(), "metrics: count jobs", "error", err) + } else { + for rows.Next() { + var status string + var n int64 + if err := rows.Scan(&status, &n); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: scan job count", "error", err) + continue + } + switch status { + case "pending": + q.Pending = n + case "failed": + q.Failed = n + } + } + if err := rows.Err(); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: job count rows", "error", err) + } + rows.Close() // #nosec G104 -- rows already fully consumed; nothing actionable on close error + } + + w.Header().Set("Content-Type", metrics.ContentType) + // Cache-Control matters here: a scrape must never be answered from an intermediary, + // or a dashboard shows a frozen instance as a healthy one. + w.Header().Set("Cache-Control", "no-store") + if err := metrics.Write(w, q); err != nil { + h.logger.ErrorContext(r.Context(), "metrics: write exposition", "error", err) + } +} + +// metricsAuthorized reports whether the request carries the configured bearer token. +// +// The comparison is over SHA-256 digests rather than the raw strings: subtle.ConstantTimeCompare +// returns early when the lengths differ, so comparing the values directly would leak the +// token's length. Hashing makes both sides 32 bytes whatever was sent. +func (h *Handler) metricsAuthorized(r *http.Request) bool { + if h.metricsToken == "" { + return false + } + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + presented := sha256.Sum256([]byte(strings.TrimPrefix(auth, "Bearer "))) + expected := sha256.Sum256([]byte(h.metricsToken)) + return subtle.ConstantTimeCompare(presented[:], expected[:]) == 1 +} diff --git a/internal/handler/metrics_test.go b/internal/handler/metrics_test.go new file mode 100644 index 0000000..1030245 --- /dev/null +++ b/internal/handler/metrics_test.go @@ -0,0 +1,107 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/metrics" +) + +const metricsToken = "metrics-token-for-tests" + +func doMetrics(h *handler.Handler, auth string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + if auth != "" { + req.Header.Set("Authorization", auth) + } + rec := httptest.NewRecorder() + h.Metrics(rec, req) + return rec +} + +// Unset METRICS_TOKEN ⇒ 404, byte-identical to the mux's own not-found. A 401 would +// confirm the endpoint exists on an instance whose operator never opted in to publishing +// its booking rate. +func TestMetrics_404WithoutAToken(t *testing.T) { + h, _ := newTestHandlerDB(t) + + rec := doMetrics(h, "Bearer "+metricsToken) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "calnode_") { + t.Error("body leaked metrics despite the endpoint being off") + } +} + +func TestMetrics_rejectsWrongOrMissingBearer(t *testing.T) { + cases := map[string]string{ + "no header": "", + "empty bearer": "Bearer ", + "wrong token": "Bearer nope", + "right token, no scheme": metricsToken, + "basic auth": "Basic " + metricsToken, + // A prefix of the real token must not pass: the comparison is over digests, so + // length tells an attacker nothing either. + "token prefix": "Bearer " + metricsToken[:10], + } + for name, auth := range cases { + t.Run(name, func(t *testing.T) { + h, _ := newTestHandlerDB(t) + h.SetMetricsToken(metricsToken) + + rec := doMetrics(h, auth) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d; want 404 for %q", rec.Code, auth) + } + }) + } +} + +func TestMetrics_servesExpositionWithTheToken(t *testing.T) { + metrics.Reset() + h, database := newTestHandlerDB(t) + h.SetMetricsToken(metricsToken) + + // Two pending jobs and one failed one, so the gauges are read from the table rather + // than reported as zero. The payload differs per row: jobs carries UNIQUE(type, + // payload), which is what makes an enqueue idempotent. + for _, row := range []struct{ id, status string }{ + {"job-1", "pending"}, {"job-2", "pending"}, {"job-3", "failed"}, {"job-4", "done"}, + } { + if _, err := database.Exec( + `INSERT INTO jobs (id, type, payload, run_at, status) VALUES (?, 'webhook.deliver', ?, '2026-01-01T00:00:00Z', ?)`, + row.id, `{"webhook_delivery_id":"`+row.id+`"}`, row.status); err != nil { + t.Fatalf("seed job %s: %v", row.id, err) + } + } + + rec := doMetrics(h, "Bearer "+metricsToken) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != metrics.ContentType { + t.Errorf("Content-Type = %q; want %q", ct, metrics.ContentType) + } + // A cached scrape shows a frozen instance as a healthy one. + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q; want no-store", cc) + } + + body := rec.Body.String() + for _, want := range []string{ + "calnode_jobs_pending 2", + "calnode_jobs_failed_total 1", + "# TYPE calnode_build_info gauge", + } { + if !strings.Contains(body, want+"\n") { + t.Errorf("body missing %q:\n%s", want, body) + } + } +} diff --git a/internal/handler/notetaker.go b/internal/handler/notetaker.go index 2ef1d90..02cf99f 100644 --- a/internal/handler/notetaker.go +++ b/internal/handler/notetaker.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/llm" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stt" @@ -59,13 +60,18 @@ func (h *Handler) deepgramKey(ctx context.Context) string { // reminders/webhooks enqueue via their own paths.) func (h *Handler) enqueueJob(ctx context.Context, typ string, payload any) error { b, _ := json.Marshal(payload) + // run_at keeps the datetime('now') shape it has always had here. It is + // deliberately not the RFC 3339 the reminder path writes: the worker's + // "run_at <= ?" compares text, and the space-separated form sorts before any + // T-separated one, which is what makes these jobs due immediately. + now := dbtime.Now() _, err := h.db.ExecContext(ctx, ` INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) - VALUES (?, ?, ?, datetime('now'), 'pending', 0, 3) + VALUES (?, ?, ?, ?, 'pending', 0, 3) ON CONFLICT(type, payload) DO UPDATE SET - status = 'pending', run_at = datetime('now'), attempts = 0, + status = 'pending', run_at = ?, attempts = 0, last_error = NULL, locked_until = NULL`, - uid.New(), typ, string(b)) + uid.New(), typ, string(b), now, now) return err } @@ -96,7 +102,13 @@ func (h *Handler) maybeStartNotetaker(ctx context.Context, recordingID string) { // JobNotetakerTranscribe (worker job) transcribes a finished recording via Deepgram, stores the // transcript, and enqueues summarisation. Returns an error to retry on transient failures. -func (h *Handler) JobNotetakerTranscribe(ctx context.Context, payload string) error { +func (h *Handler) JobNotetakerTranscribe(ctx context.Context, workspaceID, payload string) error { + // ⛔ Scoped first, before anything reads a row. The worker claims on the + // platform handle, so the receiver it calls this on is unbound; every read and + // write below has to be this job's workspace. In single-tenant mode + // workspaceForJob is the identity function. + h = h.workspaceForJob(workspaceID) + var p struct { RecordingID string `json:"recording_id"` } @@ -123,7 +135,7 @@ func (h *Handler) JobNotetakerTranscribe(ctx context.Context, payload string) er return nil } url := presignS3Get(s3, objectKey, time.Hour, timeNow()) - res, err := stt.NewDeepgram(key).TranscribeURL(ctx, url) + res, err := stt.NewDeepgram(key, h.sttBaseURL()).TranscribeURL(ctx, url) if err != nil { return err // transient — retry } @@ -150,7 +162,13 @@ func (h *Handler) JobNotetakerTranscribe(ctx context.Context, payload string) er // JobNotetakerSummarize (worker job) summarises a booking's transcript(s) into notes. Thin wrapper // over summarizeBooking, which is shared with the manual regenerate endpoint. -func (h *Handler) JobNotetakerSummarize(ctx context.Context, payload string) error { +func (h *Handler) JobNotetakerSummarize(ctx context.Context, workspaceID, payload string) error { + // ⛔ Scoped first, before anything reads a row. The worker claims on the + // platform handle, so the receiver it calls this on is unbound; every read and + // write below has to be this job's workspace. In single-tenant mode + // workspaceForJob is the identity function. + h = h.workspaceForJob(workspaceID) + var p struct { BookingID string `json:"booking_id"` } @@ -212,16 +230,16 @@ func (h *Handler) summarizeBooking(ctx context.Context, bookingID string) (strin _, _ = h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, '', 'empty') - ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID) + ON CONFLICT(booking_id) DO UPDATE SET status = 'empty', updated_at = ?`, + uid.New(), bookingID, dbtime.NowMilli()) return "", nil } if _, err := h.db.ExecContext(ctx, ` INSERT INTO notes (id, booking_id, content, status) VALUES (?, ?, ?, 'complete') ON CONFLICT(booking_id) DO UPDATE SET - content = excluded.content, status = 'complete', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, - uid.New(), bookingID, content); err != nil { + content = excluded.content, status = 'complete', updated_at = ?`, + uid.New(), bookingID, content, dbtime.NowMilli()); err != nil { return "", err } h.logger.InfoContext(ctx, "notetaker: notes generated", "booking_id", bookingID, "chars", len(content)) diff --git a/internal/handler/notetaker_http.go b/internal/handler/notetaker_http.go index f5619c7..2576fdd 100644 --- a/internal/handler/notetaker_http.go +++ b/internal/handler/notetaker_http.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" ) @@ -29,6 +30,10 @@ func (h *Handler) GetNotetakerSettings(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, map[string]any{ "enabled": enabled != 0, "stt_api_key_set": keyEnc != "", + // Read-only, and env-only (STT_BASE_URL): it names where recording audio is sent, + // which an admin should be able to read without shelling into the container, and + // should not be able to repoint from a browser session. + "stt_base_url": h.sttBaseURL(), }) } @@ -57,7 +62,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET notetaker_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET notetaker_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update enabled", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -72,7 +77,7 @@ func (h *Handler) PatchNotetakerSettings(w http.ResponseWriter, r *http.Request) return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stt_api_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "notetaker settings: update key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/notetaker_settings_test.go b/internal/handler/notetaker_settings_test.go new file mode 100644 index 0000000..3dabc95 --- /dev/null +++ b/internal/handler/notetaker_settings_test.go @@ -0,0 +1,60 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/stt" +) + +func notetakerSettings(t *testing.T, h *handler.Handler, apiKey string) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + h.RequireAuth(h.GetNotetakerSettings)(rec, authReq(http.MethodGet, "/v1/settings/notetaker", "", apiKey)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode: %v", err) + } + return out +} + +// An admin should be able to read which endpoint recording audio is sent to without +// shelling into the container to look at the environment. +func TestNotetakerSettings_reportsSTTBaseURL(t *testing.T) { + h, _, apiKey, _ := setupWorkspaceWithDB(t) + + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != stt.DefaultBaseURL { + t.Errorf("stt_base_url = %v; want the provider default %q", got, stt.DefaultBaseURL) + } + + h.SetSTTBaseURL("https://api.eu.deepgram.com") + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != "https://api.eu.deepgram.com" { + t.Errorf("stt_base_url = %v; want the configured host", got) + } +} + +// Read-only: PATCH has no field for it, so a value posted under that name is ignored +// rather than stored. The endpoint answers with GetNotetakerSettings, so the response +// still reports the env-configured host. +func TestNotetakerSettings_sttBaseURLIsNotWritable(t *testing.T) { + h, _, apiKey, _ := setupWorkspaceWithDB(t) + h.SetSTTBaseURL("https://api.eu.deepgram.com") + + rec := httptest.NewRecorder() + h.RequireAuth(h.PatchNotetakerSettings)(rec, + authReq(http.MethodPatch, "/v1/settings/notetaker", + `{"enabled":true,"stt_base_url":"https://attacker.example.test"}`, apiKey)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + + if got := notetakerSettings(t, h, apiKey)["stt_base_url"]; got != "https://api.eu.deepgram.com" { + t.Errorf("stt_base_url = %v; a PATCH must not be able to repoint it", got) + } +} diff --git a/internal/handler/override.go b/internal/handler/override.go index aaa2274..d34db3a 100644 --- a/internal/handler/override.go +++ b/internal/handler/override.go @@ -4,9 +4,9 @@ import ( "database/sql" "encoding/json" "net/http" - "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -155,7 +155,7 @@ func (h *Handler) CreateAvailabilityOverride(w http.ResponseWriter, r *http.Requ INSERT INTO availability_overrides (id, user_id, date, is_available, reason, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, user.ID, req.Date, isAvailInt, req.Reason, req.StartTime, req.EndTime); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "an override already exists for this date; delete it first") return } diff --git a/internal/handler/platform.go b/internal/handler/platform.go new file mode 100644 index 0000000..773f452 --- /dev/null +++ b/internal/handler/platform.go @@ -0,0 +1,610 @@ +package handler + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/secret" + "github.com/calnode/calnode/internal/uid" + "github.com/calnode/calnode/internal/webhook" +) + +// The platform API (D12). Workspace provisioning for a multi-tenant instance: create a +// tenant, read it, change its host or status, delete it. +// +// Every route here is Platform-wrapped, so h.db is the platform handle — it bypasses the +// row-level security policies and binds no workspace. Two consequences run through this +// whole file: +// +// - ⛔ EVERY INSERT NAMES workspace_id. The column default is +// COALESCE(current_setting('app.workspace_id', true), 'default'), and the platform +// handle sets that parameter to '' before each statement, so an unnamed column +// resolves to '' and the row fails its foreign key to workspaces(id) with SQLSTATE +// 23503. (An earlier note in PROGRESS.md said such a row lands silently in the +// `default` workspace; that is what happens on a handle that never sets the +// parameter at all, not on the paired platform handle. The rule is the same either +// way, and it is the reason it is stated here rather than assumed.) +// - Reads are equally unscoped, so every one of them carries its own workspace_id +// predicate. There is no policy behind this file to catch a forgotten WHERE. +// +// Authentication is a bearer token from CALNODE_PLATFORM_TOKEN, compared in constant +// time. With the token unset — or on a single-tenant instance, which has no workspaces to +// provision — every route 404s rather than 401ing, so a prober cannot tell a +// multi-tenant control plane from an instance that does not implement one. + +// SetPlatformToken configures the platform API's bearer token. Empty leaves the API off. +// Set once at boot from config, like SetSSOSecret, so there is no lock here. +func (h *Handler) SetPlatformToken(token string) { h.platformToken = token } + +// platformAuthorized gates every route in this file. It writes the response on failure +// and reports whether the caller may proceed. +func (h *Handler) platformAuthorized(w http.ResponseWriter, r *http.Request) bool { + // Off unless configured, and off on a single-tenant instance. 404 for both, because + // "this endpoint does not exist here" is the truth in both cases and neither answer + // should tell a prober which one applies. + if h.platformToken == "" || !h.multiTenant { + http.NotFound(w, r) + return false + } + presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if subtle.ConstantTimeCompare([]byte(presented), []byte(h.platformToken)) != 1 { + h.logger.WarnContext(r.Context(), "platform api: bad token", "path", r.URL.Path) + h.writeError(w, http.StatusUnauthorized, "invalid platform token") + return false + } + return true +} + +// provisionedWebhookEvents is what a provisioned workspace subscribes to: every event this +// codebase emits. Kept as one named list so the set is reviewable and testable rather than +// inline in a marshal call. +var provisionedWebhookEvents = []string{ + "booking.created", + "booking.cancelled", + "booking.rescheduled", + "booking.reminder", + "recording.completed", + "transcript.ready", + "notes.ready", +} + +// platformWorkspaceRequest is the create body (D12), settled against the website client. +type platformWorkspaceRequest struct { + ID string `json:"id"` + Slug string `json:"slug"` + PublicHost string `json:"public_host"` + Region string `json:"region"` + OwnerEmail string `json:"owner_email"` + OwnerName string `json:"owner_name"` + OwnerTimezone string `json:"owner_timezone"` + Defaults struct { + EmbedAllowedOrigins []string `json:"embed_allowed_origins"` + Webhook struct { + URL string `json:"url"` + Secret string `json:"secret"` + Fields []string `json:"fields"` + } `json:"webhook"` + EventType struct { + Slug string `json:"slug"` + Name string `json:"name"` + DurationMinutes int `json:"duration_minutes"` + MinNoticeMinutes int `json:"min_notice_minutes"` + MaxFutureDays int `json:"max_future_days"` + Availability []struct { + DayOfWeek int `json:"day_of_week"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + } `json:"availability"` + } `json:"event_type"` + LiveKitURL string `json:"livekit_url"` + LiveKitAPIKey string `json:"livekit_api_key"` + LiveKitAPISecret string `json:"livekit_api_secret"` + STTBaseURL string `json:"stt_base_url"` + SMTP *struct { + Host string `json:"host"` + Port string `json:"port"` + User string `json:"user"` + Pass string `json:"pass"` + TLS bool `json:"tls"` + StartTLS bool `json:"starttls"` + From string `json:"from"` + FromName string `json:"from_name"` + } `json:"smtp"` + LLM *struct { + Endpoint string `json:"endpoint"` + Model string `json:"model"` + APIKey string `json:"api_key"` + Enabled bool `json:"enabled"` + ExtraInstructions string `json:"extra_instructions"` + } `json:"llm"` + } `json:"defaults"` +} + +// CreateWorkspace handles POST /v1/platform/workspaces. +// +// One transaction provisions the whole tenant: the workspaces row, its server_settings +// row seeded from defaults, the owner user, that owner's first cno_ key, the webhook +// subscription, and the default event type with its availability rules. Either a +// workspace exists complete or it does not exist — a half-provisioned tenant would answer +// requests with no owner, or hand out a booking page with no availability. +// +// The api_key and webhook_secret in the 201 are the only time either is legible. +func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var req platformWorkspaceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + if msg := validatePlatformWorkspace(&req); msg != "" { + h.writeError(w, http.StatusBadRequest, msg) + return + } + + plainKey := "cno_" + hex.EncodeToString(mustRandom(32)) + ownerID := uid.New() + etID := uid.New() + now := time.Now().UTC().Format(time.RFC3339Nano) + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: begin tx", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + defer tx.Rollback() //nolint:errcheck + + // The workspaces row first: every other INSERT below names it as a foreign key, so + // this is also where a duplicate id or public_host is caught. Both uniques are real + // constraints rather than a pre-read, so two concurrent provisions of the same tenant + // cannot both win. + if _, err := tx.ExecContext(r.Context(), ` + INSERT INTO workspaces (id, slug, public_host, region, status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'active', ?, ?)`, + req.ID, req.Slug, req.PublicHost, req.Region, now, now); err != nil { + if db.IsUniqueViolation(err) { + h.writeError(w, http.StatusConflict, "workspace id, slug or public_host already exists") + return + } + h.logger.ErrorContext(r.Context(), "platform: insert workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := h.seedWorkspaceSettings(r.Context(), tx, &req, now); err != nil { + h.logger.ErrorContext(r.Context(), "platform: seed settings", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // The owner. iana_timezone is the REQUESTED timezone, not UTC: it is what the admin + // UI renders every time in and what the default event type's availability below is + // expressed in, so defaulting it would silently move the workspace's working hours. + if _, err := tx.ExecContext(r.Context(), ` + INSERT INTO users (id, workspace_id, email, name, iana_timezone, is_admin, is_owner, email_login) + VALUES (?, ?, ?, ?, ?, 1, 1, 0)`, + ownerID, req.ID, strings.ToLower(req.OwnerEmail), req.OwnerName, req.OwnerTimezone); err != nil { + h.logger.ErrorContext(r.Context(), "platform: insert owner", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if _, err := tx.ExecContext(r.Context(), ` + INSERT INTO api_keys (id, workspace_id, user_id, name, key_hash, created_at) + VALUES (?, ?, ?, 'platform-provisioned', ?, ?)`, + uid.New(), req.ID, ownerID, hashAPIKey(plainKey), now); err != nil { + h.logger.ErrorContext(r.Context(), "platform: insert api key", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := h.seedWorkspaceEventType(r.Context(), tx, &req, etID, ownerID); err != nil { + h.logger.ErrorContext(r.Context(), "platform: seed event type", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // The webhook rides the same transaction, and names workspace_id like everything + // else here. The secret's encoding comes from webhook.NewSecret so the convention + // (raw bytes encrypted, hex handed out) has exactly one implementation. + webhookSecret := "" + if url := req.Defaults.Webhook.URL; url != "" { + plainSecret, encSecret, err := h.webhookSvc.NewSecret(req.Defaults.Webhook.Secret) + if err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + // ⛔ All SEVEN events this codebase emits, not just the booking four. The receiver + // on the other side of a provisioned tenancy handles all of them, and a + // subscription written with four means the three media events are silently never + // delivered — a gap that shows up as "recordings never appear" long after + // provisioning, with nothing in either system to point at. + // + // The three media events fire only when recording or the notetaker is switched on, + // so a tenancy without them simply never receives them: subscribing is free, and + // not subscribing is a decision the operator cannot see or undo without an API + // call nobody knows to make. Enumerated from the tree, not from memory - every + // Enqueue call site in internal/ emits one of these and nothing else. + events, _ := json.Marshal(provisionedWebhookEvents) + // A NULL fields column means "the default payload set" (migration 00027), which + // is not the same as an empty selection — so an empty list stays NULL rather than + // becoming [], and the webhook keeps the original booking-metadata shape. + var fieldsJSON any + if len(req.Defaults.Webhook.Fields) > 0 { + fb, _ := json.Marshal(webhook.ValidFields(req.Defaults.Webhook.Fields)) + fieldsJSON = string(fb) + } + if _, err := tx.ExecContext(r.Context(), ` + INSERT INTO webhooks (id, workspace_id, user_id, url, events, fields, secret_enc) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + uid.New(), req.ID, ownerID, url, string(events), fieldsJSON, encSecret); err != nil { + h.logger.ErrorContext(r.Context(), "platform: insert webhook", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + webhookSecret = plainSecret + } + + if err := tx.Commit(); err != nil { + h.logger.ErrorContext(r.Context(), "platform: commit", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + h.logger.InfoContext(r.Context(), "platform: workspace provisioned", + "workspace_id", req.ID, "public_host", req.PublicHost, "owner", req.OwnerEmail) + h.writeJSON(w, http.StatusCreated, map[string]any{ + "api_key": plainKey, + "webhook_secret": webhookSecret, + "note": "save the api_key and webhook_secret — neither is shown again", + }) +} + +// seedWorkspaceSettings writes the workspace's single server_settings row. id = 1 is kept +// per workspace (D8), so the ~40 `WHERE id = 1` reads elsewhere need no edit. +func (h *Handler) seedWorkspaceSettings(ctx context.Context, tx *db.Tx, req *platformWorkspaceRequest, now string) error { + var smtpPassEnc, llmKeyEnc, livekitSecretEnc string + encrypt := func(plain string) (string, error) { + if plain == "" { + return "", nil + } + return secret.Encrypt(h.encKey, plain) + } + var err error + if req.Defaults.SMTP != nil { + if smtpPassEnc, err = encrypt(req.Defaults.SMTP.Pass); err != nil { + return fmt.Errorf("encrypt smtp password: %w", err) + } + } + if req.Defaults.LLM != nil { + if llmKeyEnc, err = encrypt(req.Defaults.LLM.APIKey); err != nil { + return fmt.Errorf("encrypt llm api key: %w", err) + } + } + if livekitSecretEnc, err = encrypt(req.Defaults.LiveKitAPISecret); err != nil { + return fmt.Errorf("encrypt livekit secret: %w", err) + } + + smtpHost, smtpPort, smtpUser, emailFrom, emailFromName := "", "", "", "", "" + smtpTLS, smtpStartTLS := 0, 0 + if s := req.Defaults.SMTP; s != nil { + smtpHost, smtpPort, smtpUser = s.Host, s.Port, s.User + emailFrom, emailFromName = s.From, s.FromName + if s.TLS { + smtpTLS = 1 + } + if s.StartTLS { + smtpStartTLS = 1 + } + } + llmEndpoint, llmModel, llmExtra := "", "", "" + llmEnabled := 0 + if l := req.Defaults.LLM; l != nil { + llmEndpoint, llmModel, llmExtra = l.Endpoint, l.Model, l.ExtraInstructions + if l.Enabled { + llmEnabled = 1 + } + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO server_settings + (workspace_id, id, smtp_host, smtp_port, smtp_user, smtp_pass_enc, smtp_tls, smtp_starttls, + email_from, email_from_name, updated_at, + llm_endpoint, llm_model, llm_api_key_enc, llm_enabled, llm_extra_instructions, + livekit_url, livekit_api_key, livekit_api_secret_enc, + embed_allowed_origins, stt_base_url) + VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + req.ID, smtpHost, smtpPort, smtpUser, smtpPassEnc, smtpTLS, smtpStartTLS, + emailFrom, emailFromName, now, + llmEndpoint, llmModel, llmKeyEnc, llmEnabled, llmExtra, + req.Defaults.LiveKitURL, req.Defaults.LiveKitAPIKey, livekitSecretEnc, + strings.Join(req.Defaults.EmbedAllowedOrigins, ","), req.Defaults.STTBaseURL) + return err +} + +// seedWorkspaceEventType writes the default event type and its availability rules. +// +// location_type 'link' and routing_mode 'fixed' are the schema's own defaults (the CHECK +// constraints in migration 00001 admit no 'none' or 'single'), which is what a +// single-host event type created through the admin UI gets. The platform API does not +// take either: a provisioning caller sets up a tenant, and how one event type meets is +// something its owner changes in the UI afterwards. +// +// The rules carry no timezone of their own: availability is stored as local HH:MM and +// interpreted in the OWNER's iana_timezone, which is why owner_timezone is required +// rather than defaulted (a 09:00 rule means nothing until you know whose 09:00). +func (h *Handler) seedWorkspaceEventType(ctx context.Context, tx *db.Tx, req *platformWorkspaceRequest, etID, ownerID string) error { + et := req.Defaults.EventType + if et.Slug == "" { + return nil + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO event_types + (id, workspace_id, user_id, slug, name, duration_minutes, + min_notice_minutes, max_future_days, location_type, routing_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'link', 'fixed')`, + etID, req.ID, ownerID, et.Slug, et.Name, et.DurationMinutes, + et.MinNoticeMinutes, et.MaxFutureDays); err != nil { + return fmt.Errorf("insert event type: %w", err) + } + for _, a := range et.Availability { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO availability_rules + (id, workspace_id, user_id, event_type_id, day_of_week, start_time, end_time) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + uid.New(), req.ID, ownerID, etID, a.DayOfWeek, a.StartTime, a.EndTime); err != nil { + return fmt.Errorf("insert availability rule: %w", err) + } + } + return nil +} + +// GetWorkspace handles GET /v1/platform/workspaces/{id}. +func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + ws, err := h.readPlatformWorkspace(r.Context(), r.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: read workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + h.writeJSON(w, http.StatusOK, ws) +} + +// PatchWorkspace handles PATCH /v1/platform/workspaces/{id} — public_host, status, slug. +// +// Nothing else is patchable: the id is referenced by every tenant row, and the region is +// where the data physically is, so neither is a field an operator can change by writing +// to it. +func (h *Handler) PatchWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + id := r.PathValue("id") + + r.Body = http.MaxBytesReader(w, r.Body, 4<<10) + var req struct { + PublicHost *string `json:"public_host"` + Status *string `json:"status"` + Slug *string `json:"slug"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + set := []string{"updated_at = ?"} + args := []any{time.Now().UTC().Format(time.RFC3339Nano)} + if req.PublicHost != nil { + host := hostOnly(*req.PublicHost) + if host == "" { + h.writeError(w, http.StatusBadRequest, "public_host cannot be empty") + return + } + set = append(set, "public_host = ?") + args = append(args, host) + } + if req.Status != nil { + if *req.Status != "active" && *req.Status != "suspended" { + h.writeError(w, http.StatusBadRequest, "status must be active or suspended") + return + } + set = append(set, "status = ?") + args = append(args, *req.Status) + } + if req.Slug != nil { + if strings.TrimSpace(*req.Slug) == "" { + h.writeError(w, http.StatusBadRequest, "slug cannot be empty") + return + } + set = append(set, "slug = ?") + args = append(args, *req.Slug) + } + if len(set) == 1 { + h.writeError(w, http.StatusBadRequest, "nothing to update: send public_host, status or slug") + return + } + args = append(args, id) + + res, err := h.db.ExecContext(r.Context(), + `UPDATE workspaces SET `+strings.Join(set, ", ")+` WHERE id = ?`, args...) // #nosec G202 -- set holds only hardcoded "col = ?" literals; every value is bound + if err != nil { + if db.IsUniqueViolation(err) { + h.writeError(w, http.StatusConflict, "slug or public_host already exists") + return + } + h.logger.ErrorContext(r.Context(), "platform: patch workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if n, _ := res.RowsAffected(); n == 0 { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + + ws, err := h.readPlatformWorkspace(r.Context(), id) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: read back workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + h.logger.InfoContext(r.Context(), "platform: workspace patched", "workspace_id", id) + h.writeJSON(w, http.StatusOK, ws) +} + +// DeleteWorkspace handles DELETE /v1/platform/workspaces/{id}. +// +// The row goes and Postgres cascades every tenant table with it (migration 00060's +// REFERENCES workspaces(id) ON DELETE CASCADE). Recordings are the exception that cannot +// cascade: the rows go, but the objects live in S3, so their keys are returned and +// deleting them is the caller's job. Returning them AFTER the delete would mean reading a +// table that no longer has the rows, so they are collected first. +func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + id := r.PathValue("id") + + keys := []string{} + rows, err := h.db.QueryContext(r.Context(), + `SELECT object_key FROM recordings WHERE workspace_id = ? AND object_key <> '' ORDER BY object_key`, id) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: list recording keys", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + for rows.Next() { + var key string + if err := rows.Scan(&key); err != nil { + rows.Close() + h.logger.ErrorContext(r.Context(), "platform: scan recording key", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + keys = append(keys, key) + } + if err := rows.Err(); err != nil { + rows.Close() + h.logger.ErrorContext(r.Context(), "platform: iterate recording keys", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + rows.Close() + + res, err := h.db.ExecContext(r.Context(), `DELETE FROM workspaces WHERE id = ?`, id) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: delete workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if n, _ := res.RowsAffected(); n == 0 { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + + h.logger.InfoContext(r.Context(), "platform: workspace deleted", + "workspace_id", id, "recording_objects", len(keys)) + h.writeJSON(w, http.StatusOK, map[string]any{"recording_object_keys": keys}) +} + +// readPlatformWorkspace reads one workspace row for the API's responses. +func (h *Handler) readPlatformWorkspace(ctx context.Context, id string) (map[string]any, error) { + var wsID, slug, publicHost, region, status, createdAt, updatedAt string + if err := h.db.QueryRowContext(ctx, ` + SELECT id, slug, public_host, region, status, created_at, updated_at + FROM workspaces WHERE id = ?`, id). + Scan(&wsID, &slug, &publicHost, ®ion, &status, &createdAt, &updatedAt); err != nil { + return nil, err + } + return map[string]any{ + "id": wsID, "slug": slug, "public_host": publicHost, "region": region, + "status": status, "created_at": createdAt, "updated_at": updatedAt, + }, nil +} + +// validatePlatformWorkspace returns a client-facing message for the first thing wrong +// with a create body, or "" when it is usable. +// +// It is deliberately strict about the id: db.ForWorkspace validates the same shape before +// binding, so an id this API accepted but that could not be bound would produce a +// workspace whose every request failed with ErrInvalidWorkspace. +func validatePlatformWorkspace(req *platformWorkspaceRequest) string { + if !db.ValidWorkspaceID(req.ID) { + return "id must match ^[a-z0-9_-]{1,64}$" + } + if req.ID == db.DefaultWorkspaceID { + return "id " + db.DefaultWorkspaceID + " is reserved" + } + if strings.TrimSpace(req.Slug) == "" { + return "slug is required" + } + req.PublicHost = hostOnly(req.PublicHost) + if req.PublicHost == "" { + return "public_host is required" + } + if strings.TrimSpace(req.OwnerEmail) == "" || !strings.Contains(req.OwnerEmail, "@") { + return "owner_email must be an email address" + } + if strings.TrimSpace(req.OwnerName) == "" { + return "owner_name is required" + } + if req.OwnerTimezone == "" { + return "owner_timezone is required" + } + if _, err := time.LoadLocation(req.OwnerTimezone); err != nil { + return "invalid owner_timezone: " + req.OwnerTimezone + } + et := req.Defaults.EventType + if et.Slug != "" { + if strings.TrimSpace(et.Name) == "" { + return "defaults.event_type.name is required" + } + if et.DurationMinutes <= 0 { + return "defaults.event_type.duration_minutes must be positive" + } + // validHHMM is override.go's, deliberately reused: availability_rules stores the + // same HH:MM shape for both surfaces and two validators would drift. + for _, a := range et.Availability { + if a.DayOfWeek < 0 || a.DayOfWeek > 6 { + return "availability day_of_week must be 0 (Sunday) through 6 (Saturday)" + } + if !validHHMM(a.StartTime) || !validHHMM(a.EndTime) { + return "availability start_time and end_time must be HH:MM" + } + if a.StartTime >= a.EndTime { + return "availability start_time must be before end_time" + } + } + } + return "" +} + +// mustRandom returns n cryptographically random bytes. rand.Read from crypto/rand cannot +// fail on any platform this runs on (it panics internally on a broken CSPRNG since Go +// 1.24), so there is no error to thread through the caller. +func mustRandom(n int) []byte { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand: " + err.Error()) + } + return b +} diff --git a/internal/handler/platform_data.go b/internal/handler/platform_data.go new file mode 100644 index 0000000..04deeb5 --- /dev/null +++ b/internal/handler/platform_data.go @@ -0,0 +1,513 @@ +package handler + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/calnode/calnode/internal/db" +) + +// Export, import and attendee erasure (D12). +// +// Everything here is Platform-wrapped, so h.db is the platform handle: unscoped, policy +// bypassing, and binding ''. Two rules follow, and they are the same two the rest of +// platform.go obeys — every read carries its own workspace_id predicate, and every INSERT +// names workspace_id. Import goes further and names the workspace from the URL rather than +// from the document, so an import into A cannot write a row for B even if the document +// says otherwise. + +// exportTableOrder is the replay order: parents before children. +// +// ⛔ It is a list rather than db.TenantTables' alphabetical order because +// alphabetical is a foreign-key violation waiting to happen — booking_answers before +// bookings, event_type_hosts before event_types. Import replays the document in the order +// the document carries, so this order IS the contract. +// +// ⚠️ It is checked against db.TenantTables at export time (exportCoversEveryTenantTable), +// so a table added by a later migration fails the export loudly instead of being quietly +// left out of every tenant's backup. That check is the reason this list can be trusted. +var exportTableOrder = []string{ + // The tenant's own settings and people first: almost everything references a user. + "server_settings", + "users", + "teams", + "team_members", + // Event types and what hangs off them. + "event_types", + "event_type_hosts", + "event_type_questions", + "event_type_reminders", + "availability_rules", + "availability_overrides", + // Bookings and their children. + "bookings", + "booking_hosts", + "booking_attendees", + "booking_answers", + "booking_manage_tokens", + // Meeting artefacts. + "notes", + "transcripts", + "recordings", + "meeting_consents", + // Integrations. + "calendar_connections", + "connection_calendars", + "zoom_connections", + // Credentials and sessions. Included verbatim, ciphertext and hashes alike: the + // destination has to accept the same API keys and manage links, or a migrated tenant's + // integrations all break at once. + "api_keys", + "sessions", + "magic_link_tokens", + "invite_tokens", + "oauth_access_tokens", + "oauth_auth_codes", + // Subscriptions and their delivery history. + "webhooks", + "webhook_deliveries", + // Queue and replay state. + "jobs", + "idempotency_keys", +} + +// workspaceExport is the document. Tables are an ordered array rather than a map because +// the replay order is part of the data, and Go map iteration would shuffle it. +type workspaceExport struct { + FormatVersion int `json:"format_version"` + ExportedAt string `json:"exported_at"` + Workspace map[string]any `json:"workspace"` + DEKFingerprint string `json:"dek_fingerprint"` + Tables []exportedTable `json:"tables"` + RowCounts map[string]int `json:"row_counts"` +} + +type exportedTable struct { + Table string `json:"table"` + Rows []map[string]any `json:"rows"` +} + +// ExportWorkspace handles POST /v1/platform/workspaces/{id}/export. +// +// One JSON document holding every tenant-table row for the workspace, in replay order, +// with workspace_id on every row and secrets included verbatim. +// +// ⛔ Secrets in the clear-as-ciphertext: the _enc columns and the API-key hashes travel as +// they are stored, because the destination must accept the same credentials — a tenant +// whose keys and manage links stopped working on migration has not been migrated. The +// consequence is that this document is as sensitive as the database, and the reason +// dek_fingerprint exists (see below). +func (h *Handler) ExportWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + id := r.PathValue("id") + + ws, err := h.readPlatformWorkspace(r.Context(), id) + if err != nil { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + if err := exportCoversEveryTenantTable(); err != nil { + h.logger.ErrorContext(r.Context(), "platform: export table list is stale", "error", err) + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + fingerprint, err := h.dekFingerprint(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: dek fingerprint", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + out := workspaceExport{ + FormatVersion: 1, + ExportedAt: time.Now().UTC().Format(time.RFC3339Nano), + Workspace: ws, + DEKFingerprint: fingerprint, + RowCounts: map[string]int{}, + } + for _, table := range exportTableOrder { + rows, err := h.exportTable(r.Context(), table, id) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: export table", "table", table, "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + out.Tables = append(out.Tables, exportedTable{Table: table, Rows: rows}) + out.RowCounts[table] = len(rows) + } + + h.logger.InfoContext(r.Context(), "platform: workspace exported", "workspace_id", id) + h.writeJSON(w, http.StatusOK, out) +} + +// exportTable reads one table's rows for a workspace, generically. +// +// SELECT * and rows.Columns() rather than 32 hand-written column lists: a migration that +// adds a column would silently stop exporting it otherwise, and the failure would only +// show up as data missing after an import. ORDER BY 1 makes two exports of the same +// workspace byte-identical, which is what the round-trip test compares. +func (h *Handler) exportTable(ctx context.Context, table, workspaceID string) ([]map[string]any, error) { + // table comes from exportTableOrder, a package-level literal checked against + // db.TenantTables — never from the request. + rows, err := h.db.QueryContext(ctx, + `SELECT * FROM `+table+` WHERE workspace_id = ? ORDER BY 1`, workspaceID) // #nosec G202 + if err != nil { + return nil, fmt.Errorf("select %s: %w", table, err) + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, fmt.Errorf("columns of %s: %w", table, err) + } + + out := []map[string]any{} + for rows.Next() { + cells := make([]any, len(cols)) + for i := range cells { + cells[i] = new(any) + } + if err := rows.Scan(cells...); err != nil { + return nil, fmt.Errorf("scan %s: %w", table, err) + } + row := make(map[string]any, len(cols)) + for i, col := range cols { + v := *(cells[i].(*any)) + // TEXT arrives as []byte from one driver and string from the other; both are + // text as far as this schema is concerned, and normalising here is what makes + // an export from PostgreSQL replayable and comparable. + if b, ok := v.([]byte); ok { + v = string(b) + } + row[col] = v + } + out = append(out, row) + } + return out, rows.Err() +} + +// ImportWorkspace handles POST /v1/platform/workspaces/{id}/import. +// +// The inverse of export, in one transaction, refusing a workspace that already holds rows. +func (h *Handler) ImportWorkspace(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + id := r.PathValue("id") + + if _, err := h.readPlatformWorkspace(r.Context(), id); err != nil { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + + // 64 MB: an export of a busy workspace carries its whole delivery history. + r.Body = http.MaxBytesReader(w, r.Body, 64<<20) + dec := json.NewDecoder(r.Body) + // ⛔ UseNumber, so 9007199254740993 survives. Without it every numeric column round + // trips through float64 and large ids lose their low bits silently. + dec.UseNumber() + var doc workspaceExport + if err := dec.Decode(&doc); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if doc.FormatVersion != 1 { + h.writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported format_version %d", doc.FormatVersion)) + return + } + + // ⛔ The DEK check, and it is a refusal rather than a warning. Every _enc column in the + // document is ciphertext under the SOURCE instance's data key. If the destination's key + // differs, the rows import perfectly and then every secret in them — SMTP password, + // LLM key, LiveKit secret, calendar tokens — fails to decrypt at first use, one + // integration at a time, long after anyone is watching this response. A mismatch here + // is the only moment the two keys can be compared. + fingerprint, err := h.dekFingerprint(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: dek fingerprint", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if doc.DEKFingerprint != "" && doc.DEKFingerprint != fingerprint { + h.writeError(w, http.StatusConflict, + "dek_fingerprint does not match this instance: every encrypted column in the "+ + "document would be undecryptable here. Move CALNODE_ENCRYPTION_KEY with the data, "+ + "or re-key the source before exporting") + return + } + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: import begin tx", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + defer tx.Rollback() //nolint:errcheck + + // Refuse a workspace that holds anything. Checked INSIDE the transaction so the check + // and the inserts cannot be interleaved by a second import. + for _, table := range exportTableOrder { + var n int + if err := tx.QueryRowContext(r.Context(), + `SELECT COUNT(*) FROM `+table+` WHERE workspace_id = ?`, id).Scan(&n); err != nil { // #nosec G202 + h.logger.ErrorContext(r.Context(), "platform: import precheck", "table", table, "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if n > 0 { + h.writeError(w, http.StatusConflict, + fmt.Sprintf("workspace %s already has %d rows in %s: import only into an empty workspace", id, n, table)) + return + } + } + + imported := map[string]int{} + for _, t := range doc.Tables { + if !isExportableTable(t.Table) { + h.writeError(w, http.StatusBadRequest, "unknown table in document: "+t.Table) + return + } + for _, row := range t.Rows { + if err := importRow(r.Context(), tx, t.Table, id, row); err != nil { + h.logger.ErrorContext(r.Context(), "platform: import row", + "table", t.Table, "error", err) + h.writeError(w, http.StatusBadRequest, + fmt.Sprintf("import %s: %v", t.Table, err)) + return + } + } + imported[t.Table] = len(t.Rows) + } + + if err := tx.Commit(); err != nil { + h.logger.ErrorContext(r.Context(), "platform: import commit", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + h.logger.InfoContext(r.Context(), "platform: workspace imported", "workspace_id", id) + h.writeJSON(w, http.StatusOK, map[string]any{"imported": imported}) +} + +// importRow inserts one exported row, forcing workspace_id to the target workspace. +// +// ⛔ Forcing it is the safety property, not a convenience: a document exported from +// workspace B carries workspace_id = "b" on every row, and replaying it into A must produce +// A's rows or fail. Trusting the document would make this endpoint a way to write into any +// tenant, authorised only by holding an export of another one. +func importRow(ctx context.Context, tx *db.Tx, table, workspaceID string, row map[string]any) error { + cols := make([]string, 0, len(row)+1) + args := make([]any, 0, len(row)+1) + for col, v := range row { + if col == "workspace_id" { + continue + } + if !validColumnName(col) { + return fmt.Errorf("invalid column name %q", col) + } + cols = append(cols, col) + args = append(args, importValue(v)) + } + cols = append(cols, "workspace_id") + args = append(args, workspaceID) + + placeholders := strings.TrimSuffix(strings.Repeat("?, ", len(cols)), ", ") + _, err := tx.ExecContext(ctx, + `INSERT INTO `+table+` (`+strings.Join(cols, ", ")+`) VALUES (`+placeholders+`)`, args...) // #nosec G202 -- table is allowlisted against db.TenantTables and every column name is validated; all values are bound + return err +} + +// importValue converts a decoded JSON value into something both drivers accept. +// +// json.Number is carried as an int64 when it is one, so an INTEGER column receives an +// integer rather than a float or a string — the two engines disagree about what they do +// with the other two, and neither disagreement is visible until a later read. +func importValue(v any) any { + n, ok := v.(json.Number) + if !ok { + return v + } + if i, err := n.Int64(); err == nil { + return i + } + if f, err := n.Float64(); err == nil { + return f + } + return n.String() +} + +// validColumnName guards the one part of an import statement that is not a bind parameter. +// Column names come from a document a platform operator supplied, so they are checked +// rather than trusted; a name that is not [a-z0-9_] cannot be a column in this schema. +func validColumnName(col string) bool { + if col == "" || len(col) > 64 { + return false + } + for i := 0; i < len(col); i++ { + c := col[i] + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' { + return false + } + } + return true +} + +func isExportableTable(table string) bool { + for _, t := range exportTableOrder { + if t == table { + return true + } + } + return false +} + +// exportCoversEveryTenantTable reports a tenant table that export would leave out, or an +// entry in the order that is not a tenant table at all. +// +// ⛔ This is what makes a backup trustworthy. Migration 00060's list of tenant tables is +// already guarded (TestTenancy_tableListsCoverTheSchema fails when a new table is in +// neither the tenant nor the exempt list), so checking THIS list against THAT one means a +// table added by a later migration cannot be silently absent from every export. It is +// checked at request time, not just in a test, because the consequence of being wrong is +// data that was never backed up. +func exportCoversEveryTenantTable() error { + inOrder := map[string]bool{} + for _, t := range exportTableOrder { + inOrder[t] = true + } + var missing []string + for _, t := range db.TenantTables { + if !inOrder[t] { + missing = append(missing, t) + } + } + tenant := map[string]bool{} + for _, t := range db.TenantTables { + tenant[t] = true + } + var unknown []string + for _, t := range exportTableOrder { + if !tenant[t] { + unknown = append(unknown, t) + } + } + if len(missing) > 0 || len(unknown) > 0 { + return fmt.Errorf("export table order is stale: missing %v, not tenant tables %v", missing, unknown) + } + return nil +} + +// dekFingerprint identifies the data key this instance is using, without revealing it. +// +// ⛔ The DEK ITSELF DOES NOT TRAVEL, and that is a decision worth stating. crypto_keystore +// is exempt from tenancy (D2) and holds one wrapped DEK per PROCESS (D3) — there is no +// per-workspace row to carry, and manufacturing one would be worse than useless: an export +// of a single tenant would then contain the key that decrypts EVERY tenant's secrets on +// that instance, which is the opposite of what the isolation is for. +// +// What travels instead is a fingerprint: SHA-256 over the wrapped DEK, which is already a +// ciphertext and cannot be reversed to the key. Equal fingerprints mean the two instances +// share a data key, so the document's _enc columns will decrypt. Different ones mean they +// will not, and import refuses. +// +// A per-tenant DEK would change this (and D3, and the schema). Until then, moving a +// workspace between instances means moving CALNODE_ENCRYPTION_KEY with it. +func (h *Handler) dekFingerprint(ctx context.Context) (string, error) { + var wrapped []byte + err := h.db.QueryRowContext(ctx, + `SELECT wrapped_dek FROM crypto_keystore WHERE label = 'primary'`).Scan(&wrapped) + if err != nil { + // No keystore row at all is a legitimate state (an instance that has never + // encrypted anything), and it fingerprints as empty so an export from one such + // instance imports into another without a spurious conflict. + if strings.Contains(err.Error(), "no rows") { + return "", nil + } + return "", fmt.Errorf("read keystore: %w", err) + } + sum := sha256.Sum256(wrapped) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// EraseAttendee handles DELETE /v1/platform/workspaces/{id}/attendees?email= +// +// An erasure request for one person in one workspace. It cancels nothing: the bookings +// stay, because the host's calendar and the other attendees' records are not the erased +// person's data to remove, and a cancellation would notify people about someone else's +// deletion request. +func (h *Handler) EraseAttendee(w http.ResponseWriter, r *http.Request) { + if !h.platformAuthorized(w, r) { + return + } + id := r.PathValue("id") + email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email"))) + if email == "" || !strings.Contains(email, "@") { + h.writeError(w, http.StatusBadRequest, "email is required") + return + } + if _, err := h.readPlatformWorkspace(r.Context(), id); err != nil { + h.writeError(w, http.StatusNotFound, "workspace not found") + return + } + + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: erase begin tx", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + defer tx.Rollback() //nolint:errcheck + + // ⚠️ Answers are keyed on (booking_id, question_id) and carry no attendee, so "their + // answers" has to be derived. They are erased only for bookings where this person was + // the ONLY attendee — with anyone else still on the booking, the answers cannot be + // attributed to the erased person, and deleting them would erase a third party's data + // to satisfy someone else's request. + answers, err := tx.ExecContext(r.Context(), ` + DELETE FROM booking_answers + WHERE workspace_id = ? + AND booking_id IN ( + SELECT booking_id FROM booking_attendees + WHERE workspace_id = ? AND LOWER(email) = ?) + AND booking_id NOT IN ( + SELECT booking_id FROM booking_attendees + WHERE workspace_id = ? AND LOWER(email) <> ?)`, + id, id, email, id, email) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: erase answers", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + attendees, err := tx.ExecContext(r.Context(), + `DELETE FROM booking_attendees WHERE workspace_id = ? AND LOWER(email) = ?`, id, email) + if err != nil { + h.logger.ErrorContext(r.Context(), "platform: erase attendees", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := tx.Commit(); err != nil { + h.logger.ErrorContext(r.Context(), "platform: erase commit", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + answerCount, _ := answers.RowsAffected() + attendeeCount, _ := attendees.RowsAffected() + h.logger.InfoContext(r.Context(), "platform: attendee erased", + "workspace_id", id, "attendees", attendeeCount, "answers", answerCount) + h.writeJSON(w, http.StatusOK, map[string]any{ + "booking_attendees": attendeeCount, + "booking_answers": answerCount, + }) +} diff --git a/internal/handler/platform_data_test.go b/internal/handler/platform_data_test.go new file mode 100644 index 0000000..6b44a93 --- /dev/null +++ b/internal/handler/platform_data_test.go @@ -0,0 +1,527 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/handler" +) + +// Export, import and attendee erasure (D12). Real pair throughout: the round trip is only +// meaningful if the rows it moves are ones the policies would otherwise hide. + +// newPlatformDataAPI returns the seven platform routes plus both handles. +func newPlatformDataAPI(t *testing.T) (map[string]http.HandlerFunc, *db.DB, *db.DB) { + t.Helper() + app, platform := dbtest.RequireTenantPair(t) + + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://cal.example.test") + h.SetPlatformToken(platformToken) + h.SetEncKey(platformTestEncKey) + + return map[string]http.HandlerFunc{ + "create": h.Platform((*handler.Handler).CreateWorkspace), + "export": h.Platform((*handler.Handler).ExportWorkspace), + "import": h.Platform((*handler.Handler).ImportWorkspace), + "erase": h.Platform((*handler.Handler).EraseAttendee), + "delete": h.Platform((*handler.Handler).DeleteWorkspace), + "getWorkspace": h.Platform((*handler.Handler).GetWorkspace), + }, app, platform +} + +// seedTenantData gives a workspace one booking with two attendees and an answer, a note, +// and (from provisioning) a webhook, an API key, an owner and an event type — so the round +// trip has a parent-child chain, a credential, a secret and a free-text row to compare. +func seedTenantData(t *testing.T, platform *db.DB, wsID string) { + t.Helper() + var ownerID, etID string + if err := platform.QueryRow( + `SELECT id FROM users WHERE workspace_id = ? AND is_owner = 1`, wsID).Scan(&ownerID); err != nil { + t.Fatalf("read owner of %s: %v", wsID, err) + } + if err := platform.QueryRow( + `SELECT id FROM event_types WHERE workspace_id = ?`, wsID).Scan(&etID); err != nil { + t.Fatalf("read event type of %s: %v", wsID, err) + } + + stmts := []struct { + query string + args []any + }{ + {`INSERT INTO bookings (id, workspace_id, event_type_id, host_id, start_at, end_at, status, created_at) + VALUES (?, ?, ?, ?, '2026-10-01T10:00:00Z', '2026-10-01T10:30:00Z', 'confirmed', '2026-09-01T09:00:00Z')`, + []any{wsID + "-booking", wsID, etID, ownerID}}, + {`INSERT INTO booking_attendees (id, workspace_id, booking_id, name, email, iana_timezone, is_organizer) + VALUES (?, ?, ?, 'Ada', 'ada@example.test', 'UTC', 0)`, + []any{wsID + "-att-ada", wsID, wsID + "-booking"}}, + {`INSERT INTO event_type_questions (id, workspace_id, event_type_id, label, type, position) + VALUES (?, ?, ?, 'Why?', 'text', 0)`, + []any{wsID + "-q", wsID, etID}}, + {`INSERT INTO booking_answers (id, workspace_id, booking_id, question_id, value) + VALUES (?, ?, ?, ?, 'because')`, + []any{wsID + "-ans", wsID, wsID + "-booking", wsID + "-q"}}, + {`INSERT INTO notes (id, workspace_id, booking_id, content, status, created_at, updated_at) + VALUES (?, ?, ?, 'a private note', 'ready', '2026-09-01T09:05:00Z', '2026-09-01T09:05:00Z')`, + []any{wsID + "-note", wsID, wsID + "-booking"}}, + } + for _, s := range stmts { + if _, err := platform.Exec(s.query, s.args...); err != nil { + t.Fatalf("seed %s: %v", wsID, err) + } + } +} + +func provisionForData(t *testing.T, routes map[string]http.HandlerFunc, id, host string) { + t.Helper() + rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody(id, host), platformToken) + if rec.Code != http.StatusCreated { + t.Fatalf("provision %s: %d — %s", id, rec.Code, rec.Body.String()) + } +} + +// doPlatformSub drives a /{id}/ route, which doPlatform's 5-segment path-value rule +// does not cover. +func doPlatformSub(t *testing.T, route http.HandlerFunc, method, target, id string, body []byte, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + req.SetPathValue("id", id) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + route(rec, req) + return rec +} + +// The round trip: export, delete the workspace, re-create it empty, import, export again, +// and compare the two documents. +// +// ⛔ The second export is what makes this a real assertion. Comparing row counts would pass +// with every value mangled; comparing the documents byte for byte (minus the two fields +// that are timestamps of the export itself) says the data that came back is the data that +// left, including the API-key hash and the encrypted webhook secret. +func TestPlatformData_exportDeleteImportRoundTrip(t *testing.T) { + routes, _, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + seedTenantData(t, platform, "acme") + + first := doPlatformSub(t, routes["export"], http.MethodPost, + "/v1/platform/workspaces/acme/export", "acme", nil, platformToken) + if first.Code != http.StatusOK { + t.Fatalf("export: %d — %s", first.Code, first.Body.String()) + } + document := first.Body.Bytes() + + // Every table the schema says is a tenant table is present in the document. + var doc struct { + Tables []struct { + Table string `json:"table"` + Rows []map[string]any `json:"rows"` + } `json:"tables"` + RowCounts map[string]int `json:"row_counts"` + DEKFingerprint string `json:"dek_fingerprint"` + } + if err := json.Unmarshal(document, &doc); err != nil { + t.Fatalf("decode export: %v", err) + } + present := map[string]bool{} + for _, tbl := range doc.Tables { + present[tbl.Table] = true + } + for _, want := range db.TenantTables { + if !present[want] { + t.Errorf("export omits the tenant table %q — a workspace's backup would be incomplete", want) + } + } + for table, min := range map[string]int{ + "users": 1, "event_types": 1, "availability_rules": 2, "api_keys": 1, + "webhooks": 1, "bookings": 1, "booking_attendees": 1, "booking_answers": 1, + "notes": 1, "server_settings": 1, + } { + if doc.RowCounts[table] < min { + t.Errorf("export has %d rows in %s; want at least %d", doc.RowCounts[table], table, min) + } + } + + // Delete, then re-create the same workspace empty. + if rec := doPlatform(t, routes["delete"], http.MethodDelete, + "/v1/platform/workspaces/acme", nil, platformToken); rec.Code != http.StatusOK { + t.Fatalf("delete: %d — %s", rec.Code, rec.Body.String()) + } + var left int + if err := platform.QueryRow(`SELECT COUNT(*) FROM users WHERE workspace_id = 'acme'`).Scan(&left); err != nil { + t.Fatalf("count after delete: %v", err) + } + if left != 0 { + t.Fatalf("delete left %d users behind", left) + } + if _, err := platform.Exec(` + INSERT INTO workspaces (id, slug, public_host, region, status, created_at, updated_at) + VALUES ('acme', 'acme', 'book.acme.example', 'us', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`); err != nil { + t.Fatalf("re-create workspace: %v", err) + } + + imp := doPlatformSub(t, routes["import"], http.MethodPost, + "/v1/platform/workspaces/acme/import", "acme", document, platformToken) + if imp.Code != http.StatusOK { + t.Fatalf("import: %d — %s", imp.Code, imp.Body.String()) + } + + second := doPlatformSub(t, routes["export"], http.MethodPost, + "/v1/platform/workspaces/acme/export", "acme", nil, platformToken) + if second.Code != http.StatusOK { + t.Fatalf("second export: %d — %s", second.Code, second.Body.String()) + } + + if a, b := normaliseExport(t, document), normaliseExport(t, second.Body.Bytes()); a != b { + t.Errorf("the round trip is not byte-identical: %s", firstDifference(a, b)) + } +} + +// normaliseExport drops the fields that describe the export rather than the workspace: the +// timestamp of the export itself, and the workspace row's own updated_at, which the +// re-creation above legitimately rewrites. +func normaliseExport(t *testing.T, raw []byte) string { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("decode for comparison: %v", err) + } + delete(doc, "exported_at") + delete(doc, "workspace") + out, err := json.Marshal(doc) + if err != nil { + t.Fatalf("re-encode for comparison: %v", err) + } + return string(out) +} + +// firstDifference reports where two documents diverge, because a diff of two 50 KB JSON +// strings is unreadable in a test log. +func firstDifference(a, b string) string { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] != b[i] { + lo := i - 80 + if lo < 0 { + lo = 0 + } + hiA, hiB := i+80, i+80 + if hiA > len(a) { + hiA = len(a) + } + if hiB > len(b) { + hiB = len(b) + } + return "at byte " + strconv.Itoa(i) + "\n…" + a[lo:hiA] + "\n…" + b[lo:hiB] + } + } + if len(a) != len(b) { + return "lengths differ: " + strconv.Itoa(len(a)) + " vs " + strconv.Itoa(len(b)) + } + return "identical" +} + +// Import refuses a workspace that already holds rows, and leaves it exactly as it was. +func TestPlatformData_importIntoAPopulatedWorkspaceIs409(t *testing.T) { + routes, _, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + seedTenantData(t, platform, "acme") + + export := doPlatformSub(t, routes["export"], http.MethodPost, + "/v1/platform/workspaces/acme/export", "acme", nil, platformToken) + if export.Code != http.StatusOK { + t.Fatalf("export: %d — %s", export.Code, export.Body.String()) + } + + before := tenantRowCounts(t, platform, "acme") + + rec := doPlatformSub(t, routes["import"], http.MethodPost, + "/v1/platform/workspaces/acme/import", "acme", export.Body.Bytes(), platformToken) + if rec.Code != http.StatusConflict { + t.Fatalf("import into a populated workspace: %d; want 409 — %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "already has") { + t.Errorf("409 body = %s; want it to name the table that is not empty", rec.Body.String()) + } + + after := tenantRowCounts(t, platform, "acme") + for table, n := range before { + if after[table] != n { + t.Errorf("%s went from %d to %d rows; a refused import must change nothing", + table, n, after[table]) + } + } +} + +// ⛔ A document exported from B, imported into A, must produce A's rows — not B's. The +// endpoint is authorised by the platform token, so trusting the document's own workspace_id +// would make an export of any workspace a way to write into any other. +func TestPlatformData_importForcesTheTargetWorkspace(t *testing.T) { + routes, app, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "globex", "book.globex.example") + seedTenantData(t, platform, "globex") + + export := doPlatformSub(t, routes["export"], http.MethodPost, + "/v1/platform/workspaces/globex/export", "globex", nil, platformToken) + if export.Code != http.StatusOK { + t.Fatalf("export globex: %d — %s", export.Code, export.Body.String()) + } + document := export.Body.Bytes() + if !bytes.Contains(document, []byte(`"globex"`)) { + t.Fatal("the document does not mention globex, so this test would prove nothing") + } + + // ⚠️ The source workspace is deleted first, and that is not tidying: ids are GLOBAL + // primary keys, so importing a document into a second workspace while the first still + // holds its rows collides on users_pkey. The supported operation is a MOVE — export, + // delete, import, usually into another region's instance where the rows do not exist — + // and this test performs it inside one database because that is where the workspace_id + // question can be asked. + if rec := doPlatform(t, routes["delete"], http.MethodDelete, + "/v1/platform/workspaces/globex", nil, platformToken); rec.Code != http.StatusOK { + t.Fatalf("delete globex: %d — %s", rec.Code, rec.Body.String()) + } + + // An empty target workspace, with a different id from the one the document names. + if _, err := platform.Exec(` + INSERT INTO workspaces (id, slug, public_host, region, status, created_at, updated_at) + VALUES ('acme', 'acme', 'book.acme.example', 'us', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`); err != nil { + t.Fatalf("seed acme: %v", err) + } + + if rec := doPlatformSub(t, routes["import"], http.MethodPost, + "/v1/platform/workspaces/acme/import", "acme", document, platformToken); rec.Code != http.StatusOK { + t.Fatalf("import into acme: %d — %s", rec.Code, rec.Body.String()) + } + + // Every imported row belongs to acme, and globex still has exactly its own. + var acmeBookings, globexBookings int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE workspace_id = 'acme'`).Scan(&acmeBookings); err != nil { + t.Fatalf("count acme bookings: %v", err) + } + if err := platform.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE workspace_id = 'globex'`).Scan(&globexBookings); err != nil { + t.Fatalf("count globex bookings: %v", err) + } + if acmeBookings != 1 || globexBookings != 0 { + t.Errorf("bookings: acme %d, globex %d; want 1 and 0 — every row in a document that "+ + "says \"globex\" must land in the workspace named by the URL", acmeBookings, globexBookings) + } + + // And acme's own bound handle can see them, which is the whole point of forcing the id: + // a row carrying globex's workspace_id would be invisible to acme under the policies. + var visible int + if err := app.ForWorkspace("acme").QueryRow(`SELECT COUNT(*) FROM bookings`).Scan(&visible); err != nil { + t.Fatalf("count as acme: %v", err) + } + if visible != 1 { + t.Errorf("acme's own handle sees %d bookings; want 1", visible) + } +} + +// Erasure: exactly that email, in exactly that workspace, cancelling nothing. +func TestPlatformData_eraseAttendee(t *testing.T) { + routes, _, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + provisionForData(t, routes, "globex", "book.globex.example") + seedTenantData(t, platform, "acme") + seedTenantData(t, platform, "globex") // the same address, in another workspace + + // A second attendee on acme's booking, so the answer must SURVIVE: with someone else + // still on the booking, the answers cannot be attributed to the erased person. + if _, err := platform.Exec(` + INSERT INTO booking_attendees (id, workspace_id, booking_id, name, email, iana_timezone, is_organizer) + VALUES ('acme-att-bob', 'acme', 'acme-booking', 'Bob', 'bob@example.test', 'UTC', 0)`); err != nil { + t.Fatalf("seed second attendee: %v", err) + } + + rec := doPlatformSub(t, routes["erase"], http.MethodDelete, + "/v1/platform/workspaces/acme/attendees?email=ada@example.test", "acme", nil, platformToken) + if rec.Code != http.StatusOK { + t.Fatalf("erase: %d — %s", rec.Code, rec.Body.String()) + } + var counts struct { + Attendees int `json:"booking_attendees"` + Answers int `json:"booking_answers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil { + t.Fatalf("decode counts: %v", err) + } + if counts.Attendees != 1 { + t.Errorf("booking_attendees erased = %d; want 1", counts.Attendees) + } + if counts.Answers != 0 { + t.Errorf("booking_answers erased = %d; want 0 — another attendee is still on that "+ + "booking, so the answers are not unambiguously the erased person's", counts.Answers) + } + + // Ada is gone from acme, Bob is not, and the booking is untouched. + var ada, bob, bookings, status int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM booking_attendees WHERE workspace_id = 'acme' AND email = 'ada@example.test'`).Scan(&ada); err != nil { + t.Fatalf("count ada: %v", err) + } + if err := platform.QueryRow( + `SELECT COUNT(*) FROM booking_attendees WHERE workspace_id = 'acme' AND email = 'bob@example.test'`).Scan(&bob); err != nil { + t.Fatalf("count bob: %v", err) + } + if err := platform.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE workspace_id = 'acme'`).Scan(&bookings); err != nil { + t.Fatalf("count bookings: %v", err) + } + if err := platform.QueryRow( + `SELECT COUNT(*) FROM bookings WHERE workspace_id = 'acme' AND status = 'confirmed'`).Scan(&status); err != nil { + t.Fatalf("count confirmed: %v", err) + } + if ada != 0 || bob != 1 { + t.Errorf("after erasure acme has %d ada and %d bob rows; want 0 and 1", ada, bob) + } + if bookings != 1 || status != 1 { + t.Errorf("bookings = %d (%d confirmed); erasure cancels nothing", bookings, status) + } + + // ⛔ And globex's Ada is untouched. The erasure names a workspace, and an erasure + // request from one tenant is not consent to delete another tenant's records. + var globexAda int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM booking_attendees WHERE workspace_id = 'globex' AND email = 'ada@example.test'`).Scan(&globexAda); err != nil { + t.Fatalf("count globex ada: %v", err) + } + if globexAda != 1 { + t.Errorf("globex's ada rows = %d; want 1 — erasure must not cross the workspace boundary", globexAda) + } +} + +// The only-attendee case: with nobody else on the booking, the answers go too. +func TestPlatformData_eraseTakesAnswersWhenNobodyElseIsOnTheBooking(t *testing.T) { + routes, _, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + seedTenantData(t, platform, "acme") + + rec := doPlatformSub(t, routes["erase"], http.MethodDelete, + "/v1/platform/workspaces/acme/attendees?email=ada@example.test", "acme", nil, platformToken) + if rec.Code != http.StatusOK { + t.Fatalf("erase: %d — %s", rec.Code, rec.Body.String()) + } + var counts struct { + Attendees int `json:"booking_attendees"` + Answers int `json:"booking_answers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &counts); err != nil { + t.Fatalf("decode counts: %v", err) + } + if counts.Attendees != 1 || counts.Answers != 1 { + t.Errorf("erased %d attendees and %d answers; want 1 and 1 — she was the only "+ + "attendee, so the answers were hers", counts.Attendees, counts.Answers) + } +} + +func TestPlatformData_eraseRequiresAnEmail(t *testing.T) { + routes, _, _ := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + + for name, query := range map[string]string{ + "missing": "", + "not an email": "?email=nobody", + "empty": "?email=", + } { + t.Run(name, func(t *testing.T) { + rec := doPlatformSub(t, routes["erase"], http.MethodDelete, + "/v1/platform/workspaces/acme/attendees"+query, "acme", nil, platformToken) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d; want 400 — %s", rec.Code, rec.Body.String()) + } + }) + } +} + +// All three routes are behind the same token gate as the rest of the platform API. +func TestPlatformData_routesRefuseWithoutTheToken(t *testing.T) { + routes, _, _ := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + + for _, c := range []struct { + name, method, target, route string + }{ + {"export", http.MethodPost, "/v1/platform/workspaces/acme/export", "export"}, + {"import", http.MethodPost, "/v1/platform/workspaces/acme/import", "import"}, + {"erase", http.MethodDelete, "/v1/platform/workspaces/acme/attendees?email=a@b.test", "erase"}, + } { + t.Run(c.name, func(t *testing.T) { + rec := doPlatformSub(t, routes[c.route], c.method, c.target, "acme", []byte(`{}`), "") + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401 without a token", rec.Code) + } + }) + } +} + +// A document whose dek_fingerprint names another instance's data key is refused, because +// every _enc column in it would be undecryptable here — one integration at a time, long +// after anyone is reading this response. +func TestPlatformData_importRefusesAForeignDEK(t *testing.T) { + routes, _, platform := newPlatformDataAPI(t) + provisionForData(t, routes, "acme", "book.acme.example") + + export := doPlatformSub(t, routes["export"], http.MethodPost, + "/v1/platform/workspaces/acme/export", "acme", nil, platformToken) + if export.Code != http.StatusOK { + t.Fatalf("export: %d — %s", export.Code, export.Body.String()) + } + + var doc map[string]any + if err := json.Unmarshal(export.Body.Bytes(), &doc); err != nil { + t.Fatalf("decode: %v", err) + } + doc["dek_fingerprint"] = "sha256:" + strings.Repeat("ab", 32) + foreign, err := json.Marshal(doc) + if err != nil { + t.Fatalf("encode: %v", err) + } + + if rec := doPlatform(t, routes["delete"], http.MethodDelete, + "/v1/platform/workspaces/acme", nil, platformToken); rec.Code != http.StatusOK { + t.Fatalf("delete: %d", rec.Code) + } + if _, err := platform.Exec(` + INSERT INTO workspaces (id, slug, public_host, region, status, created_at, updated_at) + VALUES ('acme', 'acme', 'book.acme.example', 'us', 'active', '2026-09-01T00:00:00Z', '2026-09-01T00:00:00Z')`); err != nil { + t.Fatalf("re-create: %v", err) + } + + rec := doPlatformSub(t, routes["import"], http.MethodPost, + "/v1/platform/workspaces/acme/import", "acme", foreign, platformToken) + if rec.Code != http.StatusConflict { + t.Fatalf("import with a foreign dek_fingerprint: %d; want 409 — %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "CALNODE_ENCRYPTION_KEY") { + t.Errorf("409 body = %s; want it to name what the operator has to move", rec.Body.String()) + } +} + +// tenantRowCounts counts every tenant table for a workspace, through the platform handle. +func tenantRowCounts(t *testing.T, platform *db.DB, wsID string) map[string]int { + t.Helper() + out := map[string]int{} + for _, table := range db.TenantTables { + var n int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM `+table+` WHERE workspace_id = ?`, wsID).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + out[table] = n + } + return out +} diff --git a/internal/handler/platform_test.go b/internal/handler/platform_test.go new file mode 100644 index 0000000..b9803b8 --- /dev/null +++ b/internal/handler/platform_test.go @@ -0,0 +1,508 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/handler" +) + +// The platform API (D12): workspace provisioning on the identity host. +// +// ⛔ These need a real OpenPair, not one handle. Provisioning runs on the PLATFORM handle +// and every INSERT names workspace_id, and the failure mode being guarded against — a row +// that does not belong to the tenant it was created for — is invisible unless the +// application handle is a NOBYPASSRLS role that the policies actually constrain. + +const platformToken = "platform-token-for-tests" + +// platformTestEncKey is a 32-byte AES key in hex. The platform API encrypts the SMTP +// password, the LLM key and the LiveKit secret it is given, so a handler without a key +// would fail provisioning for a reason unrelated to what these tests are about. +const platformTestEncKey = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + +// newPlatformAPI returns the four routes as server.New registers them, plus the platform +// handle the assertions read through and the application handle a tenant's own requests +// would use. +func newPlatformAPI(t *testing.T) (routes map[string]http.HandlerFunc, app, platform *db.DB) { + t.Helper() + app, platform = dbtest.RequireTenantPair(t) + + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://cal.example.test") + h.SetPlatformToken(platformToken) + h.SetEncKey(platformTestEncKey) + + return map[string]http.HandlerFunc{ + "create": h.Platform((*handler.Handler).CreateWorkspace), + "get": h.Platform((*handler.Handler).GetWorkspace), + "patch": h.Platform((*handler.Handler).PatchWorkspace), + "delete": h.Platform((*handler.Handler).DeleteWorkspace), + }, app, platform +} + +// platformCreateBody is the settled contract body, with the fields the website client +// sends. Cases mutate the one field they are about. +func platformCreateBody(id, host string) map[string]any { + return map[string]any{ + "id": id, + "slug": id, + "public_host": host, + "region": "us", + "owner_email": "owner@" + id + ".example", + "owner_name": "Owner " + id, + "owner_timezone": "America/Toronto", + "defaults": map[string]any{ + "embed_allowed_origins": []string{"https://" + host}, + "webhook": map[string]any{ + "url": "https://hooks." + host + "/calnode", + "fields": []string{"booking_id", "start_at"}, + }, + "event_type": map[string]any{ + "slug": "intro", + "name": "Intro call", + "duration_minutes": 30, + "min_notice_minutes": 60, + "max_future_days": 60, + "availability": []map[string]any{ + {"day_of_week": 1, "start_time": "09:00", "end_time": "17:00"}, + {"day_of_week": 3, "start_time": "09:00", "end_time": "12:00"}, + }, + }, + "livekit_url": "wss://lk." + host, + "livekit_api_key": "lkkey", + "smtp": map[string]any{ + "host": "smtp." + host, "port": "587", + "from": "bookings@" + host, "from_name": "Bookings", + }, + }, + } +} + +func doPlatform(t *testing.T, route http.HandlerFunc, method, target string, body any, token string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + if err := json.NewEncoder(&buf).Encode(body); err != nil { + t.Fatalf("encode body: %v", err) + } + } + req := httptest.NewRequest(method, target, &buf) + // ⚠️ httptest.NewRequest does not populate mux path values — those come from the + // pattern the ServeMux matched, and these tests call the handler directly. Without + // this every {id} route reads an empty id and answers 404, which looks exactly like a + // missing workspace. + if parts := strings.Split(target, "/"); len(parts) == 5 { + req.SetPathValue("id", parts[4]) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + route(rec, req) + return rec +} + +// The whole provisioning, asserted row by row through the platform handle — because that +// is the only handle that can see across the tenant boundary, and every row here was +// written by a statement that had to name the tenant itself. +func TestPlatform_createProvisionsTheWholeWorkspace(t *testing.T) { + routes, _, platform := newPlatformAPI(t) + + rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d; want 201 — %s", rec.Code, rec.Body.String()) + } + + var out struct { + APIKey string `json:"api_key"` + WebhookSecret string `json:"webhook_secret"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(out.APIKey) < 20 || out.APIKey[:4] != "cno_" { + t.Errorf("api_key = %q; want a cno_ key", out.APIKey) + } + if len(out.WebhookSecret) != 64 { + t.Errorf("webhook_secret = %q; want 64 hex characters (32 bytes)", out.WebhookSecret) + } + + // The workspace row. + var slug, host, region, status string + if err := platform.QueryRow( + `SELECT slug, public_host, region, status FROM workspaces WHERE id = 'acme'`). + Scan(&slug, &host, ®ion, &status); err != nil { + t.Fatalf("workspace row: %v", err) + } + if slug != "acme" || host != "book.acme.example" || region != "us" || status != "active" { + t.Errorf("workspace = %s/%s/%s/%s; want acme/book.acme.example/us/active", slug, host, region, status) + } + + // Every seeded row belongs to acme. Counted with the workspace_id predicate the + // production statements had to carry, so a row that landed anywhere else is missing + // here rather than merely misfiled. + for _, c := range []struct { + what string + query string + }{ + {"server_settings", `SELECT COUNT(*) FROM server_settings WHERE workspace_id = 'acme' AND id = 1`}, + {"owner user", `SELECT COUNT(*) FROM users WHERE workspace_id = 'acme' AND is_owner = 1`}, + {"api key", `SELECT COUNT(*) FROM api_keys WHERE workspace_id = 'acme'`}, + {"webhook", `SELECT COUNT(*) FROM webhooks WHERE workspace_id = 'acme'`}, + {"event type", `SELECT COUNT(*) FROM event_types WHERE workspace_id = 'acme' AND slug = 'intro'`}, + } { + var n int + if err := platform.QueryRow(c.query).Scan(&n); err != nil { + t.Fatalf("count %s: %v", c.what, err) + } + if n != 1 { + t.Errorf("%s rows in acme = %d; want 1", c.what, n) + } + } + var rules int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM availability_rules WHERE workspace_id = 'acme'`).Scan(&rules); err != nil { + t.Fatalf("count availability rules: %v", err) + } + if rules != 2 { + t.Errorf("availability rules = %d; want 2", rules) + } + + // owner_timezone, not UTC. The availability rules above are local HH:MM interpreted + // in this timezone, so defaulting it would silently move the workspace's hours. + var tz string + if err := platform.QueryRow( + `SELECT iana_timezone FROM users WHERE workspace_id = 'acme' AND is_owner = 1`).Scan(&tz); err != nil { + t.Fatalf("owner timezone: %v", err) + } + if tz != "America/Toronto" { + t.Errorf("owner iana_timezone = %q; want America/Toronto (the requested zone, not UTC)", tz) + } + + // The settings row carries the defaults, including the two columns migration 00062 + // added for values that had no home. + var origins, smtpHost, lkURL string + if err := platform.QueryRow(` + SELECT embed_allowed_origins, smtp_host, livekit_url + FROM server_settings WHERE workspace_id = 'acme' AND id = 1`). + Scan(&origins, &smtpHost, &lkURL); err != nil { + t.Fatalf("settings row: %v", err) + } + if origins != "https://book.acme.example" { + t.Errorf("embed_allowed_origins = %q; want the requested origin", origins) + } + if smtpHost != "smtp.book.acme.example" || lkURL != "wss://lk.book.acme.example" { + t.Errorf("settings = %q / %q; want the requested smtp host and livekit url", smtpHost, lkURL) + } +} + +// The event set the provisioned subscription carries, pinned exactly. +// +// ⛔ It has to be every event this codebase emits. The receiver on the other side handles +// all seven, and a subscription short of that means those events are silently never +// delivered — noticed weeks later as "recordings never appear", with nothing in either +// system to point at. The three media events fire only when recording or the notetaker is +// on, so subscribing to them costs a tenancy nothing. +func TestPlatform_createSubscribesToEveryEmittedEvent(t *testing.T) { + routes, _, platform := newPlatformAPI(t) + + if rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken); rec.Code != http.StatusCreated { + t.Fatalf("create: %d — %s", rec.Code, rec.Body.String()) + } + + var eventsJSON string + if err := platform.QueryRow( + `SELECT events FROM webhooks WHERE workspace_id = 'acme'`).Scan(&eventsJSON); err != nil { + t.Fatalf("read the subscription: %v", err) + } + var got []string + if err := json.Unmarshal([]byte(eventsJSON), &got); err != nil { + t.Fatalf("decode events %q: %v", eventsJSON, err) + } + + want := []string{ + "booking.created", "booking.cancelled", "booking.rescheduled", "booking.reminder", + "recording.completed", "transcript.ready", "notes.ready", + } + if len(got) != len(want) { + t.Fatalf("subscribed events = %v; want all %d: %v", got, len(want), want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("event %d = %q; want %q (the order is the stored order, so this pins the "+ + "whole list rather than its membership)", i, got[i], want[i]) + } + } +} + +// Two workspaces provisioned through the same endpoint must not see each other. This is +// the assertion that would fail if any INSERT above had left workspace_id to the column +// default. +func TestPlatform_createdWorkspacesAreIsolated(t *testing.T) { + routes, app, platform := newPlatformAPI(t) + + for _, ws := range []struct{ id, host string }{ + {"acme", "book.acme.example"}, + {"globex", "book.globex.example"}, + } { + rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody(ws.id, ws.host), platformToken) + if rec.Code != http.StatusCreated { + t.Fatalf("provision %s: status = %d — %s", ws.id, rec.Code, rec.Body.String()) + } + } + + // Through the APPLICATION handle bound to acme, only acme's rows exist — and the + // count is the one a forgotten predicate would get wrong, so it is the honest probe. + var users, ets int + acme := app.ForWorkspace("acme") + if err := acme.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + t.Fatalf("count users as acme: %v", err) + } + if err := acme.QueryRow(`SELECT COUNT(*) FROM event_types`).Scan(&ets); err != nil { + t.Fatalf("count event types as acme: %v", err) + } + if users != 1 || ets != 1 { + t.Errorf("acme sees %d users and %d event types; want 1 and 1 — the other workspace's "+ + "rows are visible", users, ets) + } + + // And the platform handle sees both, which is what makes the 1s above meaningful + // rather than an empty database. + var allUsers int + if err := platform.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&allUsers); err != nil { + t.Fatalf("count all users: %v", err) + } + if allUsers != 2 { + t.Errorf("platform handle sees %d users; want 2", allUsers) + } +} + +func TestPlatform_duplicateIDOrHostIs409(t *testing.T) { + routes, _, platform := newPlatformAPI(t) + + first := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken) + if first.Code != http.StatusCreated { + t.Fatalf("first create: status = %d — %s", first.Code, first.Body.String()) + } + + sameID := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "other.example"), platformToken) + if sameID.Code != http.StatusConflict { + t.Errorf("duplicate id: status = %d; want 409 — %s", sameID.Code, sameID.Body.String()) + } + + body := platformCreateBody("globex", "book.acme.example") // B's id, A's host + sameHost := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", body, platformToken) + if sameHost.Code != http.StatusConflict { + t.Errorf("duplicate public_host: status = %d; want 409 — %s", sameHost.Code, sameHost.Body.String()) + } + + // ⛔ And the losing create left nothing behind: the whole provisioning is one + // transaction, so a 409 must not leave a workspace with an owner and a live API key. + var globex int + if err := platform.QueryRow(`SELECT COUNT(*) FROM users WHERE workspace_id = 'globex'`).Scan(&globex); err != nil { + t.Fatalf("count globex users: %v", err) + } + if globex != 0 { + t.Errorf("a refused create left %d users in globex; the transaction must roll back whole", globex) + } +} + +func TestPlatform_getPatchDelete(t *testing.T) { + routes, _, platform := newPlatformAPI(t) + + if rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken); rec.Code != http.StatusCreated { + t.Fatalf("create: %d — %s", rec.Code, rec.Body.String()) + } + + // GET + rec := doPlatform(t, routes["get"], http.MethodGet, "/v1/platform/workspaces/acme", nil, platformToken) + if rec.Code != http.StatusOK { + t.Fatalf("get: status = %d — %s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get: %v", err) + } + if got["id"] != "acme" || got["public_host"] != "book.acme.example" || got["status"] != "active" { + t.Errorf("get returned %#v; want acme / book.acme.example / active", got) + } + + missing := doPlatform(t, routes["get"], http.MethodGet, "/v1/platform/workspaces/nope", nil, platformToken) + if missing.Code != http.StatusNotFound { + t.Errorf("get unknown: status = %d; want 404", missing.Code) + } + + // PATCH: suspend and move the host in one call. + patch := doPlatform(t, routes["patch"], http.MethodPatch, "/v1/platform/workspaces/acme", + map[string]any{"status": "suspended", "public_host": "vanity.acme.example"}, platformToken) + if patch.Code != http.StatusOK { + t.Fatalf("patch: status = %d — %s", patch.Code, patch.Body.String()) + } + var host, status string + if err := platform.QueryRow( + `SELECT public_host, status FROM workspaces WHERE id = 'acme'`).Scan(&host, &status); err != nil { + t.Fatalf("read back: %v", err) + } + if host != "vanity.acme.example" || status != "suspended" { + t.Errorf("after patch: %s / %s; want vanity.acme.example / suspended", host, status) + } + + bad := doPlatform(t, routes["patch"], http.MethodPatch, "/v1/platform/workspaces/acme", + map[string]any{"status": "paused"}, platformToken) + if bad.Code != http.StatusBadRequest { + t.Errorf("patch to an invalid status: %d; want 400", bad.Code) + } + empty := doPlatform(t, routes["patch"], http.MethodPatch, "/v1/platform/workspaces/acme", + map[string]any{}, platformToken) + if empty.Code != http.StatusBadRequest { + t.Errorf("patch with no fields: %d; want 400", empty.Code) + } + + // A recording with an object key, so DELETE has something to report. + if _, err := platform.Exec(` + INSERT INTO recordings (id, workspace_id, room, egress_id, status, object_key) + VALUES ('rec1', 'acme', 'booking-x', 'eg1', 'complete', 'recordings/acme/rec1.mp4')`); err != nil { + t.Fatalf("seed recording: %v", err) + } + + del := doPlatform(t, routes["delete"], http.MethodDelete, "/v1/platform/workspaces/acme", nil, platformToken) + if del.Code != http.StatusOK { + t.Fatalf("delete: status = %d — %s", del.Code, del.Body.String()) + } + var delOut struct { + Keys []string `json:"recording_object_keys"` + } + if err := json.Unmarshal(del.Body.Bytes(), &delOut); err != nil { + t.Fatalf("decode delete: %v", err) + } + if len(delOut.Keys) != 1 || delOut.Keys[0] != "recordings/acme/rec1.mp4" { + t.Errorf("recording_object_keys = %#v; want the one seeded key — the objects are the "+ + "caller's to delete and nothing else reports them", delOut.Keys) + } + + // The cascade: no tenant row survives the workspace. + for _, table := range []string{"users", "api_keys", "event_types", "availability_rules", "webhooks", "server_settings", "recordings"} { + var n int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM ` + table + ` WHERE workspace_id = 'acme'`).Scan(&n); err != nil { + t.Fatalf("count %s after delete: %v", table, err) + } + if n != 0 { + t.Errorf("%s still holds %d rows for the deleted workspace; the ON DELETE CASCADE "+ + "from migration 00060 should have taken them", table, n) + } + } + + if again := doPlatform(t, routes["delete"], http.MethodDelete, + "/v1/platform/workspaces/acme", nil, platformToken); again.Code != http.StatusNotFound { + t.Errorf("second delete: status = %d; want 404", again.Code) + } +} + +// The token, and the two shapes of "off". A wrong token is 401 so an operator can tell a +// typo from a missing feature; an unset token is 404 so a prober cannot tell a +// multi-tenant control plane from an instance that has none. +func TestPlatform_tokenGate(t *testing.T) { + routes, _, _ := newPlatformAPI(t) + + for name, token := range map[string]string{ + "wrong token": "not-the-token", + "no token": "", + } { + t.Run(name, func(t *testing.T) { + rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), token) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + }) + } +} + +func TestPlatform_404WithoutATokenConfigured(t *testing.T) { + database := dbtest.Open(t) + h := handler.New(database, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) // multi-tenant, but no token: the API does not exist + route := h.Platform((*handler.Handler).CreateWorkspace) + + rec := doPlatform(t, route, http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d; want 404 with CALNODE_PLATFORM_TOKEN unset", rec.Code) + } +} + +// A single-tenant instance has no workspaces to provision, so the API is absent there even +// with a token configured. Same 404, same reasoning as Setup's mirror image. +func TestPlatform_404OnASingleTenantInstance(t *testing.T) { + database := dbtest.Open(t) + h := handler.New(database, slog.New(slog.DiscardHandler)) + h.SetPlatformToken(platformToken) // token set, but MULTI_TENANT is not + route := h.Platform((*handler.Handler).CreateWorkspace) + + rec := doPlatform(t, route, http.MethodPost, "/v1/platform/workspaces", + platformCreateBody("acme", "book.acme.example"), platformToken) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d; want 404 on a single-tenant instance", rec.Code) + } +} + +func TestPlatform_createValidation(t *testing.T) { + cases := map[string]func(map[string]any){ + "id is not a workspace id": func(b map[string]any) { b["id"] = "Not An Id" }, + "id default is reserved": func(b map[string]any) { b["id"] = "default" }, + "no public_host": func(b map[string]any) { b["public_host"] = "" }, + "owner_email is not one": func(b map[string]any) { b["owner_email"] = "nobody" }, + "no owner_timezone": func(b map[string]any) { b["owner_timezone"] = "" }, + "bad owner_timezone": func(b map[string]any) { b["owner_timezone"] = "Mars/Olympus" }, + "day_of_week out of range": func(b map[string]any) { + d := b["defaults"].(map[string]any) + et := d["event_type"].(map[string]any) + et["availability"] = []map[string]any{{"day_of_week": 7, "start_time": "09:00", "end_time": "17:00"}} + }, + "start_time is not HH:MM": func(b map[string]any) { + d := b["defaults"].(map[string]any) + et := d["event_type"].(map[string]any) + et["availability"] = []map[string]any{{"day_of_week": 1, "start_time": "9am", "end_time": "17:00"}} + }, + "start_time after end_time": func(b map[string]any) { + d := b["defaults"].(map[string]any) + et := d["event_type"].(map[string]any) + et["availability"] = []map[string]any{{"day_of_week": 1, "start_time": "18:00", "end_time": "17:00"}} + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + routes, _, platform := newPlatformAPI(t) + body := platformCreateBody("acme", "book.acme.example") + mutate(body) + + rec := doPlatform(t, routes["create"], http.MethodPost, "/v1/platform/workspaces", body, platformToken) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400 — %s", rec.Code, rec.Body.String()) + } + var n int + if err := platform.QueryRow(`SELECT COUNT(*) FROM workspaces WHERE id <> 'default'`).Scan(&n); err != nil { + t.Fatalf("count workspaces: %v", err) + } + if n != 0 { + t.Errorf("a refused create left %d workspaces behind", n) + } + }) + } +} diff --git a/internal/handler/question_test.go b/internal/handler/question_test.go index 658ae12..d2fe97e 100644 --- a/internal/handler/question_test.go +++ b/internal/handler/question_test.go @@ -232,6 +232,86 @@ func TestCreateQuestion_autoPosition(t *testing.T) { } } +// TestCreateQuestion_returningPosition covers the one RETURNING clause in the +// tree (question_handler.go): when the caller sends no position, the handler +// computes it inside the INSERT — VALUES (…, (SELECT COALESCE(MAX(position)+1, 0) +// …)) RETURNING position — rather than SELECT-then-INSERT, so two concurrent +// creates cannot land on the same position. +// +// Boundary 2 left that clause as the one piece of ported SQL verified on SQLite +// and unverified on PostgreSQL. It runs on whichever engine dbtest selects, and +// it asserts the value the handler SCANNED from RETURNING, not just the row that +// ended up in the table: the two agreeing is the whole point, and a RETURNING +// that silently returned a zero value would still leave a correct row behind. +func TestCreateQuestion_returningPosition(t *testing.T) { + h, database, key, _ := setupWorkspaceWithDB(t) + ctx := context.Background() + slug, _ := seedEventTypeHTTP(t, h, key) + t.Logf("engine: %s", database.Dialect()) + + // create posts a question and returns (id, position) as the CREATE response + // reported them — i.e. what RETURNING produced on the auto-position path. + create := func(body string) (string, int) { + t.Helper() + req := authReq(http.MethodPost, "/v1/event-types/"+slug+"/questions", body, key) + req.SetPathValue("slug", slug) + rec := httptest.NewRecorder() + h.RequireAuth(h.CreateQuestion)(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create %s: got %d — %s", body, rec.Code, rec.Body.String()) + } + var resp struct { + ID string `json:"id"` + Position int `json:"position"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode create response: %v", err) + } + return resp.ID, resp.Position + } + + stored := func(id string) int { + t.Helper() + var pos int + if err := database.QueryRowContext(ctx, + `SELECT position FROM event_type_questions WHERE id = ?`, id).Scan(&pos); err != nil { + t.Fatalf("read stored position for %s: %v", id, err) + } + return pos + } + + // First three, no position in the request: RETURNING must hand back 0, 1, 2. + for want := 0; want < 3; want++ { + id, got := create(fmt.Sprintf(`{"label":"Q%d","type":"text"}`, want)) + if got != want { + t.Errorf("auto position #%d: RETURNING gave %d; want %d", want, got, want) + } + if s := stored(id); s != got { + t.Errorf("auto position #%d: RETURNING gave %d but the row holds %d", want, got, s) + } + } + + // An explicit position takes the other branch (a plain INSERT, no RETURNING), + // and then the next auto position must be MAX+1 over it — 10, not 3. This is + // what proves the subselect inside VALUES is evaluated by the engine rather + // than the value being an accident of insertion order. + explicitID, explicitPos := create(`{"label":"Pinned","type":"text","position":9}`) + if explicitPos != 9 { + t.Errorf("explicit position: got %d; want 9", explicitPos) + } + if s := stored(explicitID); s != 9 { + t.Errorf("explicit position: row holds %d; want 9", s) + } + + nextID, nextPos := create(`{"label":"After the pinned one","type":"text"}`) + if nextPos != 10 { + t.Errorf("auto position after an explicit 9: RETURNING gave %d; want 10", nextPos) + } + if s := stored(nextID); s != nextPos { + t.Errorf("auto position after an explicit 9: RETURNING gave %d but the row holds %d", nextPos, s) + } +} + // --------------------------------------------------------------------------- // UpdateQuestion // --------------------------------------------------------------------------- diff --git a/internal/handler/reassign.go b/internal/handler/reassign.go index e3d58c1..bf90a7b 100644 --- a/internal/handler/reassign.go +++ b/internal/handler/reassign.go @@ -207,12 +207,12 @@ func (h *Handler) ReassignBooking(w http.ResponseWriter, r *http.Request) { prefs := h.hostPrefsOrDefault(ctx, bCopy.ID, newHostID) if prefs.NotifyConfirmation { - if err := mailer.SendConfirmationToAttendee(ctx, h.mailer, d); err != nil { + if err := mailer.SendConfirmationToAttendee(ctx, h.getMailer(), d); err != nil { h.logger.Error("reassign: email attendee", "error", err, "booking_id", bCopy.ID) } } if prefs.NotifyHostBooking { - if err := mailer.SendConfirmationToHost(ctx, h.mailer, d); err != nil { + if err := mailer.SendConfirmationToHost(ctx, h.getMailer(), d); err != nil { h.logger.Error("reassign: email new host", "error", err, "booking_id", bCopy.ID) } } diff --git a/internal/handler/reminder_race_test.go b/internal/handler/reminder_race_test.go new file mode 100644 index 0000000..203c2b5 --- /dev/null +++ b/internal/handler/reminder_race_test.go @@ -0,0 +1,158 @@ +package handler_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/uid" +) + +// The reminder-replacement race, forced rather than waited for. +// +// Two detached goroutines write the same reminder payload: the booking CREATE path +// (enqueueBookingReminders) and the RESCHEDULE path (replaceReminderJobs). jobs carries a +// unique on (workspace_id, type, payload), so exactly one row can exist per +// (booking, hours_before) — and the losing interleaving is: +// +// reschedule: DELETE reminder rows for this booking (create's row not yet visible) +// create: INSERT the reminder row at the OLD time +// reschedule: INSERT the reminder row at the NEW time -> conflict +// +// With ON CONFLICT DO NOTHING the last step is a silent no-op and the reminder stays +// pinned to the original time forever. With DO UPDATE it wins, which is what this holds. +// +// ⛔ The interleaving is forced by the unique index itself, not by a sleep: the +// create-side row is inserted inside an UNCOMMITTED transaction, so it is invisible to +// the DELETE and yet already owns the key — which makes the reschedule's INSERT BLOCK +// until that transaction commits. The test waits for a backend to actually be +// lock-waiting before committing, so a run where the interleaving was never reached fails +// loudly instead of passing on the easy path. +func TestReplaceReminderJobs_upsertSurvivesTheLosingInterleaving(t *testing.T) { + h, database, key, _ := setupWorkspaceWithDB(t) + ctx := context.Background() + + if database.Dialect() != db.DialectPostgres { + t.Skip("LOUD SKIP: this needs two concurrent connections to hold one uncommitted " + + "row while another transaction blocks on its key. SQLite runs on a single " + + "connection by design (it is what serialises write transactions there), so " + + "the interleaving cannot be constructed and the race cannot occur either.") + } + + slug, etID := seedEventTypeHTTP(t, h, key) + bookStart := futureAt(10, 10, 0) + newStart := futureAt(13, 9, 0) + bookingID := createBookingViaHTTP(t, h, slug, bookStart.Format(time.RFC3339)) + + // Clean slate: drop whatever the create path enqueued, so the only rows in play are + // the ones this test writes. + if _, err := database.ExecContext(ctx, + `DELETE FROM jobs WHERE type = 'reminder.send'`); err != nil { + t.Fatalf("clear reminder jobs: %v", err) + } + + oldRunAt := bookStart.Add(-24 * time.Hour).UTC().Format(time.RFC3339) + wantRunAt := newStart.Add(-24 * time.Hour).UTC().Format(time.RFC3339) + payload := fmt.Sprintf(`{"booking_id":%q,"hours_before":24}`, bookingID) + + // The create side's insert, held open. Identical payload, OLD run_at. + tx, err := database.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin create-side tx: %v", err) + } + defer tx.Rollback() //nolint:errcheck + if _, err := tx.ExecContext(ctx, ` + INSERT INTO jobs (id, type, payload, run_at, status, attempts, max_attempts) + VALUES (?, 'reminder.send', ?, ?, 'pending', 0, 3) + ON CONFLICT (workspace_id, type, payload) DO NOTHING`, + uid.New(), payload, oldRunAt); err != nil { + t.Fatalf("create-side insert: %v", err) + } + + // The reschedule, which will delete nothing (the row above is invisible) and then + // block on its key. + replaced := make(chan error, 1) + go func() { replaced <- handler.ReplaceReminderJobsForTest(h, ctx, bookingID, etID, newStart) }() + + waitForLockWaiter(t, database) + + if err := tx.Commit(); err != nil { + t.Fatalf("commit create-side tx: %v", err) + } + if err := <-replaced; err != nil { + t.Fatalf("replaceReminderJobs: %v", err) + } + + var rows int + if err := database.QueryRowContext(ctx, + `SELECT COUNT(*) FROM jobs WHERE type = 'reminder.send'`).Scan(&rows); err != nil { + t.Fatalf("count reminder jobs: %v", err) + } + if rows != 1 { + t.Errorf("reminder rows = %d; want 1 — the unique on (workspace_id, type, payload) "+ + "admits exactly one row per (booking, hours_before)", rows) + } + + var gotRunAt, status string + if err := database.QueryRowContext(ctx, + `SELECT run_at, status FROM jobs WHERE type = 'reminder.send'`).Scan(&gotRunAt, &status); err != nil { + t.Fatalf("read surviving reminder job: %v", err) + } + if gotRunAt != wantRunAt { + t.Errorf("surviving reminder run_at = %s; want %s (the rescheduled time). "+ + "%s is the ORIGINAL time, which is what ON CONFLICT DO NOTHING leaves behind", + gotRunAt, wantRunAt, oldRunAt) + } + if status != "pending" { + t.Errorf("surviving reminder status = %q; want pending", status) + } +} + +// waitForLockWaiter blocks until the reschedule's INSERT is demonstrably queued behind the +// uncommitted key. It fails rather than returning if that never happens: without the block, +// the test would be exercising the ordinary interleaving and proving nothing. +// +// ⛔ Two signals, because `wait_event_type = 'Lock'` ALONE IS NOT ENOUGH — a `-race +// -count=30` run on a box with two other workers on it failed here 2 times in 30, always at +// this control and never at the run_at assertion. The fix was holding; the detection was not. +// +// The reason is that the Lock wait is a STATE THIS POLL HAS TO CATCH THE BACKEND IN, and +// `pg_stat_activity` is a sampled view of it. Under `-race` (several times slower on the Go +// side) plus CPU contention, the goroutine issuing the INSERT can still be inside the driver, +// or between statements of its transaction, for the whole window this loop looks at — so "not +// blocked yet" and "never going to block" are indistinguishable from that one column. +// +// ⚠️ It is NOT driver-side queueing: the PostgreSQL pool defaults to 10 open / 5 idle +// (config.PoolFromEnv) and this test uses two connections, so nothing is waiting for one. It +// is `-race` plus load, which is why the answer is a better signal rather than a bigger pool. +// +// So a backend running the reschedule's own INSERT counts too, identified by its query text. +// An `active` backend running that statement is either about to block on the key or already +// past it; either way the interleaving has been reached, which is all this control claims. 30s +// rather than 10 for the same reason: the deadline has to outlast a slow, loaded box, and it is +// only ever paid when something is genuinely wrong. +func waitForLockWaiter(t *testing.T, database *db.DB) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var waiters int + if err := database.QueryRow(` + SELECT COUNT(*) FROM pg_stat_activity + WHERE datname = current_database() + AND (wait_event_type = 'Lock' + OR (state = 'active' AND query LIKE '%ON CONFLICT (workspace_id, type, payload) DO UPDATE%'))`). + Scan(&waiters); err != nil { + t.Fatalf("read pg_stat_activity: %v", err) + } + if waiters > 0 { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("in 30s no backend ever blocked on a lock and none was running the reschedule's " + + "upsert, so the losing interleaving was never reached and this test would pass " + + "whatever ON CONFLICT does") +} diff --git a/internal/handler/reschedule_test.go b/internal/handler/reschedule_test.go index 26a01c7..852dc89 100644 --- a/internal/handler/reschedule_test.go +++ b/internal/handler/reschedule_test.go @@ -106,18 +106,60 @@ func TestRescheduleBooking_updatesReminderJob(t *testing.T) { // replaceReminderJobs deletes old jobs and inserts fresh ones keyed by booking_id. // Poll until a pending reminder job with the correct run_at appears. + // + // The JSON lookup is a dialect pair for the same reason the production statement + // is one. The Scan error is captured rather than discarded: swallowing it turned a + // "json_extract does not exist" into an empty run_at and a two-second poll that + // then reported itself as a time-parsing failure. + // + // ⛔ Three things here are about a flake, not about style, and each one hid the + // timeout it was supposed to report. Measured at 2 failures in 60 PostgreSQL runs + // after feat/platform-hooks merged, 0 in 60 before it. + // + // 1. The loop polls for the WANTED run_at. Breaking on "anything that is not the + // old string" meant any stale or unrelated row ended the wait, and a row whose + // text differs while its instant does not (a second reminder offset, a + // differently-formatted stamp) counted as success. + // 2. Falling out of the loop on the DEADLINE is a Fatal that says so. Before, the + // last value read stayed in runAt and was asserted on, so a two-second timeout + // reported itself as "run_at is three days wrong" — a wrong answer to a + // question nobody asked, and the reason this took a bisect to attribute. + // 3. ORDER BY run_at DESC LIMIT 1 makes the read deterministic. An event type may + // have several reminder offsets, so QueryRow without an order returns an + // arbitrary one of them on PostgreSQL and a stable one on SQLite — a test that + // passes on one engine and flakes on the other. + // 4. The deadline is proportional to what is being waited FOR. replaceReminderJobs + // is the LAST step of the detached rescheduleSideEffects goroutine, after two + // emails and a webhook enqueue, and that goroutine gives itself 30s. Two + // seconds was not a correctness bound, it was a bet on scheduling — and on the + // PostgreSQL lane, where every one of those steps is a round trip and the + // package runs its tests in parallel on 4 cores, it lost about 3% of the time. + // Ten seconds still fails a real regression promptly; it just no longer fails a + // busy machine. var runAt string - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - database.QueryRowContext(ctx, ` + var lastErr error + payloadMatch := database.Dialect().SQL( + `json_extract(payload, '$.booking_id') = ?`, + `payload::json ->> 'booking_id' = ?`) + wantRunAt := wantNewReminder.Format(time.RFC3339) + deadline := time.Now().Add(10 * time.Second) + for { + lastErr = database.QueryRowContext(ctx, ` SELECT run_at FROM jobs WHERE type = 'reminder.send' - AND json_extract(payload, '$.booking_id') = ? - AND status = 'pending'`, bookingID). + AND `+payloadMatch+` + AND status = 'pending' + ORDER BY run_at DESC + LIMIT 1`, bookingID). Scan(&runAt) - if runAt != "" && runAt != oldReminderAt.Format(time.RFC3339) { + if runAt == wantRunAt { break } + if !time.Now().Before(deadline) { + t.Fatalf("timed out after 10s waiting for the rescheduled reminder job: "+ + "latest pending run_at = %q, want %q (old was %q); last query error: %v", + runAt, wantRunAt, oldReminderAt.Format(time.RFC3339), lastErr) + } time.Sleep(10 * time.Millisecond) } diff --git a/internal/handler/session.go b/internal/handler/session.go index 5f54cff..7c66e91 100644 --- a/internal/handler/session.go +++ b/internal/handler/session.go @@ -3,22 +3,45 @@ package handler import ( "context" "crypto/rand" + "database/sql" "encoding/hex" + "encoding/json" + "errors" + "io" "net/http" "time" ) // createSession inserts a session row and sets the session cookie on w. +// +// workspace_id is left to the column default, which is right for every caller that runs +// on a handle bound to the request's workspace: the bind fills it in. The SSO hand-off +// is the exception, and uses createSessionIn. func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, userID string) error { + return h.createSessionIn(ctx, w, userID, "") +} + +// createSessionIn inserts a session row naming workspaceID, and sets the cookie. +// +// ⛔ It exists for the Platform-wrapped SSO hand-off. That route runs on the platform +// handle, which binds ”, so a session written without the column would not belong to +// the workspace its user does — and every later request for that user runs on a BOUND +// handle, which could then neither read that session nor delete it on logout. An empty +// workspaceID keeps the original statement, so no other caller changes. +func (h *Handler) createSessionIn(ctx context.Context, w http.ResponseWriter, userID, workspaceID string) error { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return err } sessID := hex.EncodeToString(raw) expiresAt := time.Now().UTC().Add(sessionDuration).Format(time.RFC3339) - if _, err := h.db.ExecContext(ctx, - `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`, - sessID, userID, expiresAt); err != nil { + query := `INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)` + args := []any{sessID, userID, expiresAt} + if workspaceID != "" { + query = `INSERT INTO sessions (id, user_id, expires_at, workspace_id) VALUES (?, ?, ?, ?)` + args = append(args, workspaceID) + } + if _, err := h.db.ExecContext(ctx, query, args...); err != nil { return err } http.SetCookie(w, &http.Cookie{ // #nosec G124 -- HttpOnly/SameSite/Secure are all set; Secure is h.secureCookie (true whenever BASE_URL is https, false only for local http dev) rather than a literal, which gosec's static check can't verify @@ -32,3 +55,129 @@ func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, user }) return nil } + +// RevokeAllSessions handles POST /v1/auth/sessions/revoke-all. +// +// Body: `{"user_id": "..."}`, optional. +// +// - Omitted (or naming the caller): signs the caller out everywhere **except the +// session that made the request**. "Sign out my other devices" is the action people +// actually want; dropping the current session too would log the operator out of the +// page they clicked it on, which is what Logout is for. A caller authenticating with +// an API key has no current session, so for them every session goes. +// - Naming someone else: an offboarding tool. Admin-only, and mirroring roles.go's +// tiers — an admin may revoke a member, only the owner may revoke another admin, and +// nobody may revoke the owner's sessions but the owner (there is exactly one owner, +// so that case is the self branch). +// +// It also deletes the target's MCP OAuth access tokens. An MCP connector authenticates +// with a bearer token rather than the session cookie (§19), so revoking sessions alone +// would leave an agent connected with exactly the authority that was just taken away — +// the failure mode being cut off from is a laptop that walked out of the building with a +// signed-in browser AND a connected agent on it. +func (h *Handler) RevokeAllSessions(w http.ResponseWriter, r *http.Request) { + actor, ok := userFromContext(r.Context()) + if !ok { + h.writeError(w, http.StatusUnauthorized, "authentication required") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<10) + var req struct { + UserID string `json:"user_id"` + } + // An empty body is the common case (revoke my own), so EOF is not an error here. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + h.writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + targetID := req.UserID + self := targetID == "" || targetID == actor.ID + if self { + targetID = actor.ID + } else { + // The actor's capability class is checked before the target is looked up, so a + // member cannot use this endpoint's 404 to probe which user ids exist. + if !actor.IsAdmin { + h.writeError(w, http.StatusForbidden, "admin access required") + return + } + var targetIsAdmin, targetIsOwner int + err := h.db.QueryRowContext(r.Context(), + `SELECT is_admin, is_owner FROM users WHERE id = ?`, targetID). + Scan(&targetIsAdmin, &targetIsOwner) + if err == sql.ErrNoRows { + h.writeError(w, http.StatusNotFound, "user not found") + return + } + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: load target", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + if targetIsOwner != 0 { + h.writeError(w, http.StatusForbidden, "the owner's sessions can only be revoked by the owner") + return + } + if targetIsAdmin != 0 && !actor.IsOwner { + h.writeError(w, http.StatusForbidden, "only the workspace owner can revoke another admin's sessions") + return + } + } + + // One transaction: a caller told "revoked" must not have kept an MCP token because + // the second statement failed after the first committed. + tx, err := h.db.BeginTx(r.Context(), nil) + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: begin tx", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + defer tx.Rollback() //nolint:errcheck + + var sessionRes sql.Result + if self { + current := "" + if c, cerr := r.Cookie(sessionCookieName); cerr == nil { + current = c.Value + } + sessionRes, err = tx.ExecContext(r.Context(), + `DELETE FROM sessions WHERE user_id = ? AND id <> ?`, targetID, current) + } else { + sessionRes, err = tx.ExecContext(r.Context(), + `DELETE FROM sessions WHERE user_id = ?`, targetID) + } + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: delete sessions", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + tokenRes, err := tx.ExecContext(r.Context(), + `DELETE FROM oauth_access_tokens WHERE user_id = ?`, targetID) + if err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: delete oauth tokens", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := tx.Commit(); err != nil { + h.logger.ErrorContext(r.Context(), "revoke sessions: commit", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + sessions, _ := sessionRes.RowsAffected() + tokens, _ := tokenRes.RowsAffected() + h.logger.InfoContext(r.Context(), "sessions revoked", + "actor_id", actor.ID, "user_id", targetID, "self", self, + "sessions", sessions, "oauth_tokens", tokens) + + h.writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "user_id": targetID, + "sessions_revoked": sessions, + "oauth_tokens_revoked": tokens, + }) +} diff --git a/internal/handler/session_test.go b/internal/handler/session_test.go new file mode 100644 index 0000000..b84fc2c --- /dev/null +++ b/internal/handler/session_test.go @@ -0,0 +1,266 @@ +package handler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" +) + +// seedSessionID inserts a live session row under a caller-chosen id (the cookie value), +// so one user can be given several and a test can name the one it presents. +func seedSessionID(t *testing.T, database *db.DB, id, userID string) string { + t.Helper() + if _, err := database.Exec(`INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`, + id, userID, time.Now().UTC().Add(24*time.Hour).Format(time.RFC3339)); err != nil { + t.Fatalf("seed session %s: %v", id, err) + } + return id +} + +// seedMCPToken inserts an MCP OAuth access token for userID. +func seedMCPToken(t *testing.T, database *db.DB, id, userID string) { + t.Helper() + if _, err := database.Exec(` + INSERT INTO oauth_access_tokens (id, token_hash, client_id, user_id, expires_at, created_at) + VALUES (?, ?, 'client-1', ?, ?, ?)`, + id, "hash-"+id, userID, + time.Now().UTC().Add(time.Hour).Format(time.RFC3339), + time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("seed mcp token %s: %v", id, err) + } +} + +func countSessions(t *testing.T, database *db.DB, userID string) int { + t.Helper() + var n int + if err := database.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil { + t.Fatalf("count sessions: %v", err) + } + return n +} + +func countMCPTokens(t *testing.T, database *db.DB, userID string) int { + t.Helper() + var n int + if err := database.QueryRow(`SELECT COUNT(*) FROM oauth_access_tokens WHERE user_id = ?`, userID).Scan(&n); err != nil { + t.Fatalf("count mcp tokens: %v", err) + } + return n +} + +// revokeAll drives the handler through RequireAuth. cookie is the session cookie value +// to present (empty for none); apiKey authenticates when no cookie is given. +func revokeAll(h *handler.Handler, body, apiKey, cookie string) *httptest.ResponseRecorder { + var r *http.Request + if body != "" { + r = httptest.NewRequest(http.MethodPost, "/v1/auth/sessions/revoke-all", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + } else { + r = httptest.NewRequest(http.MethodPost, "/v1/auth/sessions/revoke-all", nil) + } + if apiKey != "" { + r.Header.Set("X-API-Key", apiKey) + } + if cookie != "" { + r.AddCookie(&http.Cookie{Name: "calnode_session", Value: cookie}) + } + rec := httptest.NewRecorder() + h.RequireAuth(h.RevokeAllSessions)(rec, r) + return rec +} + +// Revoking your own keeps the session that asked. That is the difference between this +// endpoint and Logout, and the reason it is the default with no body. +func TestRevokeAllSessions_selfKeepsTheCallingSession(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + current := seedSessionID(t, database, "sess-current", ownerID) + seedSessionID(t, database, "sess-laptop", ownerID) + seedSessionID(t, database, "sess-phone", ownerID) + + rec := revokeAll(h, "", "", current) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var resp struct { + SessionsRevoked int `json:"sessions_revoked"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.SessionsRevoked != 2 { + t.Errorf("sessions_revoked = %d; want 2", resp.SessionsRevoked) + } + if n := countSessions(t, database, ownerID); n != 1 { + t.Fatalf("sessions left = %d; want 1 (the calling one)", n) + } + var left string + if err := database.QueryRow(`SELECT id FROM sessions WHERE user_id = ?`, ownerID).Scan(&left); err != nil { + t.Fatalf("read surviving session: %v", err) + } + if left != current { + t.Errorf("surviving session = %q; want %q", left, current) + } +} + +// An API-key caller has no current session, so there is none to spare. +func TestRevokeAllSessions_apiKeyCallerRevokesEveryone(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + seedSessionID(t, database, "sess-a", ownerID) + seedSessionID(t, database, "sess-b", ownerID) + + rec := revokeAll(h, "", ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, ownerID); n != 0 { + t.Errorf("sessions left = %d; want 0", n) + } +} + +// An MCP connector holds a bearer token, not a cookie. Revoking sessions and leaving it +// would hand back exactly the access that was just withdrawn. +func TestRevokeAllSessions_cutsMCPTokensToo(t *testing.T) { + h, database, ownerKey, ownerID := setupWorkspaceWithDB(t) + seedMCPToken(t, database, "tok-1", ownerID) + seedMCPToken(t, database, "tok-2", ownerID) + + rec := revokeAll(h, "", ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + var resp struct { + OAuthTokensRevoked int `json:"oauth_tokens_revoked"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.OAuthTokensRevoked != 2 { + t.Errorf("oauth_tokens_revoked = %d; want 2", resp.OAuthTokensRevoked) + } + if n := countMCPTokens(t, database, ownerID); n != 0 { + t.Errorf("mcp tokens left = %d; want 0", n) + } +} + +// seedRoleUser inserts a user with the given flags plus an API key for them. +func seedRoleUser(t *testing.T, database *db.DB, id, email string, isAdmin, isOwner int, apiKey string) { + t.Helper() + if _, err := database.Exec( + `INSERT INTO users (id,email,name,iana_timezone,is_admin,is_owner) VALUES (?,?,?,'UTC',?,?)`, + id, email, id, isAdmin, isOwner); err != nil { + t.Fatalf("seed user %s: %v", id, err) + } + if apiKey != "" { + if _, err := database.Exec( + `INSERT INTO api_keys (id,user_id,name,key_hash,created_at) VALUES (?,?,'t',?,'2024-01-01')`, + "key-"+id, id, sha256HexForTest(apiKey)); err != nil { + t.Fatalf("seed api key for %s: %v", id, err) + } + } +} + +func TestRevokeAllSessions_memberCannotTargetAnotherUser(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "member-1-key") + seedRoleUser(t, database, "member-2", "m2@example.com", 0, 0, "") + seedSessionID(t, database, "sess-victim", "member-2") + + rec := revokeAll(h, `{"user_id":"member-2"}`, "member-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-2"); n != 1 { + t.Errorf("victim sessions = %d; want 1 (untouched)", n) + } +} + +func TestRevokeAllSessions_adminRevokesAMember(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "") + seedSessionID(t, database, "sess-m1a", "member-1") + seedSessionID(t, database, "sess-m1b", "member-1") + seedMCPToken(t, database, "tok-m1", "member-1") + + rec := revokeAll(h, `{"user_id":"member-1"}`, "admin-1-key", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-1"); n != 0 { + t.Errorf("member sessions = %d; want 0", n) + } + if n := countMCPTokens(t, database, "member-1"); n != 0 { + t.Errorf("member mcp tokens = %d; want 0", n) + } +} + +func TestRevokeAllSessions_onlyTheOwnerRevokesAnAdmin(t *testing.T) { + h, database, ownerKey, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedRoleUser(t, database, "admin-2", "a2@example.com", 1, 0, "") + seedSessionID(t, database, "sess-a2", "admin-2") + + // Admin → admin is refused. + rec := revokeAll(h, `{"user_id":"admin-2"}`, "admin-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("admin targeting admin: status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "admin-2"); n != 1 { + t.Fatalf("admin-2 sessions = %d; want 1 (untouched)", n) + } + + // Owner → admin is allowed. + rec = revokeAll(h, `{"user_id":"admin-2"}`, ownerKey, "") + if rec.Code != http.StatusOK { + t.Fatalf("owner targeting admin: status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "admin-2"); n != 0 { + t.Errorf("admin-2 sessions = %d; want 0", n) + } +} + +// Nobody signs the owner out but the owner, mirroring roles.go refusing to change the +// owner's role. +func TestRevokeAllSessions_ownerIsOffLimitsToAdmins(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "admin-1", "a1@example.com", 1, 0, "admin-1-key") + seedSessionID(t, database, "sess-owner", ownerID) + + rec := revokeAll(h, `{"user_id":"`+ownerID+`"}`, "admin-1-key", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, ownerID); n != 1 { + t.Errorf("owner sessions = %d; want 1 (untouched)", n) + } +} + +func TestRevokeAllSessions_unknownUserIs404(t *testing.T) { + h, _, ownerKey, _ := setupWorkspaceWithDB(t) + + rec := revokeAll(h, `{"user_id":"nobody"}`, ownerKey, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } +} + +// Naming yourself is the self branch, not the admin branch: a member may do it. +func TestRevokeAllSessions_namingYourselfIsSelf(t *testing.T) { + h, database, _, _ := setupWorkspaceWithDB(t) + seedRoleUser(t, database, "member-1", "m1@example.com", 0, 0, "member-1-key") + seedSessionID(t, database, "sess-m1", "member-1") + + rec := revokeAll(h, `{"user_id":"member-1"}`, "member-1-key", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200 — %s", rec.Code, rec.Body.String()) + } + if n := countSessions(t, database, "member-1"); n != 0 { + t.Errorf("sessions left = %d; want 0 (api-key caller has no current session)", n) + } +} diff --git a/internal/handler/setup.go b/internal/handler/setup.go index ba99c9d..c545316 100644 --- a/internal/handler/setup.go +++ b/internal/handler/setup.go @@ -14,6 +14,20 @@ import ( // Setup handles POST /v1/setup — creates the first user and API key. // Returns 409 if the workspace is already configured. func (h *Handler) Setup(w http.ResponseWriter, r *http.Request) { + // ⛔ Refused in multi-tenant mode. This route is Platform-wrapped (it runs + // before any workspace exists, on the identity host), so its INSERTs into + // users and api_keys would land in the DEFAULT workspace — a first user and a + // live API key in a tenant nobody owns, reachable by no host. Workspaces and + // their owners are provisioned through the platform API instead. + // + // 404 rather than 403: on a multi-tenant instance this endpoint does not + // exist, and saying so is better than describing a bootstrap the caller + // cannot perform. + if h.multiTenant { + http.NotFound(w, r) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) var req struct { Name string `json:"name"` diff --git a/internal/handler/shared.go b/internal/handler/shared.go new file mode 100644 index 0000000..45a6c4d --- /dev/null +++ b/internal/handler/shared.go @@ -0,0 +1,117 @@ +package handler + +import ( + "log/slog" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/oauth2" + + "github.com/calnode/calnode/internal/calendar" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/livekit" + "github.com/calnode/calnode/internal/llm" + "github.com/calnode/calnode/internal/mailer" + "github.com/calnode/calnode/internal/stripe" + "github.com/calnode/calnode/internal/zoom" +) + +// shared is everything one process has exactly one of: the logger, the mailer, +// the hot-swappable integration clients and the mutexes guarding them, the +// configured hosts, and the demo-mode bookkeeping. +// +// It exists so Handler can be a cheap VALUE. A tenant-scoped request needs a +// handler whose db is bound to its workspace, and the only way to give 314 +// methods a bound `h.db` without editing all of them is to hand them a receiver +// that differs in that field — so Handler is copied per request. A struct +// holding six sync.RWMutex cannot be copied (go vet copylocks, and rightly: +// copying a mutex copies its state), so the mutexes live behind this pointer, +// which every copy shares. +// +// Embedding it as *shared rather than naming it keeps `h.logger`, `h.mailer`, +// `h.livekitMu` and the rest compiling unchanged across the package. +type shared struct { + logger *slog.Logger + live *mailer.Live // non-nil in production; nil in tests using a direct stub + encKey [32]byte // AES-256 key for encrypting secrets stored in the DB + + // The per-workspace runtime state (D7). Each is built lazily from THAT + // workspace's server_settings row through the bound handle, and replaced when + // that workspace saves its settings. One entry keyed "" in single-tenant mode. + mailerCache *tenantCache[mailer.Mailer] + llmCache *tenantCache[*llm.Client] + zoomCache *tenantCache[*zoom.Client] + stripeCache *tenantCache[*stripe.Client] + livekitCache *tenantCache[*livekit.Client] + // settingsCache holds the two multi-tenant-only server_settings columns + // (embed origins, STT host) per workspace; see tenant_settings.go. + settingsCache *tenantCache[tenantSettings] + + // calBase is the PLATFORM-level provider registry: one Service holding the + // instance's Google/Microsoft OAuth apps and the CalDAV client, per D7. It is + // never used to read a tenant table directly — calCache holds the per-workspace + // copies that Service.ForDB produces, and getCal is the only reader. + calMu sync.RWMutex + calBase *calendar.Service + calCache *tenantCache[*calendar.Service] + calNudge chan struct{} // buffered(1): wakes the calendar reconciler after a failed inline op + + baseURL string + publicBaseURL string + dataDir string + + // ssoSecret, metricsToken and sttBaseURLCfg arrive with feat/platform-hooks. They + // live on shared, not on the per-request Handler: each is one process-wide + // setting read from the environment at boot, none is per workspace, and putting + // them on the value would copy three strings per request for nothing. + // + // ⚠️ ssoSecret is deliberately platform-level even though the token it signs + // carries a `wid` claim. The secret identifies the INSTANCE that minted the + // token; the workspace is inside the payload, which is what lets one identity + // host hand a session to any of its tenants' public hosts (D11). + // appDB is the APPLICATION handle as server.New built it: unbound, and belonging to the + // role the row-level-security policies constrain. + // + // ⛔ forWorkspace binds from THIS, never from h.db, and the difference is the whole + // isolation guarantee on a Platform route. Platform() replaces h.db with the platform + // handle, whose role BYPASSES RLS — so a handle derived from it with ForWorkspace binds + // app.workspace_id and is then not FILTERED by it. A `WHERE id = 1` read through such a + // handle matches every workspace's row and returns an arbitrary one; a write lands + // wherever the statement says. Both are silent. + // + // Found by a vendor-webhook test: the hand-off from a Platform route to + // forWorkspace(ws).getLiveKit() read the DEFAULT workspace's empty settings row instead of + // the resolved tenant's, so the handler answered 200 and verified nothing. + appDB *db.DB + + // platformToken authorises the platform API (D12). Like the two below it is one + // process-wide env-var secret, and it is what makes an instance a control plane + // rather than just a tenant: with it unset every /v1/platform/* route 404s. + platformToken string + + ssoSecret string // HMAC key for the signed session hand-off; empty ⇒ /v1/auth/sso is off + metricsToken string // bearer token for GET /metrics; empty ⇒ that endpoint 404s + sttBaseURLCfg string // STT_BASE_URL override; empty ⇒ stt.DefaultBaseURL (see sttBaseURL) + + authMu sync.RWMutex + googleAuth *oauth2.Config + microsoftAuth *oauth2.Config + secureCookie bool + + demoMode bool // true on the public demo instance: disables calendar/Zoom connect + demoResetInterval time.Duration + demoMu sync.RWMutex + demoNextResetAt time.Time + + // mcpServers caches one MCP server per workspace, keyed by workspace id ("" in + // single-tenant mode). The tools close over their handler, so one shared + // instance would run every tenant's tool calls on one workspace's handle. + mcpMu sync.RWMutex + mcpServers map[string]*mcp.Server + + // multiTenant mirrors config.MultiTenant. It is what turns host and + // credential resolution on; unset, every request runs on the default + // workspace and the handler behaves exactly as it did before. + multiTenant bool +} diff --git a/internal/handler/slots_busy_test.go b/internal/handler/slots_busy_test.go index 9d4be97..62ba09a 100644 --- a/internal/handler/slots_busy_test.go +++ b/internal/handler/slots_busy_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" ) // TestHostAvailability_includesAdjacentUTCDayBooking is the regression guard for @@ -16,14 +16,7 @@ import ( // generation would offer a slot the host is already booked for (then 409 at // booking time). Everything is stored UTC — this guards the fetch *window*. func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) // Host in NZ; a booking at 18 Jun 10:00 NZST = 17 Jun 22:00Z (previous UTC day). @@ -60,14 +53,7 @@ func TestHostAvailability_includesAdjacentUTCDayBooking(t *testing.T) { // bookings.host_id) — otherwise their slots on other events stay open and they // get double-booked. func TestHostAvailability_includesNonPrimaryGroupSeat(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("open db: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.New(slog.DiscardHandler)) database.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin,is_owner) VALUES ('u1','a@example.com','A','UTC',1,1)`) diff --git a/internal/handler/sso.go b/internal/handler/sso.go new file mode 100644 index 0000000..5f7df63 --- /dev/null +++ b/internal/handler/sso.go @@ -0,0 +1,479 @@ +package handler + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/uid" +) + +const ( + // ssoMaxTokenLifetime caps exp - iat. A hand-off is a redirect the browser follows + // immediately, so it needs seconds, not minutes. + ssoMaxTokenLifetime = 60 * time.Second + + // ssoClockSkew is how far the two systems' clocks may disagree before a token is + // refused, applied to both ends of the window. + ssoClockSkew = 30 * time.Second + + // ssoDefaultNext is where a hand-off lands when no ?next= is given. + ssoDefaultNext = "/admin/" +) + +// SetSSOSecret configures the shared secret for the SSO hand-off endpoint. An empty +// secret leaves the endpoint off (it 404s). Set once at boot from config, like +// SetBaseURL and SetEncKey, so there is no lock here. +func (h *Handler) SetSSOSecret(secret string) { + h.ssoSecret = secret +} + +// ssoClaims is the token payload. Every field except wid is required. +type ssoClaims struct { + Iss string `json:"iss"` // issuing system, any non-empty string; logged, never authorised on + Aud string `json:"aud"` // must equal this instance's BASE_URL + Sub string `json:"sub"` // the person's email address + Name string `json:"name"` // display name, used when the user is created + Role string `json:"role"` // owner | admin | member + Iat int64 `json:"iat"` // issued at, seconds since the epoch + Exp int64 `json:"exp"` // expires at, seconds since the epoch + JTI string `json:"jti"` // unique per token; replay is refused + + // WID names the workspace the hand-off lands in. + // + // Ignored in single-tenant mode, where there is one workspace and nothing to + // select — a caller written for a multi-tenant fleet therefore works unchanged + // against a single instance. REQUIRED in multi-tenant mode: /v1/auth/sso is a + // Platform route, so there is no tenant Host to resolve from and no credential + // yet — the token IS the credential and this claim is the only statement of + // which tenant it is for (D11). + WID string `json:"wid"` +} + +// signSSOToken produces the compact HS256 JWS the verifier below accepts. It is the mint +// half of D11, used by the OAuth callbacks. +// +// ⛔ Deliberately NOT a JWT library, for the same reason verifySSOToken is not one: one +// algorithm, one key, a fixed claim set, and no dependency to keep current. The two halves +// live beside each other so a change to either is made looking at the other. +func signSSOToken(claims ssoClaims, secret string) (string, error) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) + payload, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("sso: marshal claims: %w", err) + } + signingInput := header + "." + base64.RawURLEncoding.EncodeToString(payload) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +// The three ways the wid claim fails. All three are 401 with the message in the body: +// the token is the caller's, and so is the mistake. They are sentinels rather than +// strings so SSOHandoff can tell them from a database error, which is a 500. +var ( + errSSOWIDRequired = errors.New("wid is required") + errSSOUnknownWID = errors.New("wid does not name a workspace") + errSSONoPublicHost = errors.New("wid names a workspace with no public host") +) + +// SSOHandoff handles GET /v1/auth/sso?token=[&next=/path] — the signed session +// hand-off. An external identity system that has already authenticated a person hands +// them into a Calnode session, without a second login. +// +// The token is a compact HS256 JWT signed with a secret the two systems share +// (CALNODE_SSO_SHARED_SECRET); the endpoint is off unless that is set. It is verified +// here rather than by a JWT library for the same reason internal/livekit signs its own: +// one algorithm, one key, a fixed claim set, and no dependency to keep current. Only +// HS256 is accepted — "none" and every asymmetric alg are refused before the signature +// is looked at, which is the classic JWT downgrade. +// +// Two properties do the security work, and neither is optional: +// +// - The token is short-lived (exp at most 60s after iat) so a captured URL is not a +// standing credential. 30s of clock skew is allowed in both directions, because the +// two systems are separate hosts and NTP is not a guarantee. +// - The token is single-use: its jti is claimed in sso_nonces before the session is +// created, so a replay inside the validity window loses on the primary key. +// +// This is the ONE path that creates a user without an invite (see ssoResolveUser). +// Everywhere else an unknown email is refused; the shared secret is what makes this +// different — the caller is the operator's own identity system, not an arbitrary +// visitor with a Google account. +// +// Success is a 302 into the admin app (or ?next=, when that is a same-origin absolute +// path). Every failure is a 401 with a JSON body naming the claim that failed, so an +// operator wiring this up can tell a clock problem from a wrong audience — the body +// never carries the secret, the signature, or the token. +func (h *Handler) SSOHandoff(w http.ResponseWriter, r *http.Request) { + if h.ssoSecret == "" { + // Off unless CALNODE_SSO_SHARED_SECRET is set, and 404 rather than 501 so an + // instance that has not configured SSO is indistinguishable from one that does + // not implement it. Nothing is disclosed to a prober either way. + h.writeError(w, http.StatusNotFound, "not found") + return + } + + token := r.URL.Query().Get("token") + if token == "" { + h.writeError(w, http.StatusUnauthorized, "token is required") + return + } + + claims, err := verifySSOToken(token, h.ssoSecret) + if err != nil { + h.logger.WarnContext(r.Context(), "sso: token rejected", "reason", err.Error()) + h.writeError(w, http.StatusUnauthorized, err.Error()) + return + } + + // The workspace comes from the token, after the signature and before anything is + // written. Resolved here rather than in Scoped because this route is Platform: no + // tenant Host, no credential yet, so the `wid` claim is the only source (D11). + ws, err := h.ssoWorkspace(r.Context(), r, claims) + switch { + case errors.Is(err, errWorkspaceSuspended), errors.Is(err, errUnknownHost), errors.Is(err, errWorkspaceMismatch): + // 503, 404 and 403 respectively, from the one place that maps a resolution failure + // to a response. A mismatch is 403 {"error":"workspace mismatch"} (D10), the same + // answer an API key from another workspace gets on this host. + h.writeResolveError(w, r, err) + return + case errors.Is(err, errSSOWIDRequired), errors.Is(err, errSSOUnknownWID), errors.Is(err, errSSONoPublicHost): + h.logger.WarnContext(r.Context(), "sso: workspace rejected", "reason", err.Error(), "iss", claims.Iss) + h.writeError(w, http.StatusUnauthorized, err.Error()) + return + case err != nil: + h.logger.ErrorContext(r.Context(), "sso: resolve workspace", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := claims.validate(h.ssoAudience(ws), time.Now()); err != nil { + h.logger.WarnContext(r.Context(), "sso: claims rejected", "reason", err.Error(), "iss", claims.Iss) + h.writeError(w, http.StatusUnauthorized, err.Error()) + return + } + + // Validated before the nonce is claimed: a bad ?next= is the caller's own bug, and + // burning the token over it would make the retry fail for a second, unrelated reason. + next, err := ssoNextPath(r.URL.Query().Get("next")) + if err != nil { + h.writeError(w, http.StatusBadRequest, err.Error()) + return + } + + // Claim the jti before anything is created. On a replay this is the statement that + // fails, and it fails on the primary key rather than on a read-then-write the second + // request could interleave with. + if _, err := h.db.ExecContext(r.Context(), + `INSERT INTO sso_nonces (jti, expires_at) VALUES (?, ?)`, + claims.JTI, time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339)); err != nil { + if db.IsUniqueViolation(err) { + h.logger.WarnContext(r.Context(), "sso: token replayed", "iss", claims.Iss) + h.writeError(w, http.StatusUnauthorized, "jti has already been used") + return + } + h.logger.ErrorContext(r.Context(), "sso: claim nonce", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + userID, created, err := h.ssoResolveUser(r.Context(), claims, ws.ID) + switch { + case errors.Is(err, errSSOArchived): + h.writeError(w, http.StatusUnauthorized, "account is archived") + return + case err != nil: + h.logger.ErrorContext(r.Context(), "sso: resolve user", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // The session names its workspace for the same reason the user row does: on a + // Platform route h.db binds nothing, and a session in the wrong workspace cannot + // be read or deleted by the bound handles that serve its owner's requests. + if err := h.createSessionIn(r.Context(), w, userID, ws.ID); err != nil { + h.logger.ErrorContext(r.Context(), "sso: create session", "error", err) + h.writeError(w, http.StatusInternalServerError, "internal error") + return + } + + h.logger.InfoContext(r.Context(), "sso: session handed off", + "iss", claims.Iss, "user_id", userID, "user_created", created, "workspace_id", ws.ID) + http.Redirect(w, r, next, http.StatusFound) +} + +// ssoWorkspace decides which workspace a hand-off lands in. +// +// Single-tenant: the one workspace, and `wid` is ignored (see the claim's comment). +// +// ⛔ Multi-tenant: the workspace of the REQUEST HOST, with the token's `wid` checked against +// it. Resolving from the host rather than from the token is what makes a stolen or +// misdirected token useless: this endpoint is reached at `https:///v1/auth/sso` +// precisely so the session cookie lands on the tenant's own domain, so a token for workspace +// A presented on workspace B's host must be REFUSED (403) rather than quietly creating A's +// session on B's domain. Trusting `wid` alone would do exactly that. +func (h *Handler) ssoWorkspace(ctx context.Context, r *http.Request, claims ssoClaims) (*Workspace, error) { + if !h.multiTenant { + return DefaultWorkspace, nil + } + ws, err := h.workspaceByHost(ctx, r.Host) + if err != nil { + return nil, err + } + if ws.Suspended() { + return nil, fmt.Errorf("%w: %s", errWorkspaceSuspended, ws.ID) + } + if ws.PublicHost == "" { + return nil, errSSONoPublicHost + } + wid := strings.TrimSpace(claims.WID) + if wid == "" { + return nil, errSSOWIDRequired + } + if wid != ws.ID { + return nil, fmt.Errorf("%w: token names %s, host %s is %s", errWorkspaceMismatch, wid, ws.PublicHost, ws.ID) + } + return ws, nil +} + +// ssoAudience is the audience a token must name to be spendable here. +// +// Single-tenant: BASE_URL, unchanged — one host, one instance, and binding the token +// to it is what stops a token minted for staging being spent on production when the +// two share a secret by mistake. +// +// Multi-tenant: the WORKSPACE's own public host (D11). The hand-off exists precisely +// because the identity host cannot set a cookie for a tenant's domain, so the token is +// minted for that domain — and a token for tenant A must not be spendable on tenant +// B's, which would seat a session on B for a person A vouched for. Exact match, since +// the minting side is ours. +func (h *Handler) ssoAudience(ws *Workspace) string { + if h.multiTenant && ws != nil && ws.PublicHost != "" { + return "https://" + ws.PublicHost + } + return h.baseURL +} + +// errSSOArchived reports that the token named a real but offboarded user. Archived +// means no login by every other path (see §6), and a shared secret does not change that. +var errSSOArchived = errors.New("sso: user is archived") + +// ssoResolveUser maps the token's sub to a user id in workspaceID, creating the user +// when the email is unknown there. It reports whether the user was created. +// +// ⛔ Every statement names workspaceID, and that is not decoration. /v1/auth/sso is a +// Platform route, so h.db is the platform handle: it bypasses the policies, and it +// binds ” so a column left unnamed does not default to this tenant. Since D9 the +// unique on users is (workspace_id, email), which means the same address legitimately +// exists in several workspaces — an unqualified `WHERE email = ?` would resolve an +// arbitrary one of them and hand this token a session on somebody else's tenant. It is +// the same hole reset-admin had, reachable here by anyone holding the shared secret. +// +// Role handling is deliberately asymmetric. On creation the claim's role is applied, so +// the identity system provisions people directly. On an existing user the role is left +// alone — a workspace's roles are the workspace's business, and letting a hand-off +// rewrite them on every sign-in would make the admin UI's role controls advisory. The +// single exception is bootstrapping: a claim asking for owner is honoured when the +// workspace has no owner, because the one-owner invariant TransferOwnership maintains +// means there is nothing to displace. +func (h *Handler) ssoResolveUser(ctx context.Context, claims ssoClaims, workspaceID string) (userID string, created bool, err error) { + email := strings.ToLower(strings.TrimSpace(claims.Sub)) + + var archivedAt sql.NullString + err = h.db.QueryRowContext(ctx, + `SELECT id, archived_at FROM users WHERE workspace_id = ? AND email = ?`, + workspaceID, email).Scan(&userID, &archivedAt) + switch { + case err == sql.ErrNoRows: + // Unknown email: create. This is the one path that creates a user without an + // invite; it is reachable only by a caller holding the shared secret. + isAdmin, isOwner := 0, 0 + switch claims.Role { + case "owner": + isAdmin = 1 + if !h.ssoOwnerExists(ctx, workspaceID) { + isOwner = 1 + } + case "admin": + isAdmin = 1 + } + userID = uid.New() + if _, err := h.db.ExecContext(ctx, ` + INSERT INTO users (id, workspace_id, email, name, iana_timezone, is_admin, is_owner, email_login) + VALUES (?, ?, ?, ?, 'UTC', ?, ?, 0)`, + userID, workspaceID, email, claims.Name, isAdmin, isOwner); err != nil { + return "", false, err + } + return userID, true, nil + + case err != nil: + return "", false, err + } + + if archivedAt.Valid { + return "", false, errSSOArchived + } + if claims.Role == "owner" && !h.ssoOwnerExists(ctx, workspaceID) { + if _, err := h.db.ExecContext(ctx, + `UPDATE users SET is_owner = 1, is_admin = 1 WHERE workspace_id = ? AND id = ?`, + workspaceID, userID); err != nil { + return "", false, err + } + } + return userID, false, nil +} + +// ssoOwnerExists reports whether workspaceID already has an owner. A read error is +// reported as "yes" so a failed check never hands out ownership. +// +// Scoped for the same reason as the lookup above: counted across the instance, the +// first SSO user of every workspace after the first would silently never be its owner. +func (h *Handler) ssoOwnerExists(ctx context.Context, workspaceID string) bool { + var n int + if err := h.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE workspace_id = ? AND is_owner = 1 AND archived_at IS NULL`, + workspaceID).Scan(&n); err != nil { + h.logger.ErrorContext(ctx, "sso: count owners", "error", err) + return true + } + return n > 0 +} + +// ssoNextPath validates the optional ?next= target, returning the default when it is +// absent. Only a same-origin absolute path is allowed; anything else is refused rather +// than sanitised, because a redirect built from a partially-cleaned value is how open +// redirects survive their own fix. +func ssoNextPath(next string) (string, error) { + if next == "" { + return ssoDefaultNext, nil + } + switch { + case !strings.HasPrefix(next, "/"): + return "", errors.New("next must be an absolute path") + case strings.HasPrefix(next, "//"): + // Protocol-relative: "//evil.example" is another origin, not a local path. + return "", errors.New("next must not start with //") + case strings.Contains(next, `\`): + // Some browsers normalise a backslash to a slash, so "/\evil.example" is a + // protocol-relative URL wearing a disguise. + return "", errors.New(`next must not contain a backslash`) + case strings.Contains(next, "://"): + return "", errors.New("next must not contain a scheme") + } + for _, c := range next { + if c < 0x20 || c == 0x7f { + // A CR or LF would split the Location header; the rest are never legitimate + // in a path either. + return "", errors.New("next must not contain control characters") + } + } + return next, nil +} + +// verifySSOToken parses a compact JWS and verifies its HS256 signature with secret, +// returning the claims. The error names what was wrong in terms the operator can act +// on, and never echoes any part of the token back — an error body is attacker-reachable. +func verifySSOToken(token, secret string) (ssoClaims, error) { + var claims ssoClaims + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return claims, errors.New("token is not a three-part JWT") + } + + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return claims, errors.New("token header is not base64url") + } + var header struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(headerJSON, &header); err != nil { + return claims, errors.New("token header is not JSON") + } + // Checked before the signature: accepting the token's own choice of algorithm is the + // JWT downgrade attack ("none", or an RS256 key confusion), and the message stays + // fixed rather than quoting the value back. + if header.Alg != "HS256" { + return claims, errors.New("token alg must be HS256") + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return claims, errors.New("token signature is not base64url") + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(parts[0] + "." + parts[1])) + if !hmac.Equal(sig, mac.Sum(nil)) { // constant time + return claims, errors.New("token signature does not verify") + } + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return claims, errors.New("token payload is not base64url") + } + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return claims, errors.New("token payload is not JSON") + } + return claims, nil +} + +// validate checks the claim set against this instance and the current time. aud is the +// instance's BASE_URL: binding the token to one audience is what stops a token minted +// for a staging instance being spent on production, when both share a secret by mistake. +func (c ssoClaims) validate(aud string, now time.Time) error { + if c.Iss == "" { + return errors.New("iss is required") + } + if c.Aud == "" || c.Aud != aud { + return errors.New("aud does not match this instance") + } + // A full RFC 5322 parse is not the point; the value is looked up against a column + // whose contents are addresses, so this only rejects the obviously-not-an-address. + if s := strings.TrimSpace(c.Sub); s == "" || !strings.Contains(s, "@") { + return errors.New("sub must be an email address") + } + if strings.TrimSpace(c.Name) == "" { + return errors.New("name is required") + } + switch c.Role { + case "owner", "admin", "member": + default: + return errors.New("role must be owner, admin or member") + } + if c.JTI == "" { + return errors.New("jti is required") + } + if c.Iat == 0 { + return errors.New("iat is required") + } + if c.Exp == 0 { + return errors.New("exp is required") + } + + iat, exp := time.Unix(c.Iat, 0), time.Unix(c.Exp, 0) + if !exp.After(iat) { + return errors.New("exp must be after iat") + } + if exp.Sub(iat) > ssoMaxTokenLifetime { + return errors.New("exp is more than 60s after iat") + } + if iat.After(now.Add(ssoClockSkew)) { + return errors.New("iat is in the future") + } + if exp.Before(now.Add(-ssoClockSkew)) { + return errors.New("exp is in the past") + } + return nil +} diff --git a/internal/handler/sso_tenancy_test.go b/internal/handler/sso_tenancy_test.go new file mode 100644 index 0000000..9f0acba --- /dev/null +++ b/internal/handler/sso_tenancy_test.go @@ -0,0 +1,421 @@ +package handler_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/handler" +) + +// The SSO hand-off in multi-tenant mode (D11). +// +// ⛔ The endpoint is reached at https:///v1/auth/sso, because the cookie has to +// land on the tenant's own domain — that is the entire reason the hand-off exists. So the +// workspace is resolved from the request HOST and the token's `wid` is CHECKED against it: a +// token for workspace A presented on B's host is refused, rather than quietly creating A's +// session on B's domain. +// +// It stays Platform-wrapped rather than Scoped because the handler needs the platform handle +// to write the user and session with an explicit workspace_id — the tenant does not exist as +// far as a bound handle is concerned until those rows do. + +const ( + ssoHostA = "book.acme.example" + ssoHostB = "book.globex.example" +) + +// newSSOPairHandler returns a multi-tenant handler over a real OpenPair, the platform handle +// the assertions read through, and the platform-wrapped route as server.New registers it. +func newSSOPairHandler(t *testing.T) (*db.DB, http.HandlerFunc) { + t.Helper() + app, platform := dbtest.RequireTenantPair(t) + + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL(ssoBaseURL) // the identity host + h.SetSSOSecret(ssoSecret) + + seedSSOWorkspace(t, platform, "acme", ssoHostA, "active") + seedSSOWorkspace(t, platform, "globex", ssoHostB, "active") + + return platform, h.Platform((*handler.Handler).SSOHandoff) +} + +func seedSSOWorkspace(t *testing.T, platform *db.DB, id, host, status string) { + t.Helper() + if _, err := platform.Exec( + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', ?)`, + id, id, host, status); err != nil { + t.Fatalf("seed workspace %s: %v", id, err) + } +} + +// ssoTenantClaims is a valid claim set for one workspace: wid names it and aud is its own +// public host, which is where the token is spent. +func ssoTenantClaims(wid, host string) map[string]any { + c := ssoClaimSet() + c["wid"] = wid + c["aud"] = "https://" + host + return c +} + +// doTenantSSO spends a token AT a host, which is the part that matters here. +func doTenantSSO(t *testing.T, route http.HandlerFunc, host string, claims map[string]any) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, + "/v1/auth/sso?token="+ssoToken(t, ssoSecret, claims), nil) + req.Host = host + rec := httptest.NewRecorder() + route(rec, req) + return rec +} + +// The load-bearing case. ONE email is handed to BOTH workspaces, which since D9 is +// legitimate — the unique on users is (workspace_id, email). Two users must exist, one per +// workspace, and each session must belong to its own. +// +// Unscoped, the second hand-off finds the FIRST workspace's user by email and mints a session +// for it: workspace B's visitor signs in as workspace A's person. The sessions row would even +// satisfy its foreign key, because sessions.user_id is global. +func TestSSOHandoff_multiTenantLandsInTheTokensWorkspace(t *testing.T) { + platform, route := newSSOPairHandler(t) + + const shared = "shared@example.test" + claimsA := ssoTenantClaims("acme", ssoHostA) + claimsA["sub"] = shared + claimsB := ssoTenantClaims("globex", ssoHostB) + claimsB["sub"] = shared + + recA := doTenantSSO(t, route, ssoHostA, claimsA) + if recA.Code != http.StatusFound { + t.Fatalf("A: status = %d; want 302 — %s", recA.Code, recA.Body.String()) + } + recB := doTenantSSO(t, route, ssoHostB, claimsB) + if recB.Code != http.StatusFound { + t.Fatalf("B: status = %d; want 302 — %s", recB.Code, recB.Body.String()) + } + + userA := userIDInWorkspace(t, platform, "acme", shared) + userB := userIDInWorkspace(t, platform, "globex", shared) + if userA == userB { + t.Fatalf("both hand-offs resolved the same user %q; the second workspace was served "+ + "the first workspace's person", userA) + } + + assertSession(t, platform, sessionCookieValue(t, recA), "acme", userA) + assertSession(t, platform, sessionCookieValue(t, recB), "globex", userB) +} + +// ⛔ A token for A presented on B's host: 403, and nothing created. This is the case +// host-based resolution exists for — with the workspace taken from `wid` alone, both the +// audience and the wid would check out and A's session would be created on B's domain. +func TestSSOHandoff_tokenForAnotherWorkspaceIsRefusedOnThisHost(t *testing.T) { + platform, route := newSSOPairHandler(t) + + rec := doTenantSSO(t, route, ssoHostB, ssoTenantClaims("acme", ssoHostA)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != "workspace mismatch" { + t.Errorf("error = %q; want \"workspace mismatch\" (D10's body)", got) + } + + for _, table := range []string{"users", "sessions", "sso_nonces"} { + var n int + q := `SELECT COUNT(*) FROM ` + table + if table != "sso_nonces" { + q += ` WHERE workspace_id IN ('acme', 'globex')` + } + if err := platform.QueryRow(q).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + if n != 0 { + t.Errorf("%s has %d rows; a refused hand-off must create nothing — not even a "+ + "spent nonce, since the token was never usable here", table, n) + } + } +} + +func TestSSOHandoff_multiTenantRefusesABadWID(t *testing.T) { + cases := map[string]struct { + mutate func(map[string]any) + status int + want string + }{ + "missing": {func(c map[string]any) { delete(c, "wid") }, http.StatusUnauthorized, "wid is required"}, + "empty": {func(c map[string]any) { c["wid"] = "" }, http.StatusUnauthorized, "wid is required"}, + // A wid that names another workspace, or nothing at all, is the same answer: it does + // not match the host this token was spent at. + "unknown": {func(c map[string]any) { c["wid"] = "nosuchtenant" }, http.StatusForbidden, "workspace mismatch"}, + "not an id": {func(c map[string]any) { c["wid"] = "Not An Id!" }, http.StatusForbidden, "workspace mismatch"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + platform, route := newSSOPairHandler(t) + claims := ssoTenantClaims("acme", ssoHostA) + tc.mutate(claims) + + rec := doTenantSSO(t, route, ssoHostA, claims) + if rec.Code != tc.status { + t.Fatalf("status = %d; want %d — %s", rec.Code, tc.status, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != tc.want { + t.Errorf("error = %q; want %q", got, tc.want) + } + var users int + if err := platform.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + t.Fatalf("count users: %v", err) + } + if users != 0 { + t.Errorf("users = %d; a refused hand-off must create nobody", users) + } + }) + } +} + +// The audience is the host the token is spent at, with no trailing slash. A token whose aud +// names the identity host is refused even on the right tenant host: the identity host cannot +// set the cookie this hand-off exists to set. +func TestSSOHandoff_multiTenantAudienceIsThePublicHost(t *testing.T) { + cases := map[string]string{ + "the identity host": ssoBaseURL, + "a bare hostname": ssoHostA, + "a trailing slash": "https://" + ssoHostA + "/", + } + for name, aud := range cases { + t.Run(name, func(t *testing.T) { + _, route := newSSOPairHandler(t) + claims := ssoTenantClaims("acme", ssoHostA) + claims["aud"] = aud + + rec := doTenantSSO(t, route, ssoHostA, claims) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != "aud does not match this instance" { + t.Errorf("error = %q; want the audience to be named", got) + } + }) + } +} + +// A replayed token loses on the nonce table's primary key, before a second session exists. +func TestSSOHandoff_multiTenantReplayIsRefused(t *testing.T) { + platform, route := newSSOPairHandler(t) + claims := ssoTenantClaims("acme", ssoHostA) + + if rec := doTenantSSO(t, route, ssoHostA, claims); rec.Code != http.StatusFound { + t.Fatalf("first: %d — %s", rec.Code, rec.Body.String()) + } + rec := doTenantSSO(t, route, ssoHostA, claims) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("replay: status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != "jti has already been used" { + t.Errorf("error = %q; want the jti to be named", got) + } + + var sessions int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM sessions WHERE workspace_id = 'acme'`).Scan(&sessions); err != nil { + t.Fatalf("count sessions: %v", err) + } + if sessions != 1 { + t.Errorf("sessions = %d; want 1 — a replay must not mint a second", sessions) + } +} + +// An unrecognised host is a 404 before anything else happens: no tenant, nowhere to land. +func TestSSOHandoff_unknownHostIs404(t *testing.T) { + _, route := newSSOPairHandler(t) + rec := doTenantSSO(t, route, "nobody.example", ssoTenantClaims("acme", ssoHostA)) + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d; want 404 on a host that names no workspace", rec.Code) + } +} + +// A suspended workspace answers 503 with Retry-After on its own surfaces (D12), and a +// hand-off into one is the same answer: it lands in /admin/, which is suspended too. +func TestSSOHandoff_multiTenantRefusesASuspendedWorkspace(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + seedSSOWorkspace(t, platform, "acme", ssoHostA, "suspended") + + rec := doTenantSSO(t, h.Platform((*handler.Handler).SSOHandoff), ssoHostA, + ssoTenantClaims("acme", ssoHostA)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d; want 503 — %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("no Retry-After header on the 503") + } +} + +// The mint half: the OAuth callback hands off to the workspace's public host instead of +// setting a cookie on the identity host, and the token it mints is one this endpoint accepts. +// +// The two halves are exercised together on purpose — a mint that produced a token the +// verifier rejects would pass any test that only looked at the redirect. +func TestOAuthHandoff_callbackMintsATokenTheSSOEndpointAccepts(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + seedSSOWorkspace(t, platform, "acme", ssoHostA, "active") + seedSSOWorkspace(t, platform, "globex", ssoHostB, "active") + + // The person exists in acme. The same address in globex is what makes an unscoped lookup + // wrong rather than merely untidy. + if _, err := platform.Exec(` + INSERT INTO users (id, workspace_id, email, name, iana_timezone, is_admin, is_owner) + VALUES ('acme-user', 'acme', 'both@example.test', 'Acme Person', 'UTC', 1, 1), + ('globex-user', 'globex', 'both@example.test', 'Globex Person', 'UTC', 1, 1)`); err != nil { + t.Fatalf("seed users: %v", err) + } + + target := handler.FinishOAuthLoginForTest(h, "both@example.test", "acme") + if target == "" { + t.Fatal("the callback set a cookie instead of handing off; multi-tenant must redirect") + } + if !strings.HasPrefix(target, "https://"+ssoHostA+"/v1/auth/sso?token=") { + t.Fatalf("hand-off target = %q; want the workspace's public host and /v1/auth/sso", target) + } + + // Spend the minted token at that host, through the real endpoint. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, target[len("https://"+ssoHostA):], nil) + req.Host = ssoHostA + h.Platform((*handler.Handler).SSOHandoff)(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("spending the minted token: status = %d; want 302 — %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/" { + t.Errorf("Location = %q; want /admin/", loc) + } + assertSession(t, platform, sessionCookieValue(t, rec), "acme", "acme-user") + + // ⛔ And globex's identically-addressed user got no session. Before D11 the callback's + // lookup was `WHERE email = ?` on the platform handle, which resolves an arbitrary one of + // these two rows. + var globexSessions int + if err := platform.QueryRow( + `SELECT COUNT(*) FROM sessions WHERE workspace_id = 'globex'`).Scan(&globexSessions); err != nil { + t.Fatalf("count globex sessions: %v", err) + } + if globexSessions != 0 { + t.Errorf("globex has %d sessions; the login was for acme", globexSessions) + } +} + +// A minted token is bound to the workspace that started the login: spending acme's hand-off +// on globex's host is the same 403 as any other cross-workspace token. +func TestOAuthHandoff_mintedTokenIsRefusedOnAnotherWorkspacesHost(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + seedSSOWorkspace(t, platform, "acme", ssoHostA, "active") + seedSSOWorkspace(t, platform, "globex", ssoHostB, "active") + if _, err := platform.Exec(` + INSERT INTO users (id, workspace_id, email, name, iana_timezone, is_admin, is_owner) + VALUES ('acme-user', 'acme', 'person@example.test', 'Acme Person', 'UTC', 1, 1)`); err != nil { + t.Fatalf("seed user: %v", err) + } + + target := handler.FinishOAuthLoginForTest(h, "person@example.test", "acme") + if target == "" { + t.Fatal("no hand-off target") + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, target[len("https://"+ssoHostA):], nil) + req.Host = ssoHostB // the wrong tenant's host + h.Platform((*handler.Handler).SSOHandoff)(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d; want 403 — %s", rec.Code, rec.Body.String()) + } + var sessions int + if err := platform.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&sessions); err != nil { + t.Fatalf("count sessions: %v", err) + } + if sessions != 0 { + t.Errorf("sessions = %d; want 0", sessions) + } +} + +// Without CALNODE_SSO_SHARED_SECRET a multi-tenant OAuth login has nowhere to land, so it +// refuses rather than setting a cookie on the identity host — which would produce a session +// the person's own admin UI cannot see. +func TestOAuthHandoff_refusesWithoutTheSharedSecret(t *testing.T) { + app, platform := dbtest.RequireTenantPair(t) + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL(ssoBaseURL) + // no SetSSOSecret + seedSSOWorkspace(t, platform, "acme", ssoHostA, "active") + if _, err := platform.Exec(` + INSERT INTO users (id, workspace_id, email, name, iana_timezone, is_admin, is_owner) + VALUES ('acme-user', 'acme', 'person@example.test', 'Acme Person', 'UTC', 1, 1)`); err != nil { + t.Fatalf("seed user: %v", err) + } + + target := handler.FinishOAuthLoginForTest(h, "person@example.test", "acme") + if !strings.Contains(target, "error=sso") { + t.Errorf("redirect = %q; want an error=sso refusal", target) + } + var sessions int + if err := platform.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&sessions); err != nil { + t.Fatalf("count sessions: %v", err) + } + if sessions != 0 { + t.Errorf("sessions = %d; want 0 — a login that cannot hand off must not half-succeed", sessions) + } +} + +func userIDInWorkspace(t *testing.T, platform *db.DB, workspaceID, email string) string { + t.Helper() + var id string + if err := platform.QueryRow( + `SELECT id FROM users WHERE workspace_id = ? AND email = ?`, workspaceID, email).Scan(&id); err != nil { + t.Fatalf("no user %s in workspace %s: %v", email, workspaceID, err) + } + return id +} + +func sessionCookieValue(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == "calnode_session" { + return c.Value + } + } + t.Fatal("no calnode_session cookie was set") + return "" +} + +func assertSession(t *testing.T, platform *db.DB, sessionID, wantWorkspace, wantUser string) { + t.Helper() + var gotWorkspace, gotUser string + if err := platform.QueryRow( + `SELECT workspace_id, user_id FROM sessions WHERE id = ?`, sessionID).Scan(&gotWorkspace, &gotUser); err != nil { + t.Fatalf("session %s: %v", sessionID, err) + } + if gotWorkspace != wantWorkspace { + t.Errorf("session workspace = %q; want %q", gotWorkspace, wantWorkspace) + } + if gotUser != wantUser { + t.Errorf("session user = %q; want %q", gotUser, wantUser) + } +} diff --git a/internal/handler/sso_test.go b/internal/handler/sso_test.go new file mode 100644 index 0000000..b458dfb --- /dev/null +++ b/internal/handler/sso_test.go @@ -0,0 +1,358 @@ +package handler_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/uid" +) + +const ( + ssoSecret = "shared-secret-for-tests" + ssoBaseURL = "https://cal.example.test" +) + +// newSSOHandler returns a handler with the hand-off configured and its audience pinned. +func newSSOHandler(t *testing.T) (*handler.Handler, *db.DB) { + t.Helper() + h, database := newTestHandlerDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + return h, database +} + +// ssoToken mints a compact HS256 JWT the way an external identity system would. Kept +// independent of internal/handler's verifier on purpose: a test that signs with the +// production code would pass even if both halves were wrong in the same way. +func ssoToken(t *testing.T, secret string, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + signingInput := enc(map[string]string{"alg": "HS256", "typ": "JWT"}) + "." + enc(claims) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingInput)) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// ssoClaimSet is a valid claim set; cases mutate the one field they are about. +func ssoClaimSet() map[string]any { + now := time.Now().Unix() + return map[string]any{ + "iss": "identity.example.test", + "aud": ssoBaseURL, + "sub": "handed.over@example.test", + "name": "Handed Over", + "role": "member", + "iat": now, + "exp": now + 30, + "jti": uid.New(), + } +} + +func doSSO(h *handler.Handler, target string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.SSOHandoff(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec +} + +func ssoErrorBody(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode error body %q: %v", rec.Body.String(), err) + } + return body.Error +} + +func TestSSOHandoff_createsUserAndSession(t *testing.T) { + h, database := newSSOHandler(t) + + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d; want 302 — %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/" { + t.Errorf("Location = %q; want /admin/", loc) + } + + var userID string + var isAdmin, isOwner int + if err := database.QueryRow( + `SELECT id, is_admin, is_owner FROM users WHERE email = ?`, + "handed.over@example.test").Scan(&userID, &isAdmin, &isOwner); err != nil { + t.Fatalf("user was not created: %v", err) + } + if isAdmin != 0 || isOwner != 0 { + t.Errorf("role flags = admin %d owner %d; want 0 0 for a member claim", isAdmin, isOwner) + } + + // The session cookie must name a live session row for that user. + var cookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == "calnode_session" { + cookie = c + } + } + if cookie == nil { + t.Fatal("no calnode_session cookie was set") + } + var sessionUser string + if err := database.QueryRow( + `SELECT user_id FROM sessions WHERE id = ?`, cookie.Value).Scan(&sessionUser); err != nil { + t.Fatalf("session row: %v", err) + } + if sessionUser != userID { + t.Errorf("session belongs to %q; want %q", sessionUser, userID) + } +} + +// A claim asking for owner bootstraps ownership only when nobody holds it. The +// asymmetry is the point: see ssoResolveUser. +func TestSSOHandoff_ownerClaimBootstrapsOnlyWhenUnowned(t *testing.T) { + h, database := newSSOHandler(t) + + first := ssoClaimSet() + first["sub"] = "first.owner@example.test" + first["role"] = "owner" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, first)); rec.Code != http.StatusFound { + t.Fatalf("first hand-off: status = %d — %s", rec.Code, rec.Body.String()) + } + + second := ssoClaimSet() + second["sub"] = "second.owner@example.test" + second["role"] = "owner" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, second)); rec.Code != http.StatusFound { + t.Fatalf("second hand-off: status = %d — %s", rec.Code, rec.Body.String()) + } + + var owners int + if err := database.QueryRow(`SELECT COUNT(*) FROM users WHERE is_owner = 1`).Scan(&owners); err != nil { + t.Fatalf("count owners: %v", err) + } + if owners != 1 { + t.Errorf("owners = %d; want exactly 1", owners) + } + var isAdmin, isOwner int + if err := database.QueryRow(`SELECT is_admin, is_owner FROM users WHERE email = ?`, + "second.owner@example.test").Scan(&isAdmin, &isOwner); err != nil { + t.Fatalf("second user: %v", err) + } + if isOwner != 0 || isAdmin != 1 { + t.Errorf("second user = admin %d owner %d; want admin 1 owner 0", isAdmin, isOwner) + } +} + +// An existing user's role is not rewritten by a hand-off (owner-bootstrap aside). +func TestSSOHandoff_doesNotDemoteAnExistingUser(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + + var email string + if err := database.QueryRow(`SELECT email FROM users WHERE id = ?`, ownerID).Scan(&email); err != nil { + t.Fatalf("load owner email: %v", err) + } + + claims := ssoClaimSet() + claims["sub"] = email + claims["role"] = "member" + if rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, claims)); rec.Code != http.StatusFound { + t.Fatalf("status = %d — %s", rec.Code, rec.Body.String()) + } + + var isAdmin, isOwner int + if err := database.QueryRow(`SELECT is_admin, is_owner FROM users WHERE id = ?`, ownerID). + Scan(&isAdmin, &isOwner); err != nil { + t.Fatalf("reload owner: %v", err) + } + if isAdmin != 1 || isOwner != 1 { + t.Errorf("owner = admin %d owner %d; want 1 1 (a member claim must not demote)", isAdmin, isOwner) + } +} + +func TestSSOHandoff_replayedJTIIsRejected(t *testing.T) { + h, _ := newSSOHandler(t) + token := ssoToken(t, ssoSecret, ssoClaimSet()) + + if rec := doSSO(h, "/v1/auth/sso?token="+token); rec.Code != http.StatusFound { + t.Fatalf("first use: status = %d — %s", rec.Code, rec.Body.String()) + } + rec := doSSO(h, "/v1/auth/sso?token="+token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("replay: status = %d; want 401", rec.Code) + } + if got := ssoErrorBody(t, rec); got != "jti has already been used" { + t.Errorf("error = %q; want the jti to be named", got) + } +} + +func TestSSOHandoff_rejectsBadTokens(t *testing.T) { + valid := ssoClaimSet() + + expired := ssoClaimSet() + expired["iat"] = time.Now().Add(-10 * time.Minute).Unix() + expired["exp"] = time.Now().Add(-10 * time.Minute).Add(30 * time.Second).Unix() + + longLived := ssoClaimSet() + longLived["exp"] = longLived["iat"].(int64) + 3600 + + wrongAud := ssoClaimSet() + wrongAud["aud"] = "https://other.example.test" + + noIss := ssoClaimSet() + noIss["iss"] = "" + + badRole := ssoClaimSet() + badRole["role"] = "superuser" + + cases := []struct { + name string + token string + wantError string + }{ + {"expired", ssoToken(t, ssoSecret, expired), "exp is in the past"}, + {"lifetime too long", ssoToken(t, ssoSecret, longLived), "exp is more than 60s after iat"}, + {"wrong aud", ssoToken(t, ssoSecret, wrongAud), "aud does not match this instance"}, + {"missing iss", ssoToken(t, ssoSecret, noIss), "iss is required"}, + {"bad role", ssoToken(t, ssoSecret, badRole), "role must be owner, admin or member"}, + {"bad signature", ssoToken(t, "not-the-shared-secret", valid), "token signature does not verify"}, + {"not a jwt", "nonsense", "token is not a three-part JWT"}, + {"no token", "", "token is required"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, database := newSSOHandler(t) + rec := doSSO(h, "/v1/auth/sso?token="+tc.token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != tc.wantError { + t.Errorf("error = %q; want %q", got, tc.wantError) + } + var users int + if err := database.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + t.Fatalf("count users: %v", err) + } + if users != 0 { + t.Errorf("users = %d; a rejected token must not create anyone", users) + } + }) + } +} + +// An "alg": "none" token with an empty signature is the classic downgrade. It must be +// refused on the algorithm, before the signature is even compared. +func TestSSOHandoff_rejectsAlgNone(t *testing.T) { + h, _ := newSSOHandler(t) + enc := func(v any) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + token := enc(map[string]string{"alg": "none", "typ": "JWT"}) + "." + enc(ssoClaimSet()) + "." + + rec := doSSO(h, "/v1/auth/sso?token="+token) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401", rec.Code) + } + if got := ssoErrorBody(t, rec); got != "token alg must be HS256" { + t.Errorf("error = %q; want the alg to be named", got) + } +} + +func TestSSOHandoff_nextMustBeSameOriginPath(t *testing.T) { + cases := []struct { + name string + next string + }{ + {"absolute url", "https://evil.example.test/"}, + {"protocol relative", "//evil.example.test/"}, + {"backslash", `/\evil.example.test`}, + {"scheme inside", "/redirect?to=https://evil.example.test"}, + {"not a path", "admin/"}, + {"header injection", "/admin/\r\nX-Injected: 1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h, database := newSSOHandler(t) + token := ssoToken(t, ssoSecret, ssoClaimSet()) + rec := doSSO(h, "/v1/auth/sso?token="+token+"&next="+url.QueryEscape(tc.next)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400 — %s", rec.Code, rec.Body.String()) + } + // The token was refused before its nonce was claimed, so the caller can + // retry the same one with a sane next. + var nonces int + if err := database.QueryRow(`SELECT COUNT(*) FROM sso_nonces`).Scan(&nonces); err != nil { + t.Fatalf("count nonces: %v", err) + } + if nonces != 0 { + t.Errorf("nonces = %d; a bad next must not burn the token", nonces) + } + }) + } +} + +func TestSSOHandoff_nextHonoursALocalPath(t *testing.T) { + h, _ := newSSOHandler(t) + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())+"&next=%2Fadmin%2Fbookings") + if rec.Code != http.StatusFound { + t.Fatalf("status = %d; want 302 — %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/bookings" { + t.Errorf("Location = %q; want /admin/bookings", loc) + } +} + +// Unset CALNODE_SSO_SHARED_SECRET ⇒ 404, indistinguishable from a build without the +// feature. Documented in DEPLOY.md and ARCHITECTURE.md §6. +func TestSSOHandoff_disabledWithoutASecret(t *testing.T) { + h, _ := newTestHandlerDB(t) + h.SetBaseURL(ssoBaseURL) + + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, ssoClaimSet())) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d; want 404 — %s", rec.Code, rec.Body.String()) + } +} + +func TestSSOHandoff_archivedUserCannotSignIn(t *testing.T) { + h, database, _, ownerID := setupWorkspaceWithDB(t) + h.SetBaseURL(ssoBaseURL) + h.SetSSOSecret(ssoSecret) + + var email string + if err := database.QueryRow(`SELECT email FROM users WHERE id = ?`, ownerID).Scan(&email); err != nil { + t.Fatalf("load owner email: %v", err) + } + if _, err := database.Exec(`UPDATE users SET archived_at = ? WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), ownerID); err != nil { + t.Fatalf("archive user: %v", err) + } + + claims := ssoClaimSet() + claims["sub"] = email + rec := doSSO(h, "/v1/auth/sso?token="+ssoToken(t, ssoSecret, claims)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d; want 401 — %s", rec.Code, rec.Body.String()) + } + if got := ssoErrorBody(t, rec); got != "account is archived" { + t.Errorf("error = %q; want the archive to be named", got) + } +} diff --git a/internal/handler/storage_settings.go b/internal/handler/storage_settings.go index 9254e6f..e636ced 100644 --- a/internal/handler/storage_settings.go +++ b/internal/handler/storage_settings.go @@ -5,6 +5,8 @@ import ( "net/http" "os" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // The Storage settings page surfaces both object-storage uses in one place: the Litestream DB @@ -54,7 +56,7 @@ func (h *Handler) PatchStorageSettings(w http.ResponseWriter, r *http.Request) { v = 1 } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET recordings_enabled = ?, updated_at = datetime('now') WHERE id = 1`, v); err != nil { + `UPDATE server_settings SET recordings_enabled = ?, updated_at = ? WHERE id = 1`, v, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "storage settings: update", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/stripe_booking.go b/internal/handler/stripe_booking.go index 06292f7..184569b 100644 --- a/internal/handler/stripe_booking.go +++ b/internal/handler/stripe_booking.go @@ -45,16 +45,38 @@ func (h *Handler) startBookingCheckout(ctx context.Context, sc *stripe.Client, b // StripeWebhook handles POST /v1/stripe/webhook — Stripe's payment notifications. Public, but // authenticated by the signing secret (no session cookie, so CSRF middleware doesn't apply). func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) { - sc := h.getStripe() - if sc == nil || !sc.WebhookConfigured() { - h.writeError(w, http.StatusServiceUnavailable, "payments not configured") - return - } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { h.writeError(w, http.StatusBadRequest, "could not read body") return } + + // ⛔ Resolve the tenant from the booking the session belongs to, then verify with THAT + // workspace's signing secret. The secret is in server_settings, i.e. per workspace, and + // this Platform-wrapped route's handle bypasses the policies — so "the" settings row is + // an arbitrary tenant's, and a signature checked against it proves nothing. Single-tenant + // keeps the original order (verify, then act): one workspace, one secret, nothing to + // resolve. The argument in full, including why an unverified resolve is safe here, is in + // internal/handler/vendor_webhook.go. + scoped := h + if h.multiTenant { + sessionID, metaBooking := peekStripeIDs(body) + resolved, ok := h.stripeEventWorkspace(r.Context(), sessionID, metaBooking) + if !ok { + // No booking of ours owns this session. 200, because Stripe retries a 4xx for + // days and no retry can make the row exist. + h.logger.InfoContext(r.Context(), "stripe webhook: event for no known workspace", + "session_id", sessionID, "metadata_booking_id", metaBooking) + w.WriteHeader(http.StatusOK) + return + } + scoped = resolved + } + sc := scoped.getStripe() + if sc == nil || !sc.WebhookConfigured() { + h.writeError(w, http.StatusServiceUnavailable, "payments not configured") + return + } ev, err := sc.VerifyWebhook(body, r.Header.Get("Stripe-Signature"), time.Now()) if err != nil { // Bad signature → 400 so Stripe surfaces the failure (and a forged call is rejected). @@ -63,6 +85,9 @@ func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) { return } + // Every write below is the resolved workspace's. ⚠️ These two calls deliberately use + // context.Background(): they outlive the request, and the scoped handler is safe to carry + // into them because a workspace handle pins no connection (D5). switch ev.Type { case "checkout.session.completed": sess, perr := ev.Session() @@ -71,13 +96,13 @@ func (h *Handler) StripeWebhook(w http.ResponseWriter, r *http.Request) { } if sess.PaymentStatus == "paid" { if id := sess.Metadata["booking_id"]; id != "" { - h.confirmPaidBooking(context.Background(), id, sess.PaymentIntent, int(sess.AmountTotal), sess.Currency) + scoped.confirmPaidBooking(context.Background(), id, sess.PaymentIntent, int(sess.AmountTotal), sess.Currency) } } case "checkout.session.expired": if sess, perr := ev.Session(); perr == nil { if id := sess.Metadata["booking_id"]; id != "" { - h.releaseUnpaidHold(context.Background(), id) + scoped.releaseUnpaidHold(context.Background(), id) } } } diff --git a/internal/handler/stripe_locale_internal_test.go b/internal/handler/stripe_locale_internal_test.go index efb4b64..633b9ac 100644 --- a/internal/handler/stripe_locale_internal_test.go +++ b/internal/handler/stripe_locale_internal_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/calnode/calnode/internal/booking" - "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" "github.com/calnode/calnode/internal/mailer" ) @@ -65,14 +65,7 @@ func (c *captureMailer) recipients() []string { // of the language the attendee actually booked in — even though the free-booking path (same // dispatchBookingConfirmation) got this right. See stripe_booking.go's SELECT. func TestConfirmPaidBooking_usesAttendeeLocale(t *testing.T) { - database, err := db.Open("sqlite://:memory:") - if err != nil { - t.Fatalf("db.Open: %v", err) - } - defer database.Close() - if err := db.Migrate(database); err != nil { - t.Fatalf("db.Migrate: %v", err) - } + database := dbtest.Open(t) h := New(database, slog.Default()) cap := &captureMailer{} diff --git a/internal/handler/stripe_settings.go b/internal/handler/stripe_settings.go index 6c65e35..2931f35 100644 --- a/internal/handler/stripe_settings.go +++ b/internal/handler/stripe_settings.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtime" "github.com/calnode/calnode/internal/secret" "github.com/calnode/calnode/internal/stripe" ) @@ -19,7 +21,7 @@ type StripeConfig struct { // LoadStripeSettingsFromDB reads Stripe credentials from server_settings and decrypts the // secret key + webhook secret. Returns nil (not an error) when the secret key is unset. -func LoadStripeSettingsFromDB(db *sql.DB, encKey [32]byte) (*StripeConfig, error) { +func LoadStripeSettingsFromDB(db *db.DB, encKey [32]byte) (*StripeConfig, error) { var secretEnc, pubKey, whEnc string err := db.QueryRow(` SELECT stripe_secret_key_enc, stripe_publishable_key, stripe_webhook_secret_enc @@ -97,8 +99,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE server_settings SET stripe_secret_key_enc = '', stripe_publishable_key = '', stripe_webhook_secret_enc = '', - updated_at = datetime('now') - WHERE id = 1`); err != nil { + updated_at = ? + WHERE id = 1`, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: clear", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -111,8 +113,8 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { // Update publishable key (plain — it's a public value) when provided. if req.PublishableKey != nil { if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = datetime('now') WHERE id = 1`, - *req.PublishableKey); err != nil { + `UPDATE server_settings SET stripe_publishable_key = ?, updated_at = ? WHERE id = 1`, + *req.PublishableKey, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update pub key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -127,7 +129,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_secret_key_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update secret key", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return @@ -141,7 +143,7 @@ func (h *Handler) PatchStripeSettings(w http.ResponseWriter, r *http.Request) { return } if _, err := h.db.ExecContext(r.Context(), - `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = datetime('now') WHERE id = 1`, enc); err != nil { + `UPDATE server_settings SET stripe_webhook_secret_enc = ?, updated_at = ? WHERE id = 1`, enc, dbtime.Now()); err != nil { h.logger.ErrorContext(r.Context(), "stripe settings: update webhook secret", "error", err) h.writeError(w, http.StatusInternalServerError, "internal error") return diff --git a/internal/handler/teams.go b/internal/handler/teams.go index ff32173..c096625 100644 --- a/internal/handler/teams.go +++ b/internal/handler/teams.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/calnode/calnode/internal/db" "github.com/calnode/calnode/internal/uid" ) @@ -86,7 +87,7 @@ func (h *Handler) CreateTeam(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), `INSERT INTO teams (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, id, req.Name, slug, now); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -222,7 +223,7 @@ func (h *Handler) PatchTeam(w http.ResponseWriter, r *http.Request) { res, err := h.db.ExecContext(r.Context(), "UPDATE teams SET "+strings.Join(sets, ", ")+" WHERE id = ?", args...) // #nosec G202 -- sets is built above from hardcoded "col = ?" literals only; every value is bound via args... if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "a team with that slug already exists") return } @@ -306,7 +307,7 @@ func (h *Handler) AddTeamMember(w http.ResponseWriter, r *http.Request) { INSERT INTO team_members (id, team_id, user_id, role, routing_priority) VALUES (?, ?, ?, 'member', ?)`, uid.New(), teamID, req.UserID, req.RoutingPriority); err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + if db.IsUniqueViolation(err) { h.writeError(w, http.StatusConflict, "that user is already in this team") return } diff --git a/internal/handler/tenant_settings.go b/internal/handler/tenant_settings.go new file mode 100644 index 0000000..a4f2dd6 --- /dev/null +++ b/internal/handler/tenant_settings.go @@ -0,0 +1,89 @@ +package handler + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "github.com/calnode/calnode/internal/db" +) + +// tenantSettings holds the two server_settings columns that exist only for +// multi-tenant deployments (migration 00062): the origins allowed to embed this +// workspace's booking page, and the speech-to-text host its notetaker uses. +// +// Both were written by the platform API from the day the columns existed and read +// by nothing, so every tenant shared the process-wide EMBED_ALLOWED_ORIGINS and +// STT_BASE_URL. That is a real isolation gap for the CORS allowlist (tenant A's +// embed origins were honoured on tenant B's booking page) and a routing gap for +// STT (an EU tenant's recordings went to whichever host the process was booted +// with). These readers close both. +// +// Single-tenant mode does not consult either column: the process-wide values keep +// their meaning exactly, and the columns are ” there anyway because only the +// platform API writes them. +type tenantSettings struct { + embedOrigins []string + sttBaseURL string +} + +// loadTenantSettings reads the two columns through a handle bound to the +// workspace, so the row is that workspace's own. A missing row (a workspace the +// platform API did not create, or a test that seeded no settings) is the same as +// empty values, not an error: empty is the documented default for both. +func loadTenantSettings(d *db.DB) (tenantSettings, error) { + var origins, stt string + err := d.QueryRow(`SELECT embed_allowed_origins, stt_base_url FROM server_settings WHERE id = 1`). + Scan(&origins, &stt) + if errors.Is(err, sql.ErrNoRows) { + return tenantSettings{}, nil + } + if err != nil { + return tenantSettings{}, err + } + var list []string + for _, o := range strings.Split(origins, ",") { + if o = strings.TrimSpace(o); o != "" { + list = append(list, o) + } + } + return tenantSettings{embedOrigins: list, sttBaseURL: strings.TrimSpace(stt)}, nil +} + +// tenantSettings returns this workspace's per-tenant settings, cached per +// workspace like the vendor clients. A read error is logged and answers as +// empty, because both consumers have a safe empty: the CORS check falls back to +// "no origin is allowed" (see EmbedOriginsFor) and the notetaker falls back to +// the process default host. +func (h *Handler) tenantSettings() tenantSettings { + return h.settingsCache.get(h.cacheKey(), func() tenantSettings { + s, err := loadTenantSettings(h.db) + if err != nil { + h.logger.Warn("settings: could not load per-tenant settings", "workspace", h.cacheKey(), "error", err) + return tenantSettings{} + } + return s + }) +} + +// EmbedOriginsFor is the multi-tenant origin resolver for the public CORS +// middleware: the workspace is the one whose public host the request names, and +// the allowlist is that workspace's own. +// +// The middleware runs BEFORE the route's own host resolution, so this is a second +// lookup of the same host on the platform handle; it is one indexed read and the +// route's 404 for an unknown host is unchanged. For an unknown host the resolver +// answers known=false, which the middleware turns into NO Access-Control-Allow-Origin +// at all — never `*`, because "no workspace" must not read as "any origin". +// +// An EMPTY per-tenant list keeps the single-tenant meaning: any origin. That is +// what an operator who never set the field expects, and it is what the platform +// API stores when the caller passes none. +func (h *Handler) EmbedOriginsFor(r *http.Request) ([]string, bool) { + ws, err := h.workspaceByHost(r.Context(), r.Host) + if err != nil { + return nil, false + } + return h.forWorkspace(ws).tenantSettings().embedOrigins, true +} diff --git a/internal/handler/tenant_settings_test.go b/internal/handler/tenant_settings_test.go new file mode 100644 index 0000000..952e5f7 --- /dev/null +++ b/internal/handler/tenant_settings_test.go @@ -0,0 +1,79 @@ +package handler_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/handler" + "github.com/calnode/calnode/internal/stt" +) + +// The two multi-tenant-only settings columns were written by the platform API and +// read by nothing (D7 follow-up). These pin the readers: each workspace gets its +// OWN embed allowlist and STT host, an empty column falls through exactly as the +// single-tenant ladder does, and an unknown host resolves to no tenant at all. +func TestTenantSettings_perWorkspaceReaders(t *testing.T) { + app, _ := dbtest.RequireTenantPair(t) + + h := handler.New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://cal.example.test") + h.SetPlatformToken(platformToken) + h.SetEncKey(platformTestEncKey) + h.SetSTTBaseURL("https://stt-process.example") + create := h.Platform((*handler.Handler).CreateWorkspace) + + const hostA, hostB = "book.acme.example", "book.globex.example" + + bodyA := platformCreateBody("acme", hostA) + bodyA["defaults"].(map[string]any)["embed_allowed_origins"] = []string{"https://embed-acme.example", "https://www.acme.example/"} + bodyA["defaults"].(map[string]any)["stt_base_url"] = "https://stt-eu.example" + if rec := doPlatform(t, create, http.MethodPost, "/v1/platform/workspaces", bodyA, platformToken); rec.Code != http.StatusCreated { + t.Fatalf("create acme: %d %s", rec.Code, rec.Body.String()) + } + bodyB := platformCreateBody("globex", hostB) + bodyB["defaults"].(map[string]any)["embed_allowed_origins"] = []string{} + delete(bodyB["defaults"].(map[string]any), "stt_base_url") + if rec := doPlatform(t, create, http.MethodPost, "/v1/platform/workspaces", bodyB, platformToken); rec.Code != http.StatusCreated { + t.Fatalf("create globex: %d %s", rec.Code, rec.Body.String()) + } + + // STT: A's own host wins; B, with the column empty, falls through to the + // process value, which is the rung single-tenant mode has always had. + if got := handler.STTBaseURLForWorkspaceForTest(h, "acme"); got != "https://stt-eu.example" { + t.Errorf("acme stt = %q; want its own column", got) + } + if got := handler.STTBaseURLForWorkspaceForTest(h, "globex"); got != "https://stt-process.example" { + t.Errorf("globex stt = %q; want the process value", got) + } + h.SetSTTBaseURL("") + // The cache holds the column, not the resolution, so clearing the process + // value is visible without a restart: B now lands on the provider default. + if got := handler.STTBaseURLForWorkspaceForTest(h, "globex"); got != stt.DefaultBaseURL { + t.Errorf("globex stt with no process value = %q; want %q", got, stt.DefaultBaseURL) + } + if got := handler.STTBaseURLForWorkspaceForTest(h, "acme"); got != "https://stt-eu.example" { + t.Errorf("acme stt after clearing the process value = %q; want its own column still", got) + } + + // Embed origins: resolved from the request HOST, and an unknown host is + // "no tenant", never "any origin". + originsFor := func(host string) ([]string, bool) { + r := httptest.NewRequest(http.MethodGet, "/v1/event-types/intro/public", nil) + r.Host = host + return h.EmbedOriginsFor(r) + } + if got, known := originsFor(hostA); !known || !reflect.DeepEqual(got, []string{"https://embed-acme.example", "https://www.acme.example/"}) { + t.Errorf("acme origins = %v, known=%v; want its own two", got, known) + } + if got, known := originsFor(hostB); !known || len(got) != 0 { + t.Errorf("globex origins = %v, known=%v; want known and empty (any origin)", got, known) + } + if got, known := originsFor("nobody.example"); known || got != nil { + t.Errorf("unknown host origins = %v, known=%v; want unknown", got, known) + } +} diff --git a/internal/handler/tenantcache.go b/internal/handler/tenantcache.go new file mode 100644 index 0000000..ae48789 --- /dev/null +++ b/internal/handler/tenantcache.go @@ -0,0 +1,93 @@ +package handler + +import "sync" + +// tenantCache holds one lazily-built value per workspace. +// +// It replaces the Set/get singleton pairs that used to sit on shared behind a +// mutex each. The shape is the same — build once, read many, replace on save — +// with a workspace id in front of it. +// +// ⛔ The key is "" in single-tenant mode, not the literal "default". That is +// deliberate: one entry, built the first time it is asked for, replaced by +// SetX exactly as before. A single-tenant instance therefore behaves +// identically and the map never holds more than one entry. +// +// present is separate from entries because nil is a MEANINGFUL value here: a +// workspace with no Stripe credentials caches a nil *stripe.Client, and that +// must not be mistaken for "not built yet" or every request would rebuild it. +type tenantCache[T any] struct { + mu sync.RWMutex + entries map[string]T + present map[string]bool +} + +func newTenantCache[T any]() *tenantCache[T] { + return &tenantCache[T]{entries: map[string]T{}, present: map[string]bool{}} +} + +// get returns the cached value for key, building it with build if absent. +// +// build runs WITHOUT the lock held, because every builder here reads +// server_settings — holding the write lock across a database round trip would +// serialise every tenant behind whichever one was slowest to build. The cost is +// that two concurrent first-requests for the same workspace may both build; the +// first store wins and the loser's value is discarded. Clients are stateless +// value wrappers, so that is waste and not a correctness problem. +func (c *tenantCache[T]) get(key string, build func() T) T { + c.mu.RLock() + if c.present[key] { + v := c.entries[key] + c.mu.RUnlock() + return v + } + c.mu.RUnlock() + + built := build() + + c.mu.Lock() + defer c.mu.Unlock() + if c.present[key] { + return c.entries[key] // another goroutine got there first; keep one + } + c.entries[key] = built + c.present[key] = true + return built +} + +// set replaces the value for key. The settings-save handlers call it after a +// successful write, which is both the invalidation and the rebuild. +func (c *tenantCache[T]) set(key string, v T) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[key] = v + c.present[key] = true +} + +// invalidate drops the value for key, so the next get rebuilds it from the +// database. For a save path that does not already hold the built client. +func (c *tenantCache[T]) invalidate(key string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.entries, key) + delete(c.present, key) +} + +// size reports how many workspaces have a cached value. For tests. +func (c *tenantCache[T]) size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.present) +} + +// cacheKey is the key every per-workspace cache is read and written under. +// +// ⛔ "" in single-tenant mode rather than the workspace id, so that the map holds +// exactly one entry and a single-tenant instance cannot be made to build a second +// one by anything that calls ForWorkspace. +func (h *Handler) cacheKey() string { + if !h.multiTenant { + return "" + } + return h.Workspace().ID +} diff --git a/internal/handler/tenantcache_test.go b/internal/handler/tenantcache_test.go new file mode 100644 index 0000000..5ea4c48 --- /dev/null +++ b/internal/handler/tenantcache_test.go @@ -0,0 +1,335 @@ +package handler + +import ( + "log/slog" + "sync" + "testing" + + "github.com/calnode/calnode/internal/db" + "github.com/calnode/calnode/internal/dbtest" + "github.com/calnode/calnode/internal/mailer" +) + +// Boundary 4: the hot per-tenant state. +// +// These run against ONE handle with multiTenant set, which is enough for what they +// assert: the cache key, the builder's choice of settings row, and invalidation. +// The reads go through the bound handle, and on a single handle that is the handle +// itself — so what is under test is the CACHE, not the row-level security, which +// internal/db and internal/server already prove. + +func newCacheHandler(t *testing.T, multiTenant bool) *Handler { + t.Helper() + database := dbtest.Open(t) + h := New(database, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(multiTenant) + h.SetBaseURL("https://app.calnode.test") + return h +} + +// newPairHandler is for the cases that have to tell two workspaces apart. +// +// ⛔ A single handle cannot: db.ForWorkspace is the identity function on one that +// did not come from OpenPair, so every "scoped" copy would read the same rows and +// the test would compare a value with itself. It needs the real pair, which needs +// PostgreSQL and a NOBYPASSRLS role — so these skip loudly on SQLite, and the +// pure-cache cases below do not. +func newPairHandler(t *testing.T) (*Handler, *db.DB) { + t.Helper() + app, platform := dbtest.RequireTenantPair(t) + h := New(app, slog.New(slog.DiscardHandler)) + h.SetMultiTenant(true) + h.SetBaseURL("https://app.calnode.test") + return h, platform +} + +// seedPairSettings writes the workspace and its settings row through the PLATFORM +// handle, naming workspace_id — the platform handle binds ”, so an omitted column +// would land both rows in the default workspace. +func seedPairSettings(t *testing.T, platform *db.DB, wsID, from string) { + t.Helper() + if _, err := platform.Exec( + `INSERT INTO workspaces (id, slug, public_host, region, status) VALUES (?, ?, ?, '', 'active')`, + wsID, wsID, wsID+".example.com"); err != nil { + t.Fatalf("seed workspace %s: %v", wsID, err) + } + if _, err := platform.Exec( + `INSERT INTO server_settings (workspace_id, id, smtp_host, smtp_port, email_from, email_from_name) + VALUES (?, 1, 'smtp.example.com', '587', ?, ?)`, + wsID, from, wsID); err != nil { + t.Fatalf("seed settings for %s: %v", wsID, err) + } +} + +// TestTenantCache_mailerIsPerWorkspace is the positive half: A's mailer carries +// A's From address and B's carries B's, built lazily from each workspace's own +// server_settings row. +func TestTenantCache_mailerIsPerWorkspace(t *testing.T) { + h, platform := newPairHandler(t) + seedPairSettings(t, platform, "acme", "bookings@acme.example") + seedPairSettings(t, platform, "globex", "hello@globex.example") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + + fromA := senderAddress(t, a.getMailer()) + fromB := senderAddress(t, b.getMailer()) + + if fromA != "bookings@acme.example" { + t.Errorf("A's mailer From = %q; want bookings@acme.example", fromA) + } + if fromB != "hello@globex.example" { + t.Errorf("B's mailer From = %q; want hello@globex.example", fromB) + } + if fromA == fromB { + t.Fatal("both workspaces got the same mailer") + } + + // Two entries, and the base handler's key is not one of them. + if got := h.mailerCache.size(); got != 2 { + t.Errorf("mailer cache holds %d entries; want 2", got) + } + // Reading again must not rebuild. + if again := senderAddress(t, a.getMailer()); again != fromA { + t.Errorf("second read of A's mailer = %q; want %q", again, fromA) + } + if got := h.mailerCache.size(); got != 2 { + t.Errorf("a repeat read grew the cache to %d entries", got) + } +} + +// TestTenantCache_keyIsWhatSeparatesThem is the negative control, kept in the tree +// rather than run by hand: with the key forced to "" the two workspaces share one +// entry, and whichever built first decides what the other sends. +// +// It reaches into the cache directly with the same builder the accessor uses, +// because cacheKey itself is what is under test. +func TestTenantCache_keyIsWhatSeparatesThem(t *testing.T) { + h, platform := newPairHandler(t) + seedPairSettings(t, platform, "acme", "bookings@acme.example") + seedPairSettings(t, platform, "globex", "hello@globex.example") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + + build := func(scoped *Handler) func() mailer.Mailer { + return func() mailer.Mailer { + cfg, err := LoadEmailSettingsFromDB(scoped.db, scoped.encKey) + if err != nil || cfg == nil { + return &mailer.Noop{} + } + m, _ := BuildMailer(*cfg) + return m + } + } + + // The real keys keep them apart. + realA := senderAddress(t, h.mailerCache.get(a.cacheKey(), build(a))) + realB := senderAddress(t, h.mailerCache.get(b.cacheKey(), build(b))) + if realA == realB { + t.Fatalf("with real keys both workspaces got %q", realA) + } + + // The stubbed key collapses them. + collapsed := newTenantCache[mailer.Mailer]() + stubbedA := senderAddress(t, collapsed.get("", build(a))) + stubbedB := senderAddress(t, collapsed.get("", build(b))) + if stubbedA != stubbedB { + t.Fatalf("a shared key should have collided: A got %q, B got %q", stubbedA, stubbedB) + } + if stubbedB != realA { + t.Errorf("with the key stubbed to \"\", B sends as %q — A's address is %q", stubbedB, realA) + } + t.Logf("key stubbed to \"\": B would send as %q instead of %q", stubbedB, realB) +} + +// TestTenantCache_saveInvalidatesOnlyThatWorkspace. +func TestTenantCache_saveInvalidatesOnlyThatWorkspace(t *testing.T) { + h, platform := newPairHandler(t) + seedPairSettings(t, platform, "acme", "bookings@acme.example") + seedPairSettings(t, platform, "globex", "hello@globex.example") + + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + + _ = a.getMailer() + beforeB := senderAddress(t, b.getMailer()) + + // A saves new settings, the way the settings handler does: write the row, then + // drop the cached client. + if _, err := a.db.Exec( + `UPDATE server_settings SET email_from = ? WHERE id = 1`, "new@acme.example"); err != nil { + t.Fatalf("update A's settings: %v", err) + } + h.mailerCache.invalidate(a.cacheKey()) + + if got := senderAddress(t, a.getMailer()); got != "new@acme.example" { + t.Errorf("after A saved, A's mailer From = %q; want new@acme.example", got) + } + if got := senderAddress(t, b.getMailer()); got != beforeB { + t.Errorf("A's save changed B's mailer to %q; want %q", got, beforeB) + } +} + +// TestTenantCache_singleTenantKeepsOneEntry is the byte-identical promise: with +// MULTI_TENANT unset the key is "" whatever workspace a handle claims, so the map +// cannot grow past one and SetX behaves exactly as the old singleton did. +func TestTenantCache_singleTenantKeepsOneEntry(t *testing.T) { + h := newCacheHandler(t, false) + + if got := h.cacheKey(); got != "" { + t.Errorf("single-tenant cacheKey() = %q; want empty", got) + } + h.SetLLM(nil) + h.SetStripe(nil) + h.SetZoom(nil) + h.SetLiveKit(nil) + + // forWorkspace is the identity function here, so it cannot introduce a key. + scoped := h.forWorkspace(&Workspace{ID: "acme"}) + if got := scoped.cacheKey(); got != "" { + t.Errorf("a scoped handle in single-tenant mode has key %q", got) + } + for name, size := range map[string]int{ + "llm": h.llmCache.size(), "stripe": h.stripeCache.size(), + "zoom": h.zoomCache.size(), "livekit": h.livekitCache.size(), + } { + if size != 1 { + t.Errorf("%s cache holds %d entries in single-tenant mode; want 1", name, size) + } + } + // And a nil client stays nil rather than being rebuilt on every read: nil is a + // meaningful cached value, not "absent". + if h.getStripe() != nil { + t.Error("getStripe rebuilt over an explicitly-set nil") + } + if got := h.stripeCache.size(); got != 1 { + t.Errorf("a read after SetStripe(nil) grew the cache to %d", got) + } +} + +// TestTenantCache_concurrentWorkspaces is the race case: two workspaces hitting the +// same cache at once. Run the package with -race for it to mean anything. +func TestTenantCache_concurrentWorkspaces(t *testing.T) { + h, platform := newPairHandler(t) + seedPairSettings(t, platform, "acme", "bookings@acme.example") + seedPairSettings(t, platform, "globex", "hello@globex.example") + + want := map[string]string{ + "acme": "bookings@acme.example", + "globex": "hello@globex.example", + } + + var wg sync.WaitGroup + errs := make(chan string, 64) + for i := 0; i < 16; i++ { + for ws, from := range want { + wg.Add(1) + go func(ws, from string) { + defer wg.Done() + scoped := h.forWorkspace(&Workspace{ID: ws, Status: "active"}) + got := senderAddressNoFatal(scoped.getMailer()) + if got != from { + errs <- ws + " got " + got + ", want " + from + } + // Interleave a write, so the cache is read and replaced at once. + if ws == "globex" { + h.mailerCache.invalidate(scoped.cacheKey()) + } + }(ws, from) + } + } + wg.Wait() + close(errs) + for e := range errs { + t.Error(e) + } +} + +// TestTenantCache_getBuildsOnceUnderContention pins the "first store wins" +// behaviour that lets the builder run outside the lock. +func TestTenantCache_getBuildsOnceUnderContention(t *testing.T) { + c := newTenantCache[int]() + var mu sync.Mutex + builds := 0 + + var wg sync.WaitGroup + results := make([]int, 32) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + results[i] = c.get("ws", func() int { + mu.Lock() + builds++ + n := builds + mu.Unlock() + return n + }) + }(i) + } + wg.Wait() + + // Every caller must see the SAME value, even if more than one builder ran. + for i, got := range results { + if got != results[0] { + t.Fatalf("caller %d got %d, caller 0 got %d — the cache handed out two values", i, got, results[0]) + } + } + if c.size() != 1 { + t.Errorf("cache holds %d entries for one key", c.size()) + } + t.Logf("%d concurrent gets ran %d builders and returned one value", len(results), builds) +} + +// TestTenantCache_llmAndStripeBuildFromTheirOwnRow covers the other two kinds +// with the same shape, so the mailer is not the only one proven. +func TestTenantCache_llmAndStripeBuildFromTheirOwnRow(t *testing.T) { + h, platform := newPairHandler(t) + seedPairSettings(t, platform, "acme", "bookings@acme.example") + seedPairSettings(t, platform, "globex", "hello@globex.example") + + // Only A enables the LLM layer. + a := h.forWorkspace(&Workspace{ID: "acme", Status: "active"}) + b := h.forWorkspace(&Workspace{ID: "globex", Status: "active"}) + if _, err := a.db.Exec( + `UPDATE server_settings SET llm_enabled = 1, llm_endpoint = ?, llm_model = ? WHERE id = 1`, + "https://llm.acme.example/v1", "acme-model"); err != nil { + t.Fatalf("enable A's LLM: %v", err) + } + + if a.getLLM() == nil { + t.Error("A enabled the LLM layer and got no client") + } + if b.getLLM() != nil { + t.Error("B did not enable the LLM layer and got a client") + } + + // Neither has Stripe credentials, so both cache nil — and cache it, rather than + // rebuilding on every read. + if a.getStripe() != nil || b.getStripe() != nil { + t.Error("a workspace with no Stripe credentials got a client") + } + if got := h.stripeCache.size(); got != 2 { + t.Errorf("stripe cache holds %d entries; want one nil per workspace", got) + } +} + +// senderAddress reads the From address out of whatever transport BuildMailer +// chose, which is the only per-workspace value visible from outside. +func senderAddress(t *testing.T, m mailer.Mailer) string { + t.Helper() + got := senderAddressNoFatal(m) + if got == "" { + t.Fatalf("mailer %T exposes no From address", m) + } + return got +} + +func senderAddressNoFatal(m mailer.Mailer) string { + type fromer interface{ From() string } + if f, ok := m.(fromer); ok { + return f.From() + } + return "" +} diff --git a/internal/handler/test_email.go b/internal/handler/test_email.go index 269d843..06af26f 100644 --- a/internal/handler/test_email.go +++ b/internal/handler/test_email.go @@ -111,7 +111,7 @@ func (h *Handler) SendTestEmail(w http.ResponseWriter, r *http.Request) { h.writeError(w, http.StatusInternalServerError, "internal error") return } - if err := h.mailer.Send(r.Context(), mailer.Message{ + if err := h.getMailer().Send(r.Context(), mailer.Message{ To: []string{user.Email}, Subject: "[TEST] " + subject, Text: body, diff --git a/internal/handler/tracking_settings.go b/internal/handler/tracking_settings.go index 9a29f77..87a3e9a 100644 --- a/internal/handler/tracking_settings.go +++ b/internal/handler/tracking_settings.go @@ -6,6 +6,8 @@ import ( "net/http" "regexp" "strings" + + "github.com/calnode/calnode/internal/dbtime" ) // Google tag ID formats. The ID is interpolated into a