Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 22 additions & 21 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
79 changes: 79 additions & 0 deletions docs/adr/0005-organizations-and-tenancy.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
5 changes: 4 additions & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading