diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index e2276ac5..5a71f777 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -41,6 +41,8 @@ const CONTAINER_DEFS: Record = { email_verifications: { partitionKeyPath: '/productId', defaultTtl: 7 * 86400 }, // SmartAuth — OAuth provider linking auth_providers: { partitionKeyPath: '/userId' }, + // SmartAuth — Enterprise IdP configs + auth_enterprise_idps: { partitionKeyPath: '/orgId' }, // SmartAuth — TOTP MFA secrets + recovery codes auth_mfa: { partitionKeyPath: '/userId' }, // SmartAuth — MFA enforcement policies (per product) @@ -63,6 +65,10 @@ const CONTAINER_DEFS: Record = { // Generic orchestration runs agent_runs: { partitionKeyPath: '/productId', defaultTtl: 30 * 86400 }, agent_run_steps: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, + // Canonical tenant model + organizations: { partitionKeyPath: '/productId' }, + workspaces: { partitionKeyPath: '/orgId' }, + org_memberships: { partitionKeyPath: '/orgId' }, // Telemetry (client diagnostics — see docs/WINDSURF/CLIENT_TELEMETRY_DESIGN.md) telemetry_events: { partitionKeyPath: '/pk', defaultTtl: 30 * 86400 }, telemetry_error_clusters: { partitionKeyPath: '/pk', defaultTtl: 90 * 86400 }, diff --git a/services/platform-service/src/modules/orgs/repository.test.ts b/services/platform-service/src/modules/orgs/repository.test.ts new file mode 100644 index 00000000..a3471519 --- /dev/null +++ b/services/platform-service/src/modules/orgs/repository.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js'; +import * as repo from './repository.js'; + +describe('orgs repository', () => { + beforeEach(() => { + setProvider(new MemoryDatastoreProvider()); + }); + + afterEach(() => { + _resetDatastoreProvider(); + }); + + it('creates orgs, workspaces, and memberships', async () => { + await repo.createOrganization({ + id: 'org_1', + productId: 'lysnrai', + name: 'Acme', + slug: 'acme', + status: 'active', + ownerUserId: 'user_1', + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + await repo.createWorkspace({ + id: 'ws_1', + orgId: 'org_1', + productId: 'lysnrai', + name: 'Operations', + slug: 'operations', + status: 'active', + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + await repo.createMembership({ + id: 'mbr_1', + orgId: 'org_1', + productId: 'lysnrai', + scope: 'workspace', + workspaceId: 'ws_1', + userId: 'user_2', + role: 'member', + status: 'active', + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + const orgs = await repo.listOrganizations('lysnrai', { limit: 20 }); + const workspaces = await repo.listWorkspaces('org_1'); + const memberships = await repo.listMemberships('org_1', { limit: 100 }); + + expect(orgs).toHaveLength(1); + expect(workspaces[0].name).toBe('Operations'); + expect(memberships[0].workspaceId).toBe('ws_1'); + }); +}); diff --git a/services/platform-service/src/modules/orgs/repository.ts b/services/platform-service/src/modules/orgs/repository.ts new file mode 100644 index 00000000..81f454b8 --- /dev/null +++ b/services/platform-service/src/modules/orgs/repository.ts @@ -0,0 +1,128 @@ +import { NotFoundError } from '../../lib/errors.js'; +import { getCollection } from '../../lib/datastore.js'; +import type { + ListMembershipsQuery, + ListOrganizationsQuery, + MembershipDoc, + OrganizationDoc, + WorkspaceDoc, +} from './types.js'; + +function orgCollection() { + return getCollection('organizations', '/productId'); +} + +function workspaceCollection() { + return getCollection('workspaces', '/orgId'); +} + +function membershipCollection() { + return getCollection('org_memberships', '/orgId'); +} + +export async function createOrganization(doc: OrganizationDoc): Promise { + return orgCollection().create(doc); +} + +export async function listOrganizations( + productId: string, + query: ListOrganizationsQuery +): Promise { + return orgCollection().findMany({ + filter: { + productId, + ...(query.status ? { status: query.status } : {}), + }, + sort: { createdAt: -1 }, + limit: query.limit, + }); +} + +export async function getOrganization(id: string, productId: string): Promise { + const org = await orgCollection().findById(id, productId); + if (!org) throw new NotFoundError(`Organization '${id}' not found`); + return org; +} + +export async function updateOrganization( + id: string, + productId: string, + updates: Partial +): Promise { + const updated = await orgCollection().update(id, productId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`Organization '${id}' not found`); + return updated; +} + +export async function createWorkspace(doc: WorkspaceDoc): Promise { + return workspaceCollection().create(doc); +} + +export async function listWorkspaces(orgId: string): Promise { + return workspaceCollection().findMany({ + filter: { orgId }, + sort: { createdAt: -1 }, + limit: 100, + }); +} + +export async function getWorkspace(id: string, orgId: string): Promise { + const workspace = await workspaceCollection().findById(id, orgId); + if (!workspace) throw new NotFoundError(`Workspace '${id}' not found`); + return workspace; +} + +export async function updateWorkspace( + id: string, + orgId: string, + updates: Partial +): Promise { + const updated = await workspaceCollection().update(id, orgId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`Workspace '${id}' not found`); + return updated; +} + +export async function createMembership(doc: MembershipDoc): Promise { + return membershipCollection().create(doc); +} + +export async function listMemberships( + orgId: string, + query: ListMembershipsQuery +): Promise { + const memberships = await membershipCollection().findMany({ + filter: { + orgId, + ...(query.scope ? { scope: query.scope } : {}), + ...(query.workspaceId ? { workspaceId: query.workspaceId } : {}), + }, + sort: { createdAt: -1 }, + limit: query.limit, + }); + return memberships; +} + +export async function getMembership(id: string, orgId: string): Promise { + const membership = await membershipCollection().findById(id, orgId); + if (!membership) throw new NotFoundError(`Membership '${id}' not found`); + return membership; +} + +export async function updateMembership( + id: string, + orgId: string, + updates: Partial +): Promise { + const updated = await membershipCollection().update(id, orgId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`Membership '${id}' not found`); + return updated; +} diff --git a/services/platform-service/src/modules/orgs/routes.test.ts b/services/platform-service/src/modules/orgs/routes.test.ts new file mode 100644 index 00000000..ec6f3ae4 --- /dev/null +++ b/services/platform-service/src/modules/orgs/routes.test.ts @@ -0,0 +1,80 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + createOrganization: vi.fn(), + listOrganizations: vi.fn(), + getOrganization: vi.fn(), + updateOrganization: vi.fn(), + createWorkspace: vi.fn(), + listWorkspaces: vi.fn(), + getWorkspace: vi.fn(), + updateWorkspace: vi.fn(), + createMembership: vi.fn(), + listMemberships: vi.fn(), + updateMembership: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); + +async function buildApp(payload?: { sub: string; productId: string; role?: string }) { + const { orgRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (payload) { + app.addHook('onRequest', async req => { + req.jwtPayload = payload; + }); + } + await app.register(orgRoutes, { prefix: '/api' }); + return app; +} + +describe('orgRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /orgs creates an organization and owner membership', async () => { + repoMock.createOrganization.mockResolvedValue({ + id: 'org_1', + name: 'Acme', + ownerUserId: 'admin_1', + }); + repoMock.createMembership.mockResolvedValue({ id: 'mbr_1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/orgs', + payload: { name: 'Acme', slug: 'acme' }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createOrganization).toHaveBeenCalled(); + expect(repoMock.createMembership).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'org', + role: 'owner', + userId: 'admin_1', + }) + ); + }); + + it('POST /orgs/:id/memberships validates workspace scope', async () => { + repoMock.getOrganization.mockResolvedValue({ id: 'org_1' }); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/orgs/org_1/memberships', + payload: { userId: 'user_2', role: 'member', scope: 'workspace' }, + }); + + expect(res.statusCode).toBe(400); + expect(repoMock.createMembership).not.toHaveBeenCalled(); + }); +}); diff --git a/services/platform-service/src/modules/orgs/routes.ts b/services/platform-service/src/modules/orgs/routes.ts new file mode 100644 index 00000000..2da41914 --- /dev/null +++ b/services/platform-service/src/modules/orgs/routes.ts @@ -0,0 +1,194 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ForbiddenError } from '../../lib/errors.js'; +import * as repo from './repository.js'; +import { + CreateMembershipSchema, + CreateOrganizationSchema, + CreateWorkspaceSchema, + ListMembershipsQuerySchema, + ListOrganizationsQuerySchema, + MembershipDoc, + OrganizationDoc, + UpdateMembershipSchema, + UpdateOrganizationSchema, + UpdateWorkspaceSchema, + WorkspaceDoc, +} from './types.js'; + +function requireAdmin(req: { jwtPayload?: { sub?: string; role?: string; productId?: string } }): { + userId: string; + productId: string; +} { + const payload = req.jwtPayload; + if (!payload?.sub) { + throw new ForbiddenError('Authentication required'); + } + if (!payload.role || !['super_admin', 'admin'].includes(payload.role)) { + throw new ForbiddenError('Admin access required'); + } + return { + userId: payload.sub, + productId: payload.productId ?? process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai', + }; +} + +export async function orgRoutes(app: FastifyInstance) { + app.get('/orgs', async req => { + const access = requireAdmin(req); + const parsed = ListOrganizationsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.listOrganizations(access.productId, parsed.data); + }); + + app.post('/orgs', async req => { + const access = requireAdmin(req); + const parsed = CreateOrganizationSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const now = new Date().toISOString(); + const org: OrganizationDoc = { + id: `org_${randomUUID()}`, + productId: access.productId, + name: parsed.data.name, + slug: parsed.data.slug, + status: 'active', + ownerUserId: parsed.data.ownerUserId ?? access.userId, + metadata: parsed.data.metadata, + createdAt: now, + updatedAt: now, + }; + + const created = await repo.createOrganization(org); + const ownerMembership: MembershipDoc = { + id: `mbr_${created.id}_${created.ownerUserId}_org`, + orgId: created.id, + productId: access.productId, + scope: 'org', + userId: created.ownerUserId, + role: 'owner', + status: 'active', + createdAt: now, + updatedAt: now, + }; + await repo.createMembership(ownerMembership); + + return created; + }); + + app.get('/orgs/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.getOrganization(id, access.productId); + }); + + app.patch('/orgs/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = UpdateOrganizationSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateOrganization(id, access.productId, parsed.data); + }); + + app.get('/orgs/:id/workspaces', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + return repo.listWorkspaces(id); + }); + + app.post('/orgs/:id/workspaces', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const org = await repo.getOrganization(id, access.productId); + const parsed = CreateWorkspaceSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const now = new Date().toISOString(); + const workspace: WorkspaceDoc = { + id: `ws_${randomUUID()}`, + orgId: org.id, + productId: access.productId, + name: parsed.data.name, + slug: parsed.data.slug, + status: 'active', + description: parsed.data.description, + metadata: parsed.data.metadata, + createdAt: now, + updatedAt: now, + }; + + return repo.createWorkspace(workspace); + }); + + app.patch('/orgs/:id/workspaces/:workspaceId', async req => { + requireAdmin(req); + const { id, workspaceId } = req.params as { id: string; workspaceId: string }; + const parsed = UpdateWorkspaceSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateWorkspace(workspaceId, id, parsed.data); + }); + + app.get('/orgs/:id/memberships', async req => { + requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = ListMembershipsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.listMemberships(id, parsed.data); + }); + + app.post('/orgs/:id/memberships', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const parsed = CreateMembershipSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + if (parsed.data.scope === 'workspace' && !parsed.data.workspaceId) { + throw new BadRequestError('workspaceId is required for workspace-scoped memberships'); + } + + await repo.getOrganization(id, access.productId); + if (parsed.data.workspaceId) { + await repo.getWorkspace(parsed.data.workspaceId, id); + } + + const now = new Date().toISOString(); + const membership: MembershipDoc = { + id: `mbr_${id}_${parsed.data.userId}_${parsed.data.workspaceId ?? 'org'}`, + orgId: id, + productId: access.productId, + scope: parsed.data.scope, + workspaceId: parsed.data.workspaceId, + userId: parsed.data.userId, + role: parsed.data.role, + status: 'active', + invitedBy: parsed.data.invitedBy ?? access.userId, + createdAt: now, + updatedAt: now, + }; + return repo.createMembership(membership); + }); + + app.patch('/orgs/:id/memberships/:membershipId', async req => { + requireAdmin(req); + const { id, membershipId } = req.params as { id: string; membershipId: string }; + const parsed = UpdateMembershipSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateMembership(membershipId, id, parsed.data); + }); +} diff --git a/services/platform-service/src/modules/orgs/types.ts b/services/platform-service/src/modules/orgs/types.ts new file mode 100644 index 00000000..e249d85b --- /dev/null +++ b/services/platform-service/src/modules/orgs/types.ts @@ -0,0 +1,116 @@ +import { z } from 'zod'; + +export const OrganizationStatusSchema = z.enum(['active', 'disabled']); +export const WorkspaceStatusSchema = z.enum(['active', 'archived']); +export const MembershipRoleSchema = z.enum(['owner', 'admin', 'member', 'viewer']); +export const MembershipScopeSchema = z.enum(['org', 'workspace']); + +export const OrganizationSchema = z.object({ + id: z.string().min(1), + productId: z.string().min(1), + name: z.string().min(1), + slug: z.string().min(1), + status: OrganizationStatusSchema, + ownerUserId: z.string().min(1), + metadata: z.record(z.unknown()).optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type OrganizationDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const WorkspaceSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + productId: z.string().min(1), + name: z.string().min(1), + slug: z.string().min(1), + status: WorkspaceStatusSchema, + description: z.string().optional(), + metadata: z.record(z.unknown()).optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type WorkspaceDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const MembershipSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + productId: z.string().min(1), + scope: MembershipScopeSchema, + workspaceId: z.string().optional(), + userId: z.string().min(1), + role: MembershipRoleSchema, + status: z.enum(['active', 'invited', 'disabled']).default('active'), + invitedBy: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type MembershipDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const CreateOrganizationSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), + ownerUserId: z.string().min(1).optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const UpdateOrganizationSchema = z.object({ + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + status: OrganizationStatusSchema.optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const CreateWorkspaceSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), + description: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const UpdateWorkspaceSchema = z.object({ + name: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + status: WorkspaceStatusSchema.optional(), + description: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const CreateMembershipSchema = z.object({ + userId: z.string().min(1), + role: MembershipRoleSchema.default('member'), + scope: MembershipScopeSchema.default('org'), + workspaceId: z.string().optional(), + invitedBy: z.string().optional(), +}); + +export const UpdateMembershipSchema = z.object({ + role: MembershipRoleSchema.optional(), + status: z.enum(['active', 'invited', 'disabled']).optional(), +}); + +export const ListOrganizationsQuerySchema = z.object({ + status: OrganizationStatusSchema.optional(), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +export const ListMembershipsQuerySchema = z.object({ + scope: MembershipScopeSchema.optional(), + workspaceId: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(100), +}); + +export type ListOrganizationsQuery = z.infer; +export type ListMembershipsQuery = z.infer; diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index b2b2fd3e..af76ee7f 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -24,6 +24,7 @@ import { createServiceApp, registerOptionalJwtContext, startService } from '@byt import { productRoutes } from './modules/products/routes.js'; import { loadProductCache } from './modules/products/cache.js'; import { authRoutes } from './modules/auth/routes.js'; +import { orgRoutes } from './modules/orgs/routes.js'; import { oauthRoutes } from './modules/auth/oauth/routes.js'; import { mfaRoutes } from './modules/auth/mfa/routes.js'; import { passkeyRoutes } from './modules/auth/passkeys/routes.js'; @@ -126,6 +127,7 @@ await registerOptionalApiKeyContext(app); // Register route modules await app.register(productRoutes, { prefix: '/api' }); await app.register(authRoutes, { prefix: '/api' }); +await app.register(orgRoutes, { prefix: '/api' }); await app.register(oauthRoutes, { prefix: '/api' }); await app.register(mfaRoutes, { prefix: '/api' }); await app.register(passkeyRoutes, { prefix: '/api' });