diff --git a/packages/server/src/db/migrations/014_organizations_not_null.sql b/packages/server/src/db/migrations/014_organizations_not_null.sql new file mode 100644 index 0000000..08562ea --- /dev/null +++ b/packages/server/src/db/migrations/014_organizations_not_null.sql @@ -0,0 +1,27 @@ +-- NOT NULL backstop for the organization tenant FKs. Migration 013 added +-- organization_id as nullable so the backfill and the app writers could be rolled out +-- incrementally; now that every writer (admin routes, create-admin, create-project) sets +-- it, we close the gap at the DB level so a tenant-less project or admin can never exist. + +-- Catch any stragglers first: rows an older image may have created after 013 but before +-- the org-aware writers shipped. Bucket them into a 'Default' org (create it if needed) +-- so the constraint can be applied cleanly. In an in-order deploy there are none. +DO $$ +DECLARE + default_org UUID; +BEGIN + IF EXISTS (SELECT 1 FROM admin_users WHERE organization_id IS NULL) + OR EXISTS (SELECT 1 FROM projects WHERE organization_id IS NULL) THEN + SELECT id INTO default_org FROM organizations WHERE name = 'Default' LIMIT 1; + IF default_org IS NULL THEN + INSERT INTO organizations (name, slug) VALUES ('Default', 'default') + ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name + RETURNING id INTO default_org; + END IF; + UPDATE admin_users SET organization_id = default_org WHERE organization_id IS NULL; + UPDATE projects SET organization_id = default_org WHERE organization_id IS NULL; + END IF; +END $$; + +ALTER TABLE admin_users ALTER COLUMN organization_id SET NOT NULL; +ALTER TABLE projects ALTER COLUMN organization_id SET NOT NULL; diff --git a/packages/server/src/lib/organizations.ts b/packages/server/src/lib/organizations.ts new file mode 100644 index 0000000..0f3d357 --- /dev/null +++ b/packages/server/src/lib/organizations.ts @@ -0,0 +1,16 @@ +import { db } from '../db/client.js'; + +// Find an organization by name, or create it. Used by the bootstrap CLIs (create-admin, +// create-project) to attach the first admin/project to a tenant: a fresh self-host install +// has no org until the first bootstrap, so these scripts provision one. Multi-tenant hosts +// pass a distinct name per customer. Idempotent on name (re-running returns the same org). +export async function findOrCreateOrgByName(name: string): Promise { + const existing = await db<{ id: string }[]>` + SELECT id FROM organizations WHERE name = ${name} LIMIT 1 + `; + if (existing[0]) return existing[0].id; + const [org] = await db<{ id: string }[]>` + INSERT INTO organizations (name) VALUES (${name}) RETURNING id + `; + return org!.id; +} diff --git a/packages/server/src/pipeline/retention.integration.test.ts b/packages/server/src/pipeline/retention.integration.test.ts index 6402952..4361250 100644 --- a/packages/server/src/pipeline/retention.integration.test.ts +++ b/packages/server/src/pipeline/retention.integration.test.ts @@ -3,13 +3,16 @@ import { migrate } from '../db/migrate.js'; import { db } from '../db/client.js'; import { sweepRetention } from './retention.js'; import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Gated on DATABASE_URL like the other integration suites (runs in CI, skips locally // without a DB). See events.integration.test.ts for the rationale. const hasDb = Boolean(process.env['DATABASE_URL']); const API_KEY = 'retention-test-key'; +const ORG = 'Retention IT Org'; let projectId: string; // retention_days = 30 +let orgId: string; async function seedIdentity(project: string, ageDays: number): Promise { const id = `ret-${ageDays}d-${crypto.randomUUID()}`; @@ -24,9 +27,11 @@ beforeAll(async () => { if (!hasDb) return; await migrate(); await db`DELETE FROM projects WHERE api_key_hash IN (${hashApiKey(API_KEY)}, ${hashApiKey(`${API_KEY}-keep`)})`; + await deleteTestOrg(ORG); + orgId = await createTestOrg(ORG); const [proj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name, retention_days) - VALUES (${hashApiKey(API_KEY)}, 'Retention Test', 30) RETURNING id + INSERT INTO projects (api_key_hash, name, retention_days, organization_id) + VALUES (${hashApiKey(API_KEY)}, 'Retention Test', 30, ${orgId}) RETURNING id `; projectId = proj!.id; }); @@ -34,6 +39,7 @@ beforeAll(async () => { afterAll(async () => { if (!hasDb) return; await db`DELETE FROM projects WHERE api_key_hash IN (${hashApiKey(API_KEY)}, ${hashApiKey(`${API_KEY}-keep`)})`; + await deleteTestOrg(ORG); await db.end(); }); @@ -55,8 +61,8 @@ describe.skipIf(!hasDb)('sweepRetention', () => { it('skips projects with null retention_days (keep forever)', async () => { const [keepProj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) - VALUES (${hashApiKey(`${API_KEY}-keep`)}, 'Keep Forever') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) + VALUES (${hashApiKey(`${API_KEY}-keep`)}, 'Keep Forever', ${orgId}) RETURNING id `; const ancient = await seedIdentity(keepProj!.id, 999); diff --git a/packages/server/src/risk/assess.integration.test.ts b/packages/server/src/risk/assess.integration.test.ts index 2d26292..0cc2fe9 100644 --- a/packages/server/src/risk/assess.integration.test.ts +++ b/packages/server/src/risk/assess.integration.test.ts @@ -3,6 +3,7 @@ import { migrate } from '../db/migrate.js'; import { db } from '../db/client.js'; import { hashApiKey } from '../middleware/api-key.js'; import { assessRisk } from './assess.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Regression coverage for the risk_assessments.flags encoding bug: the insert used // `${JSON.stringify(flags)}::jsonb`, which postgres.js double-encodes into a JSON @@ -11,6 +12,7 @@ import { assessRisk } from './assess.js'; // ARRAY. Gated on DATABASE_URL. const hasDb = Boolean(process.env['DATABASE_URL']); const API_KEY = 'assess-integration-key'; +const ORG = 'Assess IT Org'; const ID = 'assessit-identity-1'; let projectId: string; @@ -20,8 +22,10 @@ beforeAll(async () => { if (!hasDb) return; await migrate(); await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); const [proj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Assess Integration') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Assess Integration', ${org}) RETURNING id `; projectId = proj!.id; await db` @@ -39,6 +43,7 @@ beforeAll(async () => { afterAll(async () => { if (!hasDb) return; await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); await db.end(); }); diff --git a/packages/server/src/routes/events.integration.test.ts b/packages/server/src/routes/events.integration.test.ts index 3ff4ddd..dbdb0df 100644 --- a/packages/server/src/routes/events.integration.test.ts +++ b/packages/server/src/routes/events.integration.test.ts @@ -9,6 +9,7 @@ import { resolveSnapshot } from '../pipeline/resolve.js'; import { createQueueConnection, INGEST_QUEUE_NAME } from '../queue/ingest.js'; import type { IngestJobData } from '../queue/ingest.js'; import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration tests hit a real Postgres + Redis. They run in CI (which provides // DATABASE_URL/REDIS_URL via service containers) and locally when those env vars @@ -17,6 +18,7 @@ import { hashApiKey } from '../middleware/api-key.js'; const hasDb = Boolean(process.env['DATABASE_URL']); const API_KEY = 'integration-test-key'; +const ORG = 'Events IT Org'; // A rich, stable signal set: identical re-submissions resolve to the same identity // with high confidence (canvas/audio/fonts/hardware dominate the weighting). @@ -80,8 +82,10 @@ beforeAll(async () => { await migrate(); await redis.flushdb(); await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; // cascades to identities/snapshots/links + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); const [proj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Integration Test') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Integration Test', ${org}) RETURNING id `; projectId = proj!.id; }); @@ -89,6 +93,7 @@ beforeAll(async () => { afterAll(async () => { if (!hasDb) return; await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/events.resolution.integration.test.ts b/packages/server/src/routes/events.resolution.integration.test.ts index fb19013..79eb511 100644 --- a/packages/server/src/routes/events.resolution.integration.test.ts +++ b/packages/server/src/routes/events.resolution.integration.test.ts @@ -5,6 +5,7 @@ import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { resolveSnapshot } from '../pipeline/resolve.js'; import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for the *resolution decisions* — new-vs-returning boundary, // ambiguous match, and cluster linking — that the happy-path suite doesn't reach. @@ -14,6 +15,7 @@ import { hashApiKey } from '../middleware/api-key.js'; // skips locally without a DB). const hasDb = Boolean(process.env['DATABASE_URL']); const API_KEY = 'integration-resolution-key'; +const ORG = 'Resolution IT Org'; // Base device: 4 stable (weight 0.9) + 5 moderate (0.55) signals. const C = { @@ -89,8 +91,10 @@ beforeAll(async () => { await migrate(); await redis.flushdb(); await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); const [proj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Resolution Integration') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Resolution Integration', ${org}) RETURNING id `; projectId = proj!.id; }); @@ -98,6 +102,7 @@ beforeAll(async () => { afterAll(async () => { if (!hasDb) return; await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/routes/queries.integration.test.ts b/packages/server/src/routes/queries.integration.test.ts index f76450f..8ba610b 100644 --- a/packages/server/src/routes/queries.integration.test.ts +++ b/packages/server/src/routes/queries.integration.test.ts @@ -5,12 +5,14 @@ import { migrate } from '../db/migrate.js'; import { db } from '../db/client.js'; import { redis } from '../db/redis.js'; import { hashApiKey } from '../middleware/api-key.js'; +import { createTestOrg, deleteTestOrg } from '../test-support/org.js'; // Integration coverage for the read/query API (the GET routes + /v1/resolve), which // the OpenAPI spec documents and the SDK/Observatory consume. Seeds a small graph and // asserts status + response shape. Gated on DATABASE_URL like the other suites. const hasDb = Boolean(process.env['DATABASE_URL']); const API_KEY = 'queries-integration-key'; +const ORG = 'Queries IT Org'; const app = createApp(); let projectId: string; @@ -28,8 +30,10 @@ beforeAll(async () => { await migrate(); await redis.flushdb(); await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); + const org = await createTestOrg(ORG); const [proj] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name) VALUES (${hashApiKey(API_KEY)}, 'Queries Integration') RETURNING id + INSERT INTO projects (api_key_hash, name, organization_id) VALUES (${hashApiKey(API_KEY)}, 'Queries Integration', ${org}) RETURNING id `; projectId = proj!.id; @@ -82,6 +86,7 @@ beforeAll(async () => { afterAll(async () => { if (!hasDb) return; await db`DELETE FROM projects WHERE api_key_hash = ${hashApiKey(API_KEY)}`; + await deleteTestOrg(ORG); await redis.quit(); await db.end(); }); diff --git a/packages/server/src/scripts/create-admin.ts b/packages/server/src/scripts/create-admin.ts index b7c708b..b5e1039 100644 --- a/packages/server/src/scripts/create-admin.ts +++ b/packages/server/src/scripts/create-admin.ts @@ -1,32 +1,38 @@ import { db } from '../db/client.js'; import { hashPassword } from '../admin/password.js'; +import { findOrCreateOrgByName } from '../lib/organizations.js'; // Bootstrap an admin user. This is the install owner: CLI-created admins are always -// 'owner' (full access — manages projects and, going forward, other admins). Member +// 'owner' (full access — manages projects and other admins WITHIN their org). Member // accounts are provisioned from the Observatory, not here. Password is hashed with // bcrypt before it touches the DB. // -// tsx src/scripts/create-admin.ts (dev) -// docker compose exec scent-server node dist/scripts/create-admin.js +// tsx src/scripts/create-admin.ts [orgName] (dev) +// docker compose exec scent-server node dist/scripts/create-admin.js [org] // -// Re-running for an existing email resets the password but leaves the role untouched. +// The optional org name (default 'Default') attaches the admin to a tenant, creating it +// if needed — this is how a fresh install gets its first org + owner, and how an operator +// adds a second tenant on a hosted box. Re-running for an existing email resets the +// password but leaves the role and org untouched. async function main(): Promise { const email = process.argv[2]?.trim().toLowerCase(); const password = process.argv[3]; + const orgName = process.argv[4]?.trim() || 'Default'; if (!email || !password) { - console.error('Usage: create-admin '); + console.error('Usage: create-admin [orgName]'); process.exit(1); } const passwordHash = await hashPassword(password); + const organizationId = await findOrCreateOrgByName(orgName); const [user] = await db<{ id: string }[]>` - INSERT INTO admin_users (email, password_hash, role) - VALUES (${email}, ${passwordHash}, 'owner') + INSERT INTO admin_users (email, password_hash, role, organization_id) + VALUES (${email}, ${passwordHash}, 'owner', ${organizationId}) ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash RETURNING id `; - console.error(`Admin user ready: ${email} (id: ${user?.id})`); + console.error(`Admin user ready: ${email} (id: ${user?.id}, org: ${orgName})`); await db.end(); } diff --git a/packages/server/src/scripts/create-project.ts b/packages/server/src/scripts/create-project.ts index a15bb24..e501541 100644 --- a/packages/server/src/scripts/create-project.ts +++ b/packages/server/src/scripts/create-project.ts @@ -1,30 +1,36 @@ import { db } from '../db/client.js'; import { mintApiKey } from '../lib/api-key.js'; +import { findOrCreateOrgByName } from '../lib/organizations.js'; // Create a project and mint its API key. Only the key's hash is stored; the // plaintext is printed once here and cannot be recovered later. // -// tsx src/scripts/create-project.ts "Project Name" (dev) -// node dist/scripts/create-project.js "Project Name" (in the image) +// tsx src/scripts/create-project.ts "Project Name" [orgName] (dev) +// node dist/scripts/create-project.js "Project Name" [org] (in the image) // docker compose exec scent-server node dist/scripts/create-project.js "Prod" +// +// The optional org name (default 'Default') attaches the project to a tenant, creating +// it if needed — every project belongs to an organization (migration 013). async function main(): Promise { const name = process.argv[2]; + const orgName = process.argv[3]?.trim() || 'Default'; if (!name) { - console.error('Usage: create-project ""'); + console.error('Usage: create-project "" [orgName]'); process.exit(1); } const { apiKey, keyHash, keyPrefix } = mintApiKey(); + const organizationId = await findOrCreateOrgByName(orgName); const [project] = await db<{ id: string }[]>` - INSERT INTO projects (api_key_hash, name, key_prefix) - VALUES (${keyHash}, ${name}, ${keyPrefix}) + INSERT INTO projects (api_key_hash, name, key_prefix, organization_id) + VALUES (${keyHash}, ${name}, ${keyPrefix}, ${organizationId}) RETURNING id `; // The key is shown exactly once. Print to stdout; everything else to stderr so // `... | tail` style capture of just the key works. - console.error(`Created project "${name}" (id: ${project?.id})`); + console.error(`Created project "${name}" (id: ${project?.id}, org: ${orgName})`); console.error('API key (store it now — it is not recoverable):'); console.log(apiKey);