diff --git a/services/platform-service/src/lib/cosmos-init.ts b/services/platform-service/src/lib/cosmos-init.ts index 11834638..0b59f119 100644 --- a/services/platform-service/src/lib/cosmos-init.ts +++ b/services/platform-service/src/lib/cosmos-init.ts @@ -22,6 +22,7 @@ const CONTAINER_DEFS: Record = { usage_daily: { partitionKeyPath: '/userId' }, // API tokens api_tokens: { partitionKeyPath: '/id' }, + rate_limit_entries: { partitionKeyPath: '/id', defaultTtl: 24 * 3600 }, // Tracker modules tracker_items: { partitionKeyPath: '/id' }, comments: { partitionKeyPath: '/itemId' }, @@ -87,6 +88,11 @@ const CONTAINER_DEFS: Record = { knowledge_bases: { partitionKeyPath: '/productId' }, knowledge_sources: { partitionKeyPath: '/knowledgeBaseId' }, knowledge_chunks: { partitionKeyPath: '/knowledgeBaseId', defaultTtl: 90 * 86400 }, + // Enterprise provisioning / SCIM + scim_connectors: { partitionKeyPath: '/orgId' }, + scim_user_sync: { partitionKeyPath: '/connectorId' }, + scim_group_sync: { partitionKeyPath: '/connectorId' }, + scim_events: { partitionKeyPath: '/connectorId', defaultTtl: 90 * 86400 }, // 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/scim/repository.test.ts b/services/platform-service/src/modules/scim/repository.test.ts new file mode 100644 index 00000000..7b923d49 --- /dev/null +++ b/services/platform-service/src/modules/scim/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('scim repository', () => { + beforeEach(() => { + setProvider(new MemoryDatastoreProvider()); + }); + + afterEach(() => { + _resetDatastoreProvider(); + }); + + it('stores connectors, sync state, and provisioning events', async () => { + await repo.createConnector({ + id: 'scim_1', + orgId: 'org_1', + productId: 'lysnrai', + name: 'Okta Provisioning', + authMode: 'bearer', + status: 'active', + createdBy: 'admin_1', + createdAt: '2026-03-15T00:00:00.000Z', + updatedAt: '2026-03-15T00:00:00.000Z', + }); + + await repo.upsertUserSync({ + id: 'scim_1:user:ext_1', + orgId: 'org_1', + connectorId: 'scim_1', + productId: 'lysnrai', + externalUserId: 'ext_1', + email: 'member@acme.com', + status: 'provisioned', + lastSyncedAt: '2026-03-15T00:05:00.000Z', + }); + + await repo.createEvent({ + id: 'scimevt_1', + orgId: 'org_1', + connectorId: 'scim_1', + productId: 'lysnrai', + entityType: 'user', + entityId: 'ext_1', + action: 'provision', + status: 'success', + createdAt: '2026-03-15T00:06:00.000Z', + }); + + const connectors = await repo.listConnectors('org_1'); + const users = await repo.listUserSync('scim_1'); + const events = await repo.listEvents('scim_1'); + + expect(connectors).toHaveLength(1); + expect(users[0].email).toBe('member@acme.com'); + expect(events[0].action).toBe('provision'); + }); +}); diff --git a/services/platform-service/src/modules/scim/repository.ts b/services/platform-service/src/modules/scim/repository.ts new file mode 100644 index 00000000..3b264cbe --- /dev/null +++ b/services/platform-service/src/modules/scim/repository.ts @@ -0,0 +1,93 @@ +import { NotFoundError } from '../../lib/errors.js'; +import { getCollection } from '../../lib/datastore.js'; +import type { + ScimConnectorDoc, + ScimGroupSyncDoc, + ScimProvisioningEventDoc, + ScimUserSyncDoc, +} from './types.js'; + +function connectorCollection() { + return getCollection('scim_connectors', '/orgId'); +} + +function userSyncCollection() { + return getCollection('scim_user_sync', '/connectorId'); +} + +function groupSyncCollection() { + return getCollection('scim_group_sync', '/connectorId'); +} + +function eventCollection() { + return getCollection('scim_events', '/connectorId'); +} + +export async function createConnector(doc: ScimConnectorDoc): Promise { + return connectorCollection().create(doc); +} + +export async function listConnectors(orgId: string): Promise { + return connectorCollection().findMany({ + filter: { orgId }, + sort: { createdAt: -1 }, + limit: 100, + }); +} + +export async function getConnector(id: string, orgId: string): Promise { + const connector = await connectorCollection().findById(id, orgId); + if (!connector) throw new NotFoundError(`SCIM connector '${id}' not found`); + return connector; +} + +export async function updateConnector( + id: string, + orgId: string, + updates: Partial +): Promise { + const updated = await connectorCollection().update(id, orgId, { + ...updates, + updatedAt: new Date().toISOString(), + }); + if (!updated) throw new NotFoundError(`SCIM connector '${id}' not found`); + return updated; +} + +export async function upsertUserSync(doc: ScimUserSyncDoc): Promise { + return userSyncCollection().upsert(doc); +} + +export async function listUserSync(connectorId: string): Promise { + return userSyncCollection().findMany({ + filter: { connectorId }, + sort: { lastSyncedAt: -1 }, + limit: 500, + }); +} + +export async function upsertGroupSync(doc: ScimGroupSyncDoc): Promise { + return groupSyncCollection().upsert(doc); +} + +export async function listGroupSync(connectorId: string): Promise { + return groupSyncCollection().findMany({ + filter: { connectorId }, + sort: { lastSyncedAt: -1 }, + limit: 500, + }); +} + +export async function createEvent( + doc: ScimProvisioningEventDoc +): Promise { + return eventCollection().create(doc); +} + +export async function listEvents(connectorId: string): Promise { + return eventCollection().findMany({ + filter: { connectorId }, + sort: { createdAt: -1 }, + limit: 500, + }); +} diff --git a/services/platform-service/src/modules/scim/routes.test.ts b/services/platform-service/src/modules/scim/routes.test.ts new file mode 100644 index 00000000..288f1739 --- /dev/null +++ b/services/platform-service/src/modules/scim/routes.test.ts @@ -0,0 +1,119 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const repoMock = { + listConnectors: vi.fn(), + createConnector: vi.fn(), + getConnector: vi.fn(), + updateConnector: vi.fn(), + upsertUserSync: vi.fn(), + listUserSync: vi.fn(), + upsertGroupSync: vi.fn(), + listGroupSync: vi.fn(), + createEvent: vi.fn(), + listEvents: vi.fn(), +}; + +const orgRepoMock = { + getOrganization: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('../orgs/repository.js', () => orgRepoMock); + +async function buildApp(payload?: { sub: string; productId: string; role?: string }) { + const { scimRoutes } = await import('./routes.js'); + const app = Fastify({ logger: false }); + if (payload) { + app.addHook('onRequest', async req => { + req.jwtPayload = payload; + }); + } + await app.register(scimRoutes, { prefix: '/api' }); + return app; +} + +describe('scimRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('POST /scim/connectors creates a connector for an existing org', async () => { + orgRepoMock.getOrganization.mockResolvedValue({ id: 'org_1' }); + repoMock.createConnector.mockResolvedValue({ id: 'scim_1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/scim/connectors', + payload: { + orgId: 'org_1', + name: 'Okta', + baseUrl: 'https://okta.example.com/scim/v2', + }, + }); + + expect(res.statusCode).toBe(200); + expect(orgRepoMock.getOrganization).toHaveBeenCalledWith('org_1', 'lysnrai'); + expect(repoMock.createConnector).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: 'org_1', + name: 'Okta', + }) + ); + }); + + it('POST /scim/connectors/:orgId/:id/users upserts user sync state', async () => { + repoMock.getConnector.mockResolvedValue({ id: 'scim_1' }); + repoMock.upsertUserSync.mockResolvedValue({ id: 'scim_1:user:ext_1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/scim/connectors/org_1/scim_1/users', + payload: { + externalUserId: 'ext_1', + email: 'member@acme.com', + status: 'provisioned', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.upsertUserSync).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'scim_1', + externalUserId: 'ext_1', + }) + ); + }); + + it('POST /scim/connectors/:orgId/:id/events records provisioning events', async () => { + repoMock.getConnector.mockResolvedValue({ id: 'scim_1' }); + repoMock.createEvent.mockResolvedValue({ id: 'scimevt_1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/scim/connectors/org_1/scim_1/events', + payload: { + entityType: 'user', + entityId: 'ext_1', + action: 'provision', + status: 'success', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'scim_1', + entityType: 'user', + action: 'provision', + }) + ); + }); +}); diff --git a/services/platform-service/src/modules/scim/routes.ts b/services/platform-service/src/modules/scim/routes.ts new file mode 100644 index 00000000..05e21697 --- /dev/null +++ b/services/platform-service/src/modules/scim/routes.ts @@ -0,0 +1,178 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, ForbiddenError } from '../../lib/errors.js'; +import * as orgRepo from '../orgs/repository.js'; +import { + CreateScimConnectorSchema, + RecordScimEventSchema, + RecordScimGroupSyncSchema, + RecordScimUserSyncSchema, + ScimConnectorDoc, + ScimGroupSyncDoc, + ScimProvisioningEventDoc, + ScimUserSyncDoc, + UpdateScimConnectorSchema, +} from './types.js'; +import * as repo from './repository.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', + }; +} + +function validationError(message: string): never { + throw new BadRequestError(message); +} + +export async function scimRoutes(app: FastifyInstance) { + app.get('/scim/connectors/:orgId', async req => { + requireAdmin(req); + const { orgId } = req.params as { orgId: string }; + return repo.listConnectors(orgId); + }); + + app.post('/scim/connectors', async req => { + const access = requireAdmin(req); + const parsed = CreateScimConnectorSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + await orgRepo.getOrganization(parsed.data.orgId, access.productId); + + const now = new Date().toISOString(); + const connector: ScimConnectorDoc = { + id: `scim_${randomUUID()}`, + orgId: parsed.data.orgId, + productId: access.productId, + idpId: parsed.data.idpId, + name: parsed.data.name, + baseUrl: parsed.data.baseUrl, + authMode: parsed.data.authMode, + status: 'active', + metadata: parsed.data.metadata, + createdBy: access.userId, + createdAt: now, + updatedAt: now, + }; + return repo.createConnector(connector); + }); + + app.get('/scim/connectors/:orgId/:id', async req => { + requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + return repo.getConnector(id, orgId); + }); + + app.patch('/scim/connectors/:orgId/:id', async req => { + requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + const parsed = UpdateScimConnectorSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + return repo.updateConnector(id, orgId, parsed.data); + }); + + app.get('/scim/connectors/:orgId/:id/users', async req => { + requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + return repo.listUserSync(id); + }); + + app.post('/scim/connectors/:orgId/:id/users', async req => { + const access = requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + const parsed = RecordScimUserSyncSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const doc: ScimUserSyncDoc = { + id: `${id}:user:${parsed.data.externalUserId}`, + orgId, + connectorId: id, + productId: access.productId, + externalUserId: parsed.data.externalUserId, + userId: parsed.data.userId, + email: parsed.data.email, + status: parsed.data.status, + lastSyncedAt: new Date().toISOString(), + errorMessage: parsed.data.errorMessage, + metadata: parsed.data.metadata, + }; + return repo.upsertUserSync(doc); + }); + + app.get('/scim/connectors/:orgId/:id/groups', async req => { + requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + return repo.listGroupSync(id); + }); + + app.post('/scim/connectors/:orgId/:id/groups', async req => { + const access = requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + const parsed = RecordScimGroupSyncSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const doc: ScimGroupSyncDoc = { + id: `${id}:group:${parsed.data.externalGroupId}`, + orgId, + connectorId: id, + productId: access.productId, + externalGroupId: parsed.data.externalGroupId, + workspaceId: parsed.data.workspaceId, + displayName: parsed.data.displayName, + memberCount: parsed.data.memberCount, + status: parsed.data.status, + lastSyncedAt: new Date().toISOString(), + errorMessage: parsed.data.errorMessage, + metadata: parsed.data.metadata, + }; + return repo.upsertGroupSync(doc); + }); + + app.get('/scim/connectors/:orgId/:id/events', async req => { + requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + return repo.listEvents(id); + }); + + app.post('/scim/connectors/:orgId/:id/events', async req => { + const access = requireAdmin(req); + const { orgId, id } = req.params as { orgId: string; id: string }; + await repo.getConnector(id, orgId); + const parsed = RecordScimEventSchema.safeParse(req.body); + if (!parsed.success) { + validationError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + const doc: ScimProvisioningEventDoc = { + id: `scimevt_${randomUUID()}`, + orgId, + connectorId: id, + productId: access.productId, + entityType: parsed.data.entityType, + entityId: parsed.data.entityId, + action: parsed.data.action, + status: parsed.data.status, + message: parsed.data.message, + createdAt: new Date().toISOString(), + }; + return repo.createEvent(doc); + }); +} diff --git a/services/platform-service/src/modules/scim/types.ts b/services/platform-service/src/modules/scim/types.ts new file mode 100644 index 00000000..0d7f65a3 --- /dev/null +++ b/services/platform-service/src/modules/scim/types.ts @@ -0,0 +1,128 @@ +import { z } from 'zod'; + +export const ScimConnectorStatusSchema = z.enum(['active', 'paused', 'archived']); +export const ScimUserSyncStatusSchema = z.enum(['provisioned', 'updated', 'disabled', 'failed']); +export const ScimGroupSyncStatusSchema = z.enum(['provisioned', 'updated', 'disabled', 'failed']); +export const ScimEventStatusSchema = z.enum(['success', 'failed']); + +export const ScimConnectorSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + productId: z.string().min(1), + idpId: z.string().optional(), + name: z.string().min(1), + baseUrl: z.string().url().optional(), + authMode: z.enum(['bearer', 'oauth']).default('bearer'), + status: ScimConnectorStatusSchema, + metadata: z.record(z.unknown()).optional(), + createdBy: z.string().min(1), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type ScimConnectorDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const ScimUserSyncSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + connectorId: z.string().min(1), + productId: z.string().min(1), + externalUserId: z.string().min(1), + userId: z.string().optional(), + email: z.string().email(), + status: ScimUserSyncStatusSchema, + lastSyncedAt: z.string(), + errorMessage: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type ScimUserSyncDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const ScimGroupSyncSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + connectorId: z.string().min(1), + productId: z.string().min(1), + externalGroupId: z.string().min(1), + workspaceId: z.string().optional(), + displayName: z.string().min(1), + memberCount: z.number().int().min(0).default(0), + status: ScimGroupSyncStatusSchema, + lastSyncedAt: z.string(), + errorMessage: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type ScimGroupSyncDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const ScimProvisioningEventSchema = z.object({ + id: z.string().min(1), + orgId: z.string().min(1), + connectorId: z.string().min(1), + productId: z.string().min(1), + entityType: z.enum(['user', 'group']), + entityId: z.string().min(1), + action: z.enum(['provision', 'update', 'deprovision', 'sync']), + status: ScimEventStatusSchema, + message: z.string().optional(), + createdAt: z.string(), +}); + +export type ScimProvisioningEventDoc = z.infer & { + _ts?: number; + _etag?: string; +}; + +export const CreateScimConnectorSchema = z.object({ + orgId: z.string().min(1), + idpId: z.string().optional(), + name: z.string().min(1), + baseUrl: z.string().url().optional(), + authMode: z.enum(['bearer', 'oauth']).default('bearer'), + metadata: z.record(z.unknown()).optional(), +}); + +export const UpdateScimConnectorSchema = z.object({ + idpId: z.string().optional(), + name: z.string().min(1).optional(), + baseUrl: z.string().url().optional(), + authMode: z.enum(['bearer', 'oauth']).optional(), + status: ScimConnectorStatusSchema.optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const RecordScimUserSyncSchema = z.object({ + externalUserId: z.string().min(1), + userId: z.string().optional(), + email: z.string().email(), + status: ScimUserSyncStatusSchema, + errorMessage: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const RecordScimGroupSyncSchema = z.object({ + externalGroupId: z.string().min(1), + workspaceId: z.string().optional(), + displayName: z.string().min(1), + memberCount: z.number().int().min(0).default(0), + status: ScimGroupSyncStatusSchema, + errorMessage: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export const RecordScimEventSchema = z.object({ + entityType: z.enum(['user', 'group']), + entityId: z.string().min(1), + action: z.enum(['provision', 'update', 'deprovision', 'sync']), + status: ScimEventStatusSchema, + message: z.string().optional(), +}); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 1d45f2bc..30f7ce6d 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -39,6 +39,7 @@ import { agentRoutes } from './modules/agents/routes.js'; import { agentEvalRoutes } from './modules/agent-evals/routes.js'; import { aiBudgetRoutes } from './modules/ai-budgets/routes.js'; import { knowledgeRoutes } from './modules/knowledge/routes.js'; +import { scimRoutes } from './modules/scim/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { flagRoutes } from './modules/flags/routes.js'; import { rateLimitRoutes } from './modules/ratelimit/routes.js'; @@ -147,6 +148,7 @@ await app.register(agentRoutes, { prefix: '/api' }); await app.register(agentEvalRoutes, { prefix: '/api' }); await app.register(aiBudgetRoutes, { prefix: '/api' }); await app.register(knowledgeRoutes, { prefix: '/api' }); +await app.register(scimRoutes, { prefix: '/api' }); await app.register(notificationRoutes, { prefix: '/api' }); await app.register(flagRoutes, { prefix: '/api' }); await app.register(rateLimitRoutes, { prefix: '/api' });