From da8dfaca16932b4cc220c1aeb7ac16e066e69099 Mon Sep 17 00:00:00 2001 From: Ikerlaforga <19539979+Isonimus@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:56:16 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20organizations=20layer=20=E2=80=94=20ADR?= =?UTF-8?q?-0005,=20ROADMAP,=20OpenAPI,=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0005: organizations are the tenant boundary; owner re-scoped from global to org-scoped; 404-not-403 on cross-org; per-org 2FA; the nullable->backfill->NOT NULL rollout; deferred public signup. Indexed in the ADR README. - ROADMAP: flip the multi-tenancy section from planned to SHIPPED (migrations 013-014, PRs #69-#72), keeping public signup + metering/billing + org-mgmt UI as explicit follow-ups. - OpenAPI: note that all /admin responses are org-scoped (cross-org -> 404). Lints clean. - CHANGELOG: Internal entry for the organizations layer. --- CHANGELOG.md | 1 + ROADMAP.md | 43 ++++++------ docs/adr/0005-organizations-and-tenancy.md | 79 ++++++++++++++++++++++ docs/adr/README.md | 1 + docs/openapi.yaml | 5 +- 5 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 docs/adr/0005-organizations-and-tenancy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe614c..618158b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ the first consumer-visible behaviour change and will drive the next SDK version - **Automation-detector wording**: the anti-tamper flag now reads "Anti-tamper signals" (not "Automation detected") when the combined confidence is weak — e.g. devtools open in a dev environment — so a human isn't labelled a bot. The machine-readable `code` (`automation_suspected`) is unchanged; the reason text also drops the `tamper.` prefix for readability. ### Internal +- **Organizations (multi-tenant) layer** ([ADR-0005](docs/adr/0005-organizations-and-tenancy.md), migrations 013–014): a new `organizations` table is now the tenant boundary above `projects`. The admin `owner` role is **re-scoped from a global superuser to org-scoped** — `canViewProject`/`canManageProject`, the `/admin/*` listing queries, and `requireProjectRead` filter by `organization_id`, and a cross-org project/user id returns `404` (no existence leak). 2FA policy moved from the install-wide `admin_settings.require_2fa` to per-org `organizations.require_2fa`; invites carry the inviter's org so an accepted account joins that company. Migration 013 backfills a single `Default` org for existing installs, so **self-host is unchanged** (one auto-created org); the `NOT NULL` FK backstop lands in migration 014 once every writer is org-aware. `create-admin`/`create-project` take an optional `[orgName]` (shared `findOrCreateOrgByName`). `organization_id` stays off the `/v1` data path (a project key already scopes data) — orgs are an admin/billing concern, the foundational prerequisite for hosted metering/billing. Public self-serve signup is deferred to the billing workstream. - **Docs: GDPR & consent** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): new [GDPR & consent integration guide](docs/integrations/gdpr-consent.md) (controller/processor split, CMP wiring per mode, lawful-basis guidance, data-subject rights, DPA stub); OpenAPI updated with the snapshot consent fields, the `LawfulBasis` schema, and the `DELETE`/`export` identity paths. - **Retention sweeper** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): a daily BullMQ repeatable job in the worker deletes identities (and, by cascade, their snapshots/drifts/risk/links) whose `last_seen` is older than their project's `retention_days`; projects with a null `retention_days` keep data indefinitely. `sweepRetention()` is idempotent and unit-tested against a real DB. - **Data-subject endpoints** ([ADR-0004](docs/adr/0004-consent-and-data-lifecycle.md)): `DELETE /v1/identity/:id` (GDPR Art. 17 — erases the identity; snapshots/drifts/risk assessments/account links cascade) and `GET /v1/identity/:id/export` (Art. 20 — the full bundle, including each snapshot's consent provenance). Erasure is strictly key-gated (a non-GET never reaches the route via an admin session); export is readable by key or admin session like the other reads. diff --git a/ROADMAP.md b/ROADMAP.md index e2adc68..788c93f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -668,29 +668,30 @@ Docs — PR #66: --- -## Multi-tenancy: organizations layer — planned (prerequisite for hosted Phase 7) +## Multi-tenancy: organizations layer — SHIPPED ([ADR-0005], migrations 013–014, PRs #69–#72) -**Problem.** The data model today is **single-organization, multi-project**: there is no -company/tenant entity above `projects`, and the admin `owner` role is a **global -superuser** — `canViewProject`/`canManageProject` return true for *any* owner on *any* +**Problem.** The data model was **single-organization, multi-project**: there was no +company/tenant entity above `projects`, and the admin `owner` role was a **global +superuser** — `canViewProject`/`canManageProject` returned true for *any* owner on *any* project (`admin/authz.ts`). That's exactly right for **self-hosting** (one deployment = -one operator; projects = that operator's apps/environments). It is **unsafe for a hosted +one operator; projects = that operator's apps/environments). It was **unsafe for a hosted SaaS** with multiple paying customers on one deployment: Company A's owner could read Company B's identities, there's no tenant root to meter/bill per customer, and there's no -"sign up → get a workspace" onboarding. So the hosted box is effectively single-tenant -until this lands — fine for the first design partner, blocking before the second. - -**Decision (to validate).** Add an `organizations` (tenant) layer and make it the unit -of isolation, scoping, and billing: -- [ ] `organizations` table (id, name, plan, created_at; billing fields later). Migration backfills a single default org and assigns all existing admins/projects to it — a no-op for self-host (one org, auto-created). -- [ ] `projects.organization_id` and `admin_users.organization_id` FKs (every project and admin belongs to exactly one org). -- [ ] **Re-scope `owner` from global → org-scoped**: `isOwner`/`canViewProject`/`canManageProject` and the project-list/`requireProjectRead` queries gain an org check — an owner sees only their org's projects. (A separate platform/superadmin concept, if ever needed for Tindalabs ops, stays out of the customer RBAC.) -- [ ] Signup/onboarding: creating an account provisions an org + its first owner; invites are org-scoped (extends the existing invite flow). -- [ ] Anchor usage metering + Stripe (Phase 7) on `organizations`, not projects. - -**Why it composes:** mirrors how migration 009 backfilled `role` for existing admins — +"sign up → get a workspace" onboarding. So the hosted box was effectively single-tenant. + +**Decision (shipped).** Added an `organizations` (tenant) layer and made it the unit of +isolation; metering/billing will anchor on it: +- [x] `organizations` table (id, name, slug, `require_2fa`, created_at; billing fields later). Migration 013 backfills a single `Default` org and assigns all existing admins/projects to it — a no-op for self-host (one org, auto-created). +- [x] `projects.organization_id`, `admin_users.organization_id`, `admin_invites.organization_id` FKs (every project and admin belongs to exactly one org; `NOT NULL` backstop in migration 014). +- [x] **Re-scoped `owner` from global → org-scoped**: `canViewProject`/`canManageProject` (new `projectInOrg`), the `/admin/*` listing queries, and `requireProjectRead` gained an org filter — an owner sees only its org's projects; cross-org ids return 404 (no existence leak). (A platform/superadmin concept for Tindalabs ops stays out of the customer RBAC by design.) +- [x] Per-org 2FA policy (`organizations.require_2fa`) supersedes the install-wide toggle; invites carry the inviter's org so an accepted account joins that company. +- [x] Org-aware provisioning via the bootstrap CLIs (`create-admin`/`create-project` take an optional `[orgName]`, shared `findOrCreateOrgByName`). +- [ ] **Deferred to the billing workstream:** public self-serve signup (`POST /admin/signup`) — coupled to free-tier limits + Stripe. +- [ ] **Deferred:** anchor usage metering + Stripe (Phase 7) on `organizations`; Observatory org-management UI (org name/settings, switcher). + +**Why it composed:** mirrors how migration 009 backfilled `role` for existing admins — self-host stays a single auto-created org, so no behaviour change there; the hosted tier -gains the company boundary it needs. This is the **foundational prerequisite** for the -hosted free tier + metering/billing already on the backlog, and should land before a -second customer's data shares the box. Relates to [ADR-0004] data-isolation guarantees -and the BSL "Tindalabs-hosted only" commercial model. +gains the company boundary it needs. This was the **foundational prerequisite** for the +hosted free tier + metering/billing on the backlog, landing before a second customer's +data shares the box. Relates to [ADR-0005] / [ADR-0004] data-isolation guarantees and the +BSL "Tindalabs-hosted only" commercial model. diff --git a/docs/adr/0005-organizations-and-tenancy.md b/docs/adr/0005-organizations-and-tenancy.md new file mode 100644 index 0000000..31eef81 --- /dev/null +++ b/docs/adr/0005-organizations-and-tenancy.md @@ -0,0 +1,79 @@ +# ADR-0005: Organizations are the tenant boundary; owner is org-scoped, not global + +**Status:** Accepted +**Date:** 2026-06-20 + +## Context + +Scent's data model was **single-organization, multi-project**: every data row is scoped by +`project_id` (with `ON DELETE CASCADE`), but there was no company/tenant entity *above* +projects, and the admin `owner` role was a **global superuser** — `isOwner` short-circuited +`canViewProject`/`canManageProject` to `true` for any owner on any project (`admin/authz.ts`), +and the `/admin/*` listing queries read across the whole install. + +That is exactly right for **self-hosting**: one deployment = one operator, and "projects" +are that operator's own apps/environments. It is **unsafe for a hosted SaaS** with multiple +paying customers on one box (which is what `api.scent.tindalabs.dev` is): + +1. **No company boundary** — Company A's owner could read Company B's identities. For an + identity/fraud product that is a non-starter. +2. **No billing root** — Phase 7 usage metering → Stripe needs a customer entity to meter + and invoice; projects don't group into a billable company. +3. **No onboarding seam** — "a company signs up → gets a workspace → creates projects" has + nowhere to hang. + +So the hosted box was effectively single-tenant: fine for the first design partner, blocking +before the second. + +## Decision + +Introduce an **`organizations`** table as the tenant boundary — the unit of isolation today +and the anchor for metering/billing later — and **re-scope the `owner` role from global to +org-scoped**. An owner is a superuser *within its own organization only*. + +- `organizations(id, name, slug, require_2fa, created_at)`. `admin_users`, `projects`, and + `admin_invites` gain an `organization_id` FK. +- **Authz**: `canViewProject`/`canManageProject` gate the owner short-circuit on the project + belonging to the user's org (`projectInOrg`). Members reach only the projects granted in + `project_members` (same-org by construction). Every owner-scoped `/admin/*` query filters + by `organization_id`. +- **No existence leak**: a cross-org project or user id returns **404, not 403** — a tenant + can't even confirm another tenant's resources exist. +- **2FA policy is per-org** (`organizations.require_2fa`), superseding the install-wide + `admin_settings.require_2fa`, so one tenant's policy never affects another. +- **Invites carry the inviter's org**, so an accepted account joins that company. +- `organization_id` stays **off the `/v1` data hot path** — a project API key already + resolves to a `project_id` that fully scopes data. Organizations are an admin/billing + concern only. + +### Provisioning (this iteration) + +New orgs are provisioned via the bootstrap CLIs (`create-admin`/`create-project`, optional +`[orgName]`, default `Default`) using a shared idempotent `findOrCreateOrgByName`. **Public +self-serve signup is deliberately deferred** to the billing workstream — it is coupled to +free-tier limits and Stripe, and provisioning orgs without those guardrails invites abuse. + +## Why it composes (self-host unaffected) + +The rollout mirrors how migration 009 backfilled `role` for existing admins: + +- Migration **013** adds the entity + **nullable** FKs and backfills a single `Default` org, + assigning every existing admin and project to it (seeding `require_2fa` from the + install-wide setting). A single-org install behaves exactly as before — an owner still + sees all of *its* projects. +- Migration **014** enforces `organization_id NOT NULL` once every writer (admin routes, + both CLIs) is org-aware, first bucketing any stragglers into `Default`. + +The nullable→backfill→NOT NULL split lets the change ship incrementally with green CI at +each step, and keeps the constraint as a permanent backstop against a tenant-less row. + +## Consequences + +- The hosted box can safely host a second customer: isolation is enforced in code and at the + DB level. This is the **foundational prerequisite** for the hosted free tier + metering. +- Metering/Stripe (Phase 7) will anchor on `organizations`. +- **Deferred**: public signup; an Observatory org-management UI (org name/settings, switcher); + a Tindalabs-ops platform-superadmin concept (kept out of customer RBAC by design). + +Relates to [ADR-0004](0004-consent-and-data-lifecycle.md) (data-isolation guarantees) and the +BSL "Tindalabs-hosted only" commercial model. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6f386fe..3c874f2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,3 +8,4 @@ Each ADR documents a significant architectural choice: the context, the decision | [0002](0002-persistence-policies.md) | Persistence Policies as first-class config | Accepted | | [0003](0003-otel-bridge.md) | OTel traceparent bridge for blindspot-ux composability | Accepted | | [0004](0004-consent-and-data-lifecycle.md) | Consent is the controller's responsibility; the SDK enforces, never triggers | Accepted | +| [0005](0005-organizations-and-tenancy.md) | Organizations are the tenant boundary; owner is org-scoped, not global | Accepted | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4ecfef9..c09225e 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -9,7 +9,10 @@ info: - **/v1** — the data API consumed by the SDK and integrators, authenticated by a project API key (`X-Api-Key`). - **/admin** — the management API behind the Observatory, authenticated by an admin - session cookie; mutating routes also require a double-submit CSRF token. + session cookie; mutating routes also require a double-submit CSRF token. All + `/admin` responses are scoped to the caller's organization (the tenant boundary, + see ADR-0005): an owner sees only its own org's projects, users, and invites, and a + cross-organization id returns `404`. This file is the authoritative, machine-readable contract. `docs/api.md` is a prose companion. license: