feat(platform-service): add scim provisioning foundation

This commit is contained in:
root 2026-03-15 09:36:27 +00:00
parent 66d0bf53a9
commit 8b99b7a9a7
7 changed files with 585 additions and 0 deletions

View File

@ -22,6 +22,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
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<string, ContainerConfig> = {
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 },

View File

@ -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');
});
});

View File

@ -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<ScimConnectorDoc>('scim_connectors', '/orgId');
}
function userSyncCollection() {
return getCollection<ScimUserSyncDoc>('scim_user_sync', '/connectorId');
}
function groupSyncCollection() {
return getCollection<ScimGroupSyncDoc>('scim_group_sync', '/connectorId');
}
function eventCollection() {
return getCollection<ScimProvisioningEventDoc>('scim_events', '/connectorId');
}
export async function createConnector(doc: ScimConnectorDoc): Promise<ScimConnectorDoc> {
return connectorCollection().create(doc);
}
export async function listConnectors(orgId: string): Promise<ScimConnectorDoc[]> {
return connectorCollection().findMany({
filter: { orgId },
sort: { createdAt: -1 },
limit: 100,
});
}
export async function getConnector(id: string, orgId: string): Promise<ScimConnectorDoc> {
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<ScimConnectorDoc>
): Promise<ScimConnectorDoc> {
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<ScimUserSyncDoc> {
return userSyncCollection().upsert(doc);
}
export async function listUserSync(connectorId: string): Promise<ScimUserSyncDoc[]> {
return userSyncCollection().findMany({
filter: { connectorId },
sort: { lastSyncedAt: -1 },
limit: 500,
});
}
export async function upsertGroupSync(doc: ScimGroupSyncDoc): Promise<ScimGroupSyncDoc> {
return groupSyncCollection().upsert(doc);
}
export async function listGroupSync(connectorId: string): Promise<ScimGroupSyncDoc[]> {
return groupSyncCollection().findMany({
filter: { connectorId },
sort: { lastSyncedAt: -1 },
limit: 500,
});
}
export async function createEvent(
doc: ScimProvisioningEventDoc
): Promise<ScimProvisioningEventDoc> {
return eventCollection().create(doc);
}
export async function listEvents(connectorId: string): Promise<ScimProvisioningEventDoc[]> {
return eventCollection().findMany({
filter: { connectorId },
sort: { createdAt: -1 },
limit: 500,
});
}

View File

@ -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',
})
);
});
});

View File

@ -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);
});
}

View File

@ -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<typeof ScimConnectorSchema> & {
_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<typeof ScimUserSyncSchema> & {
_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<typeof ScimGroupSyncSchema> & {
_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<typeof ScimProvisioningEventSchema> & {
_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(),
});

View File

@ -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' });