feat(credentials): add managed credential groups (#6697)

* feat(credentials): add managed credential groups

* fix(audit): sync credential group mock

* fix(credentials): serialize enrollment revocation

* fix(credentials): isolate managed delegation

* fix(credentials): serialize invitation lifecycle

* fix(credentials): preserve enrollment lifecycle

* refactor(credentials): migrate groups to application boundary

* fix(credentials): serialize enrollment readiness

* fix(credentials): preserve completed reconnect state

* fix(credentials): revalidate policy before grant persistence

* fix(credentials): prioritize expired invitations

* fix(credentials): redirect unavailable oauth starts

* fix(credentials): clarify managed oauth boundaries

* fix(settings): complete feature flag test mocks

* fix(credentials): clarify enrollment actions and entitlement errors

* fix(credentials): preserve entitlement failure reasons

* fix(credentials): refine managed oauth flow

* fix(lint): use optional chain for pagination
This commit is contained in:
Theodore Li
2026-08-14 21:46:58 -04:00
committed by GitHub
parent 2a7abfdd17
commit 8fb571ac54
156 changed files with 34020 additions and 223 deletions
+1
View File
@@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
# Instance organization (Optional). Most enterprise features read their settings from the
@@ -4,7 +4,13 @@
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
import {
dbChainMockFns,
hybridAuthMockFns,
permissionsMock,
resetDbChainMock,
workflowsUtilsMock,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('should handle unauthenticated user', async () => {
@@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => {
expect(response.status).toBe(200)
expect(data.credentials).toHaveLength(0)
})
it('does not expose a managed credential requested by exact ID', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: true,
userId: 'user-123',
authType: 'session',
})
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'managed-credential-1',
workspaceId: 'workspace-1',
type: 'managed_oauth',
displayName: 'Managed Gmail',
providerId: 'google-email',
accountId: null,
updatedAt: new Date('2026-01-01T00:00:00Z'),
accountProviderId: null,
accountScope: null,
accountUpdatedAt: null,
},
])
const response = await GET(
createMockRequestWithQuery('GET', '?credentialId=managed-credential-1')
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ credentials: [] })
})
})
+187 -1
View File
@@ -12,8 +12,17 @@ import {
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
const {
mockAuthenticateManagedOAuthDelegation,
mockAuthorizeCredentialUse,
mockGetToolMetadata,
mockResolveManagedOAuthCredentialToken,
mockResolveServiceAccountToken,
} = vi.hoisted(() => ({
mockAuthenticateManagedOAuthDelegation: vi.fn(),
mockAuthorizeCredentialUse: vi.fn(),
mockGetToolMetadata: vi.fn(),
mockResolveManagedOAuthCredentialToken: vi.fn(),
mockResolveServiceAccountToken: vi.fn(),
}))
@@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({
authorizeCredentialUseForAuth: mockAuthorizeCredentialUse,
}))
vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation,
InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {},
}))
vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
}))
vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { GET, POST } from '@/app/api/auth/oauth/token/route'
@@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => {
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
})
it('does not authenticate managed delegation for an ordinary OAuth credential', async () => {
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
refreshToken: 'refresh-token',
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
providerId: 'google',
})
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
accessToken: 'fresh-token',
refreshed: false,
})
const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'credential-id', workflowId: 'workflow-id' },
{ 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' }
)
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' })
expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
const req = createMockRequest('POST', {})
@@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => {
)
})
describe('managed OAuth path', () => {
const managedCredential = {
accountId: '',
credentialId: 'managed-credential-id',
credentialType: 'managed_oauth',
providerId: 'google-email',
workspaceId: 'workspace-id',
usedCredentialTable: true,
}
beforeEach(() => {
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
mockGetToolMetadata.mockReturnValue({
oauth: {
required: true,
provider: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
},
})
})
it('fails closed when workflow delegation is missing', async () => {
const response = await POST(
createMockRequest('POST', {
credentialId: 'managed-credential-id',
toolId: 'gmail_read',
})
)
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
})
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
})
it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
subjectUserId: 'user-id',
workspaceId: 'workspace-id',
delegationId: 'delegation-id',
audience: 'sim:managed-oauth-credentials',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialId: 'managed-credential-id' },
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-id',
},
}
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
accessToken: 'managed-access-token',
refreshed: false,
})
const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
)
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
principal,
input: {
credentialId: 'managed-credential-id',
expectedProviderId: 'google-email',
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
toolId: 'gmail_read',
},
request: expect.any(NextRequest),
})
})
it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
mockGetToolMetadata.mockReturnValueOnce({
oauth: {
required: true,
provider: 'slack',
},
})
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
subjectUserId: 'user-id',
workspaceId: 'workspace-id',
delegationId: 'delegation-id',
audience: 'sim:managed-oauth-credentials',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialId: 'managed-credential-id' },
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-id',
},
}
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
accessToken: 'managed-slack-token',
refreshed: false,
})
const response = await POST(
createMockRequest(
'POST',
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
)
)
expect(response.status).toBe(200)
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
principal,
input: {
credentialId: 'managed-credential-id',
expectedProviderId: 'slack',
requiredScopes: expect.arrayContaining([
'channels:read',
'channels:history',
'chat:write',
]),
toolId: 'slack_message',
},
request: expect.any(NextRequest),
})
})
})
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
+137 -1
View File
@@ -1,19 +1,30 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
MANAGED_OAUTH_DELEGATION_HEADER,
oauthTokenGetContract,
oauthTokenPostContract,
} from '@/lib/api/contracts/oauth-connections'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredential, getOAuthToken } from '@/lib/oauth/credential-service'
import {
authenticateManagedOAuthDelegation,
InvalidManagedOAuthDelegationError,
} from '@/lib/credentials/application/managed-oauth-delegation'
import { resolveManagedOAuthCredentialToken } from '@/lib/credentials/application/resolve-managed-oauth-token'
import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth'
import { getCredential, getOAuthToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
import { completeOAuthCredentialToken, resolveCredentialToken } from '@/lib/oauth/token-resolution'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { getToolMetadata } from '@/tools/metadata'
export const dynamic = 'force-dynamic'
@@ -50,6 +61,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
credentialId,
credentialAccountUserId,
providerId,
toolId,
workflowId,
scopes,
impersonateEmail,
@@ -115,6 +127,129 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}
const resolved = credentialId ? await resolveOAuthAccountId(credentialId) : null
if (resolved?.credentialType === 'managed_oauth' && resolved.credentialId) {
const managedOAuthDelegation = parsed.data.headers?.[MANAGED_OAUTH_DELEGATION_HEADER]
if (!managedOAuthDelegation) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
error: 'Managed credentials can only be used by an authenticated workflow execution',
},
{ status: 403 }
)
}
let managedOAuthPrincipal: WorkflowExecutionDelegatedPrincipal
try {
managedOAuthPrincipal = await authenticateManagedOAuthDelegation(
managedOAuthDelegation,
resolved.credentialId
)
} catch (error) {
if (!(error instanceof InvalidManagedOAuthDelegationError)) throw error
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_DELEGATION_INVALID',
error: error.message,
},
{ status: 401 }
)
}
if (!toolId) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_REQUIRED',
error: 'A tool ID is required to use a managed credential',
},
{ status: 400 }
)
}
const toolMetadata = getToolMetadata(toolId)
if (!toolMetadata?.oauth?.required) {
logger.error(`[${requestId}] Tool is not configured for managed OAuth`, { toolId })
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
error: 'This tool is not configured to use managed credentials',
},
{ status: 500 }
)
}
const requiredScopes =
toolMetadata.oauth.requiredScopes ??
getCanonicalScopesForProvider(toolMetadata.oauth.provider)
if (requiredScopes.length === 0) {
logger.error(`[${requestId}] Tool has no trusted OAuth scope policy`, {
toolId,
providerId: toolMetadata.oauth.provider,
})
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_TOOL_UNSUPPORTED',
error: 'This tool is not configured to use managed credentials',
},
{ status: 500 }
)
}
try {
const result = await resolveManagedOAuthCredentialToken.execute({
principal: managedOAuthPrincipal,
input: {
credentialId: resolved.credentialId,
expectedProviderId: toolMetadata.oauth.provider,
requiredScopes,
toolId,
},
request,
})
captureServerEvent(
managedOAuthPrincipal.subjectUserId,
'credential_used',
{
credential_type: 'managed_oauth',
provider_id: toolMetadata.oauth.provider,
workspace_id: managedOAuthPrincipal.workspaceId,
},
{ groups: { workspace: managedOAuthPrincipal.workspaceId } }
)
return NextResponse.json(
{
accessToken: result.accessToken,
...(result.idToken ? { idToken: result.idToken } : {}),
},
{ status: 200 }
)
} catch (error) {
if (error instanceof ManagedOAuthCredentialError) {
logger.warn(`[${requestId}] Managed OAuth credential rejected`, {
credentialId: resolved.credentialId,
code: error.code,
})
return NextResponse.json(
{ code: error.code, error: error.message },
{ status: error.statusCode }
)
}
const orchestrationError = asOrchestrationError(error)
if (orchestrationError) {
return NextResponse.json(
{
code: 'MANAGED_CREDENTIAL_UNAUTHORIZED',
error: orchestrationError.message,
},
{ status: statusForOrchestrationError(orchestrationError.code) }
)
}
throw error
}
}
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
const result = await resolveCredentialToken(auth, {
requestId,
@@ -124,6 +259,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
impersonateEmail,
callerUserId,
auditRequest: request,
resolvedCredential: resolved,
})
if (!result.ok) {
@@ -0,0 +1,89 @@
/**
* @vitest-environment node
*/
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
complete: vi.fn(),
ipRateLimit: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
authenticateCredentialGroupEnrollment: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
completePublicCredentialGroupEnrollment: { execute: mocks.complete },
}))
vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit,
}))
import { POST } from '@/app/api/credential-groups/enroll/[token]/complete/route'
const principal = {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'alex@example.com',
invitationTokenHash: 'hash-1',
} as const
const context = { params: Promise.resolve({ token: 'invitation-token' }) }
function request() {
return new NextRequest(
'http://localhost:3000/api/credential-groups/enroll/invitation-token/complete',
{ method: 'POST' }
)
}
describe('credential group enrollment completion route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ipRateLimit.mockResolvedValue(null)
mocks.authenticate.mockResolvedValue(principal)
mocks.complete.mockResolvedValue({ completed: true })
})
it('submits a fully connected enrollment through its invitation principal', async () => {
const enrollmentRequest = request()
const response = await POST(enrollmentRequest, context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?submitted=1'
)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(mocks.complete).toHaveBeenCalledWith({
principal,
input: {},
request: enrollmentRequest,
})
})
it('redirects an incomplete enrollment without marking it complete', async () => {
mocks.complete.mockResolvedValue({ completed: false })
const response = await POST(request(), context)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=incomplete'
)
})
it('stops before token lookup when the public IP budget is exhausted', async () => {
mocks.ipRateLimit.mockResolvedValue(
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
)
const response = await POST(request(), context)
expect(response.status).toBe(429)
expect(mocks.authenticate).not.toHaveBeenCalled()
expect(mocks.complete).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,44 @@
import type { NextRequest } from 'next/server'
import { completeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'complete')
if (limited) return limited
const parsed = await parseRequest(completeCredentialGroupEnrollmentContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
const completion = await completePublicCredentialGroupEnrollment
.execute({ principal, input: {}, request })
.catch((error: unknown) => {
if (asOrchestrationError(error)?.code === 'not_found') return null
throw error
})
if (!completion) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
const { completed } = completion
if (completed === null) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
return createCredentialGroupEnrollmentRedirect(
token,
completed ? { submitted: '1' } : { oauth: 'incomplete' }
)
}
)
@@ -0,0 +1,112 @@
/**
* @vitest-environment node
*/
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
startOAuth: vi.fn(),
ipRateLimit: vi.fn(),
enrollmentRateLimit: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
authenticateCredentialGroupEnrollment: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
startPublicCredentialGroupOAuth: { execute: mocks.startOAuth },
}))
vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mocks.ipRateLimit,
enforceCredentialGroupEnrollmentOAuthRateLimit: mocks.enrollmentRateLimit,
}))
import { GET } from '@/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route'
const principal = {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'alex@example.com',
invitationTokenHash: 'hash-1',
} as const
const context = {
params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }),
}
function request() {
return new NextRequest(
'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1'
)
}
describe('credential group OAuth start route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ipRateLimit.mockResolvedValue(null)
mocks.enrollmentRateLimit.mockResolvedValue(null)
mocks.authenticate.mockResolvedValue(principal)
mocks.startOAuth.mockResolvedValue({
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=state-1',
})
})
it('redirects a valid enrollment to Google through its application operation', async () => {
const oauthRequest = request()
const response = await GET(oauthRequest, context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toContain('https://accounts.google.com/')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(mocks.startOAuth).toHaveBeenCalledWith({
principal,
input: { invitationToken: 'invitation-token', optionId: 'option-1' },
request: oauthRequest,
})
})
it('returns an unavailable enrollment to its public page', async () => {
mocks.authenticate.mockResolvedValue(null)
const response = await GET(request(), context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=unavailable'
)
expect(response.headers.get('cache-control')).toBe('no-store')
expect(mocks.startOAuth).not.toHaveBeenCalled()
})
it('returns a rate-limited OAuth start to its enrollment page before token lookup', async () => {
mocks.ipRateLimit.mockResolvedValue(
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
)
const response = await GET(request(), context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
)
expect(mocks.authenticate).not.toHaveBeenCalled()
})
it('returns an exhausted enrollment OAuth budget to the enrollment page', async () => {
mocks.enrollmentRateLimit.mockResolvedValue(
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
)
const response = await GET(request(), context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
)
expect(mocks.startOAuth).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { startCredentialGroupOAuthContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { startPublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import {
enforceCredentialGroupEnrollmentOAuthRateLimit,
enforcePublicCredentialGroupIpRateLimit,
} from '@/lib/credential-groups/rate-limit'
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const logger = createLogger('CredentialGroupOAuthStartAPI')
export const GET = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ token: string; optionId: string }> }
) => {
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-start')
const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context)
if (!parsed.success) return limited ?? parsed.response
const { token, optionId } = parsed.data.params
if (limited) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
}
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' })
}
const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
principal.enrollmentId
)
if (enrollmentLimited) {
return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' })
}
try {
const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({
principal,
input: { invitationToken: token, optionId },
request,
})
const response = NextResponse.redirect(authorizationUrl)
response.headers.set('Cache-Control', 'no-store')
response.headers.set('Referrer-Policy', 'no-referrer')
return response
} catch (error) {
logger.error('Failed to start managed OAuth authorization', {
error: getErrorMessage(error),
})
return createCredentialGroupEnrollmentRedirect(token, {
oauth:
error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
: 'unavailable',
})
}
}
)
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
export function createCredentialGroupEnrollmentRedirect(
token: string,
params: Record<string, string>
): NextResponse {
const query = new URLSearchParams(params).toString()
const location = `/credential-groups/enroll/${encodeURIComponent(token)}${query ? `?${query}` : ''}`
return new NextResponse(null, {
status: 307,
headers: {
Location: location,
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
},
})
}
@@ -0,0 +1,128 @@
/**
* @vitest-environment node
*/
import { NextRequest, NextResponse } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
completeOAuth: vi.fn(),
consumeAttempt: vi.fn(),
rateLimit: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({
authenticateCredentialGroupEnrollment: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({
completePublicCredentialGroupOAuth: { execute: mocks.completeOAuth },
}))
vi.mock('@/lib/credential-groups/oauth-state', () => ({
consumeCredentialGroupOAuthAttempt: mocks.consumeAttempt,
}))
vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit,
}))
import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route'
const principal = {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'alex@example.com',
invitationTokenHash: 'hash-1',
} as const
const attempt = {
provider: 'gmail',
invitationToken: 'invitation-token',
optionId: 'option-1',
}
const context = { params: Promise.resolve({ provider: 'gmail' }) }
function request(query: string) {
return new NextRequest(
`http://localhost:3000/api/credential-groups/oauth/gmail/callback?${query}`
)
}
describe('credential group OAuth callback', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.rateLimit.mockResolvedValue(null)
mocks.consumeAttempt.mockResolvedValue(attempt)
mocks.authenticate.mockResolvedValue(principal)
mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' })
})
it('consumes provider-bound state and enters the application operation', async () => {
const callbackRequest = request('state=state-1&code=code-1')
const response = await GET(callbackRequest, context)
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
expect(mocks.completeOAuth).toHaveBeenCalledWith({
principal,
input: { attempt, code: 'code-1' },
request: callbackRequest,
})
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?connected=option-1'
)
})
it('returns without exchanging when the user denies consent', async () => {
const response = await GET(request('state=state-1&error=access_denied'), context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=denied'
)
expect(mocks.authenticate).not.toHaveBeenCalled()
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
it('rejects replayed, expired, or cross-provider state', async () => {
mocks.consumeAttempt.mockResolvedValue(null)
const replayedResponse = await GET(request('state=state-1&code=code-1'), context)
expect(replayedResponse.status).toBe(400)
mocks.consumeAttempt.mockResolvedValue({ ...attempt, provider: 'slack' })
const mismatchedResponse = await GET(request('state=state-2&code=code-2'), context)
expect(mismatchedResponse.status).toBe(400)
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => {
mocks.authenticate.mockResolvedValue(null)
const response = await GET(request('state=state-1&code=code-1'), context)
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=unavailable'
)
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
it('returns a valid rate-limited callback to the enrollment page without exchanging', async () => {
mocks.rateLimit.mockResolvedValue(
NextResponse.json({ error: 'Too many requests' }, { status: 429 })
)
const response = await GET(request('state=state-1&code=code-1'), context)
expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1')
expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe(
'/credential-groups/enroll/invitation-token?oauth=rate_limited'
)
expect(mocks.authenticate).not.toHaveBeenCalled()
expect(mocks.completeOAuth).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,92 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { credentialGroupOAuthCallbackContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
const logger = createLogger('CredentialGroupOAuthCallbackAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ provider: string }> }) => {
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'oauth-callback')
const parsed = await parseRequest(credentialGroupOAuthCallbackContract, request, context)
if (!parsed.success) return limited ?? parsed.response
const { provider } = parsed.data.params
const { state, code, error: providerError } = parsed.data.query
let attempt
try {
attempt = await consumeCredentialGroupOAuthAttempt(state)
} catch (error) {
logger.error('Failed to consume credential group OAuth state', {
error: getErrorMessage(error),
})
return NextResponse.json(
{ error: 'Authorization state is unavailable. Please try again.' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } }
)
}
if (!attempt || attempt.provider !== provider) {
if (limited) return limited
return NextResponse.json(
{ error: 'Authorization state is invalid or expired.' },
{ status: 400, headers: { 'Cache-Control': 'no-store' } }
)
}
if (limited) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
oauth: 'rate_limited',
})
}
if (providerError) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' })
}
if (!code) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' })
}
const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken)
if (!principal) {
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
oauth: 'unavailable',
})
}
try {
await completePublicCredentialGroupOAuth.execute({
principal,
input: { attempt, code },
request,
})
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
connected: attempt.optionId,
})
} catch (error) {
logger.error('Managed OAuth authorization failed', {
provider,
error: getErrorMessage(error),
})
const status =
error instanceof CredentialGroupOAuthError && error.statusCode === 403
? error.message.startsWith('Sign in with')
? 'account_mismatch'
: 'permissions_required'
: error instanceof CredentialGroupOAuthError && error.statusCode === 409
? 'configuration_changed'
: 'failed'
return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status })
}
}
)
@@ -0,0 +1,123 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { slackCredentialGroupConfigurationCallbackContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { completeSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
const logger = createLogger('SlackCredentialGroupConfigurationCallbackAPI')
const CHANNEL_NAME = 'slack-managed-users'
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function jsonLiteral(value: unknown): string {
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
}
function closePopup(params: {
ok: boolean
message: string
state?: string
credentialGroupId?: string
slackBotCredentialId?: string
reason: string
}): NextResponse {
const title = params.ok ? 'Slack configured' : 'Slack setup failed'
const payload = {
type: CHANNEL_NAME,
ok: params.ok,
state: params.state,
credentialGroupId: params.credentialGroupId,
slackBotCredentialId: params.slackBotCredentialId,
reason: params.reason,
}
const body = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><p>${escapeHtml(params.message)}</p><script>
try { var channel = new BroadcastChannel(${jsonLiteral(CHANNEL_NAME)}); channel.postMessage(${jsonLiteral(payload)}); channel.close() } catch (error) {}
setTimeout(function () { window.close() }, 500)
</script></body></html>`
return new NextResponse(body, {
headers: {
'Cache-Control': 'no-store, max-age=0',
'Content-Type': 'text/html; charset=utf-8',
},
})
}
export const GET = withRouteHandler(async (request: NextRequest) => {
const rawState = new URL(request.url).searchParams.get('state')?.slice(0, 512)
const session = await getSession()
if (!session?.user?.id || !session.session?.id) {
return closePopup({
ok: false,
message: 'Sign in to Sim to complete this Slack setup.',
state: rawState,
reason: 'unauthenticated',
})
}
const parsed = await parseRequest(slackCredentialGroupConfigurationCallbackContract, request, {})
if (!parsed.success) {
return closePopup({
ok: false,
message: 'Slack returned an invalid authorization response.',
state: rawState,
reason: 'invalid_callback',
})
}
const { state, code, error: providerError } = parsed.data.query
try {
const result = await completeSlackCredentialGroupConfiguration.execute({
principal: {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
},
input: { state, code, providerError },
request,
})
return result.ok
? closePopup({
ok: true,
message: 'Slack is ready for this Credential Group. You can close this window.',
state,
credentialGroupId: result.result.credentialGroupId,
slackBotCredentialId: result.result.slackBotCredentialId,
reason: result.reason,
})
: closePopup({
ok: false,
message: 'Slack authorization was cancelled.',
state,
reason: result.reason,
})
} catch (error) {
const orchestrationError = asOrchestrationError(error)
const message =
error instanceof SlackManagedUsersError || orchestrationError
? getErrorMessage(error)
: 'Slack setup failed. Please try again.'
logger.error('Slack Credential Group configuration callback failed', {
error: getErrorMessage(error),
})
return closePopup({
ok: false,
message,
state,
reason:
error instanceof SlackManagedUsersError
? error.code
: (orchestrationError?.code ?? 'unknown'),
})
}
})
@@ -27,12 +27,19 @@ interface RouteContext {
async function requireCredentialAdmin(credentialId: string, userId: string) {
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) return null
if (!cred || cred.type === 'managed_oauth') {
return null
}
const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId)
if (perm === null) return null
@@ -67,12 +74,17 @@ export const GET = withRouteHandler(async (_request: NextRequest, context: Route
const { id: credentialId } = await context.params
const [cred] = await db
.select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.select({
id: credential.id,
workspaceId: credential.workspaceId,
type: credential.type,
providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!cred) {
if (!cred || cred.type === 'managed_oauth') {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
+15 -4
View File
@@ -50,7 +50,7 @@ export const GET = withRouteHandler(
try {
const access = await getCredentialActorContext(id, session.user.id)
if (!access.credential) {
if (!access.credential || access.credential.type === 'managed_oauth') {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!canUseCredential(access)) {
@@ -82,6 +82,11 @@ export const PUT = withRouteHandler(
const { id } = parsed.data.params
const body = parsed.data.body
const currentAccess = await getCredentialActorContext(id, session.user.id)
if (!currentAccess.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const result = await performUpdateCredential({
credentialId: id,
userId: session.user.id,
@@ -149,6 +154,10 @@ export const DELETE = withRouteHandler(
const { id } = await params
try {
const currentAccess = await getCredentialActorContext(id, session.user.id)
if (!currentAccess.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const result = await performDeleteCredential({
credentialId: id,
userId: session.user.id,
@@ -162,9 +171,11 @@ export const DELETE = withRouteHandler(
? 404
: result.errorCode === 'forbidden'
? 403
: result.errorCode === 'validation'
? 400
: 500
: result.errorCode === 'conflict'
? 409
: result.errorCode === 'validation'
? 400
: 500
return NextResponse.json({ error: result.error }, { status })
}
+6 -1
View File
@@ -35,7 +35,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (credentialId) {
const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess })
if (!access.credential || access.credential.workspaceId !== workspaceId || !access.isAdmin) {
if (
!access.credential ||
access.credential.type === 'managed_oauth' ||
access.credential.workspaceId !== workspaceId ||
!access.isAdmin
) {
return NextResponse.json(
{ error: 'Admin access required on the target credential' },
{ status: 403 }
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials'
import { getValidationErrorMessage } from '@/lib/api/server'
@@ -31,7 +31,9 @@ export const GET = withRouteHandler(async () => {
})
.from(credentialMember)
.innerJoin(credential, eq(credentialMember.credentialId, credential.id))
.where(eq(credentialMember.userId, session.user.id))
.where(
and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth'))
)
return NextResponse.json({ memberships }, { status: 200 })
} catch (error) {
+10 -3
View File
@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
createWorkspaceCredentialContract,
@@ -167,7 +167,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
providerId: credential.providerId,
})
.from(credential)
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
.where(
and(
eq(credential.id, lookupCredentialId),
eq(credential.workspaceId, workspaceId),
ne(credential.type, 'managed_oauth')
)
)
.limit(1)
if (!row) {
@@ -182,7 +188,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
.where(
and(
eq(credential.accountId, lookupCredentialId),
eq(credential.workspaceId, workspaceId)
eq(credential.workspaceId, workspaceId),
ne(credential.type, 'managed_oauth')
)
)
.limit(1)
@@ -251,6 +251,11 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
}
case 'delegated':
throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads')
case 'credential_group_enrollment':
throw new UploadSessionError(
'forbidden',
'Credential Group enrollment principals cannot create uploads'
)
}
}
@@ -0,0 +1,31 @@
import { resendCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { resendCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
export const POST = defineInternalJsonRoute({
contract: resendCredentialGroupEnrollmentContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.resendEnrollment,
rateLimit: internalRateLimits.none({
reason: 'Credential Group invitation resends use a shared per-workspace delivery budget',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
'Failed to resend credential group enrollment'
),
async mapInput({ params }) {
await enforceCredentialGroupInvitationRouteRateLimit(params.id)
return {
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
enrollmentId: params.enrollmentId,
}
},
useCase: resendCredentialGroupEnrollmentSettings,
})
@@ -0,0 +1,27 @@
import { revokeCredentialGroupEnrollmentContract } from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { revokeCredentialGroupEnrollmentSettings } from '@/lib/credential-groups/application/manage-enrollments'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
export const DELETE = defineInternalJsonRoute({
contract: revokeCredentialGroupEnrollmentContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.revokeEnrollment,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal Credential Group revocation behavior',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
'Failed to revoke credential group enrollment'
),
mapInput: ({ params }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
enrollmentId: params.enrollmentId,
}),
useCase: revokeCredentialGroupEnrollmentSettings,
})
@@ -0,0 +1,126 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
getSession: vi.fn(),
rateLimit: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
vi.mock('@/lib/credential-groups/application/manage-enrollments', () => ({
inviteCredentialGroupEnrollmentsSettings: {
operation: { id: 'credential_groups.invites.send_batch' },
execute: mocks.execute,
},
}))
vi.mock('@/lib/credential-groups/rate-limit', () => {
class CredentialGroupInvitationRateLimitError extends Error {
readonly statusCode = 429
constructor(
readonly retryAfterSeconds: number,
readonly resetAt: Date
) {
super('Rate limit exceeded')
}
}
return {
CredentialGroupInvitationRateLimitError,
enforceCredentialGroupInvitationRouteRateLimit: mocks.rateLimit,
}
})
import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit'
import { POST } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/enrollments/route'
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
const GROUP_ID = 'group-1'
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }
function createRequest(body: unknown): NextRequest {
return new NextRequest(
`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/enrollments`,
{
method: 'POST',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
}
)
}
describe('credential group enrollment invitation route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.rateLimit.mockResolvedValue(undefined)
mocks.execute.mockResolvedValue({
results: [{ email: 'alex@example.com', success: false, error: 'Delivery failed' }],
sentCount: 0,
failedCount: 1,
})
})
it('authenticates before parsing the batch', async () => {
mocks.getSession.mockResolvedValue(null)
const response = await POST(createRequest({}), context)
expect(response.status).toBe(401)
expect(mocks.execute).not.toHaveBeenCalled()
})
it('sends the entire validated batch through one application command', async () => {
const body = { emails: ['alex@example.com', 'sam@example.com'] }
const request = createRequest(body)
const response = await POST(request, context)
expect(response.status).toBe(200)
expect(mocks.execute).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: {
assertedWorkspaceId: WORKSPACE_ID,
credentialGroupId: GROUP_ID,
emails: body.emails,
},
request,
})
expect(await response.json()).toMatchObject({ sentCount: 0, failedCount: 1 })
})
it('rejects a batch larger than 100 before admission or delivery', async () => {
const response = await POST(
createRequest({
emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`),
}),
context
)
expect(response.status).toBe(400)
expect(mocks.rateLimit).not.toHaveBeenCalled()
expect(mocks.execute).not.toHaveBeenCalled()
})
it('applies the shared workspace invitation rate limit', async () => {
mocks.rateLimit.mockRejectedValue(
new CredentialGroupInvitationRateLimitError(30, new Date('2026-08-14T12:00:00Z'))
)
const response = await POST(createRequest({ emails: ['alex@example.com'] }), context)
expect(response.status).toBe(429)
expect(mocks.rateLimit).toHaveBeenCalledWith(WORKSPACE_ID)
expect(response.headers.get('retry-after')).toBe('30')
expect(mocks.execute).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,31 @@
import { inviteCredentialGroupEnrollmentsContract } from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { enforceCredentialGroupInvitationRouteRateLimit } from '@/lib/credential-groups/rate-limit'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
export const POST = defineInternalJsonRoute({
contract: inviteCredentialGroupEnrollmentsContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.inviteBatch,
rateLimit: internalRateLimits.none({
reason: 'Credential Group invitations use a shared per-workspace delivery budget',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
'Failed to invite credential group enrollments'
),
async mapInput({ params, body }) {
await enforceCredentialGroupInvitationRouteRateLimit(params.id)
return {
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
emails: body.emails,
}
},
useCase: inviteCredentialGroupEnrollmentsSettings,
})
@@ -0,0 +1,63 @@
import {
deleteCredentialGroupContract,
getCredentialGroupContract,
updateCredentialGroupContract,
} from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
deleteCredentialGroupSettings,
getCredentialGroupSettings,
updateCredentialGroupSettings,
} from '@/lib/credential-groups/application/manage-groups'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
const rateLimit = internalRateLimits.none({
reason: 'Preserve existing internal Credential Group detail behavior',
})
export const GET = defineInternalJsonRoute({
contract: getCredentialGroupContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.readSettings,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to get credential group'),
mapInput: ({ params, query }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
limit: query.limit,
cursor: query.cursor,
}),
useCase: getCredentialGroupSettings,
})
export const PATCH = defineInternalJsonRoute({
contract: updateCredentialGroupContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.update,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update credential group'),
mapInput: ({ params, body }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
update: body,
}),
useCase: updateCredentialGroupSettings,
})
export const DELETE = defineInternalJsonRoute({
contract: deleteCredentialGroupContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.delete,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'),
mapInput: ({ params }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
}),
useCase: deleteCredentialGroupSettings,
})
@@ -0,0 +1,38 @@
import { startSlackCredentialGroupConfigurationContract } from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
extendInternalErrorPolicy,
internalErrorResponse,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users'
import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
const errorPolicy = extendInternalErrorPolicy(
createCredentialGroupInternalErrorPolicy('Failed to configure Slack for Credential Group'),
(error) =>
error instanceof SlackManagedUsersError
? internalErrorResponse(400, { error: error.message })
: null
)
export const POST = defineInternalJsonRoute({
contract: startSlackCredentialGroupConfigurationContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.startSlackConfiguration,
rateLimit: internalRateLimits.none({
reason: 'Slack applies provider authorization limits and setup requires a workspace admin',
}),
errorPolicy,
mapInput: ({ params, body }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
slackBotCredentialId: body.slackBotCredentialId,
clientId: body.clientId,
clientSecret: body.clientSecret,
}),
useCase: startSlackCredentialGroupConfiguration,
})
@@ -0,0 +1,43 @@
import {
createInternalResourceConcealmentPolicy,
type InternalErrorPolicy,
internalErrorResponse,
internalOrchestrationErrorPolicy,
} from '@/lib/api/server/routes'
import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments'
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
import { CredentialGroupInvitationRateLimitError } from '@/lib/credential-groups/rate-limit'
export function createCredentialGroupInternalErrorPolicy(
unhandledMessage: string,
notFoundMessage = 'Credential group not found'
): InternalErrorPolicy {
if (!unhandledMessage.trim()) {
throw new Error('Credential Group error policy requires an unhandled message')
}
const base: InternalErrorPolicy = {
project(error) {
if (error instanceof CredentialGroupProviderConfigurationError) {
return internalErrorResponse(503, { error: error.message })
}
if (error instanceof CredentialGroupEnrollmentError) {
return internalErrorResponse(error.status, { error: error.message })
}
if (error instanceof CredentialGroupInvitationRateLimitError) {
return internalErrorResponse(
429,
{ error: error.message, retryAfter: error.resetAt.getTime() },
{
'Retry-After': String(error.retryAfterSeconds),
'X-RateLimit-Reset': error.resetAt.toISOString(),
}
)
}
return internalOrchestrationErrorPolicy.project(error)
},
unhandled() {
return internalErrorResponse(500, { error: unhandledMessage })
},
}
return createInternalResourceConcealmentPolicy({ base, notFoundMessage })
}
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
create: vi.fn(),
getSession: vi.fn(),
list: vi.fn(),
}))
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
vi.mock('@/lib/credential-groups/application/manage-groups', () => ({
createCredentialGroupSettings: {
operation: { id: 'credential_groups.create' },
execute: mocks.create,
},
listCredentialGroupSettings: {
operation: { id: 'credential_groups.settings.list' },
execute: mocks.list,
},
}))
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route'
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
const context = { params: Promise.resolve({ id: WORKSPACE_ID }) }
function createRequest(method: 'GET' | 'POST', body?: Record<string, unknown>): NextRequest {
return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, {
method,
...(body
? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }
: {}),
})
}
describe('credential groups collection route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.list.mockResolvedValue({ credentialGroups: [] })
})
it('authenticates before parsing the request body', async () => {
mocks.getSession.mockResolvedValue(null)
const response = await POST(createRequest('POST', {}), context)
expect(response.status).toBe(401)
expect(mocks.create).not.toHaveBeenCalled()
})
it('enters the application use case with the authenticated session principal', async () => {
const request = createRequest('GET')
const response = await GET(request, context)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ credentialGroups: [] })
expect(mocks.list).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: WORKSPACE_ID },
request,
})
})
it('preserves concealed entitlement failures from the application boundary', async () => {
mocks.list.mockRejectedValue(
new OrchestrationError('not_found', 'Credential Groups are not available')
)
const response = await GET(createRequest('GET'), context)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'Credential Groups are not available' })
})
it('fails fast when managed Gmail OAuth is not configured', async () => {
mocks.create.mockRejectedValue(
new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured')
)
const response = await POST(
createRequest('POST', {
name: 'Support inboxes',
options: [{ provider: 'gmail', label: 'Gmail', required: true }],
}),
context
)
expect(response.status).toBe(503)
expect(await response.json()).toEqual({
error: 'Managed Gmail authorization is not configured',
})
})
})
@@ -0,0 +1,45 @@
import {
createCredentialGroupContract,
listCredentialGroupsContract,
} from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
createCredentialGroupSettings,
listCredentialGroupSettings,
} from '@/lib/credential-groups/application/manage-groups'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
export const GET = defineInternalJsonRoute({
contract: listCredentialGroupsContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.listSettings,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal Credential Group list behavior',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
'Failed to list credential groups',
'Workspace not found'
),
mapInput: ({ params }) => ({ workspaceId: params.id }),
useCase: listCredentialGroupSettings,
})
export const POST = defineInternalJsonRoute({
contract: createCredentialGroupContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.create,
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal Credential Group create behavior',
}),
errorPolicy: createCredentialGroupInternalErrorPolicy(
'Failed to create credential group',
'Workspace not found'
),
mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }),
useCase: createCredentialGroupSettings,
})
@@ -0,0 +1,27 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@sim/emcn', () => ({
chipVariants: () => 'chip',
}))
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
describe('OAuthConnectLink', () => {
it('presents enrollment authorization as Connect', () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
const root = createRoot(container)
act(() => root.render(<OAuthConnectLink href='/oauth/start' />))
const link = container.querySelector('a')
expect(link?.textContent).toBe('Connect')
expect(link?.getAttribute('href')).toBe('/oauth/start')
act(() => root.unmount())
})
})
@@ -0,0 +1,15 @@
'use client'
import { chipVariants } from '@sim/emcn'
interface OAuthConnectLinkProps {
href: string
}
export function OAuthConnectLink({ href }: OAuthConnectLinkProps) {
return (
<a href={href} className={chipVariants()}>
Connect
</a>
)
}
@@ -0,0 +1,74 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockError, mockSetOAuthStatus, mockSuccess } = vi.hoisted(() => ({
mockError: vi.fn(),
mockSetOAuthStatus: vi.fn().mockResolvedValue(null),
mockSuccess: vi.fn(),
}))
vi.mock('@sim/emcn', () => ({
useToast: () => ({
toast: {
error: mockError,
success: mockSuccess,
},
}),
}))
vi.mock('nuqs', () => ({
useQueryStates: () => [{}, mockSetOAuthStatus],
}))
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
function renderToast(variant: 'success' | 'error', message: string): Root {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const root = createRoot(document.createElement('div'))
act(() => root.render(<CredentialGroupOAuthToast message={message} variant={variant} />))
return root
}
describe('CredentialGroupOAuthToast', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows OAuth success once and removes callback state from the URL', () => {
const root = renderToast('success', 'Gmail connected successfully.')
expect(mockSuccess).toHaveBeenCalledOnce()
expect(mockSuccess).toHaveBeenCalledWith('Gmail connected successfully.')
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
{ connected: null, oauth: null, submitted: null },
{ history: 'replace', scroll: false }
)
act(() => root.unmount())
})
it('uses the error toast and preserves unrelated query parameters', () => {
const root = renderToast('error', 'Authorization was canceled.')
expect(mockError).toHaveBeenCalledWith('Authorization was canceled.')
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
{ connected: null, oauth: null, submitted: null },
{ history: 'replace', scroll: false }
)
act(() => root.unmount())
})
it('removes the submitted state after showing the completion toast', () => {
const root = renderToast('success', 'Accounts submitted successfully.')
expect(mockSuccess).toHaveBeenCalledWith('Accounts submitted successfully.')
expect(mockSetOAuthStatus).toHaveBeenCalledWith(
{ connected: null, oauth: null, submitted: null },
{ history: 'replace', scroll: false }
)
act(() => root.unmount())
})
})
@@ -0,0 +1,38 @@
'use client'
import { useEffect, useRef } from 'react'
import { useToast } from '@sim/emcn'
import { useQueryStates } from 'nuqs'
import {
credentialGroupEnrollmentStatusParsers,
credentialGroupEnrollmentStatusUrlKeys,
} from '@/app/credential-groups/enroll/[token]/search-params'
interface CredentialGroupOAuthToastProps {
message: string
variant: 'success' | 'error'
}
export function CredentialGroupOAuthToast({ message, variant }: CredentialGroupOAuthToastProps) {
const { toast } = useToast()
const [, setOAuthStatus] = useQueryStates(
credentialGroupEnrollmentStatusParsers,
credentialGroupEnrollmentStatusUrlKeys
)
const shownRef = useRef(false)
useEffect(() => {
if (shownRef.current) return
shownRef.current = true
if (variant === 'success') toast.success(message)
else toast.error(message)
void setOAuthStatus(
{ connected: null, oauth: null, submitted: null },
{ history: 'replace', scroll: false }
)
}, [message, setOAuthStatus, toast, variant])
return null
}
@@ -0,0 +1,191 @@
import { type ReactNode, Suspense } from 'react'
import { Chip, ToastProvider } from '@sim/emcn'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import { SupportFooter } from '@/app/(auth)/components'
import { LogoShell } from '@/app/(landing)/components'
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
export const metadata: Metadata = {
title: 'Connect accounts',
robots: { index: false, follow: false },
}
export const dynamic = 'force-dynamic'
interface CredentialGroupEnrollmentPageProps {
params: Promise<{ token: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
interface PageShellProps {
children: ReactNode
}
function PageShell({ children }: PageShellProps) {
return (
<ToastProvider>
<LogoShell footer={<SupportFooter position='static' />}>
<div className='mx-auto flex w-full max-w-[640px] flex-1 flex-col px-5 pt-16 pb-20 max-sm:pt-10'>
{children}
</div>
</LogoShell>
</ToastProvider>
)
}
function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) {
return (
<PageShell>
<div className='my-auto py-16 text-center'>
<h1 className='text-balance text-[32px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
{rateLimited ? 'Too many requests' : 'Invitation unavailable'}
</h1>
<p className='mx-auto mt-3 max-w-[440px] text-pretty text-[var(--text-muted)] text-base leading-relaxed'>
{rateLimited
? 'This link has been opened too many times. Wait a few minutes and try again.'
: 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'}
</p>
</div>
</PageShell>
)
}
const OAUTH_MESSAGES = {
denied: 'Authorization was canceled. Nothing was connected.',
account_mismatch: 'Choose the account matching the email address on this invitation.',
permissions_required: 'All requested permissions are required to connect this account.',
configuration_changed: 'This credential option changed. Reload the page and try again.',
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
incomplete: 'Connect every account before submitting.',
unavailable: 'Account authorization is temporarily unavailable. Please try again.',
failed: 'Account authorization did not complete. Please try again.',
} as const
function getSearchParam(
searchParams: Record<string, string | string[] | undefined>,
key: string
): string | undefined {
const value = searchParams[key]
return Array.isArray(value) ? value[0] : value
}
export default async function CredentialGroupEnrollmentPage({
params,
searchParams,
}: CredentialGroupEnrollmentPageProps) {
const requestHeaders = await headers()
const limited = await enforcePublicCredentialGroupIpRateLimit(
{ headers: requestHeaders },
'metadata'
)
if (limited) return <UnavailableInvitation rateLimited />
const { token } = await params
if (!token || token.length > 128) return <UnavailableInvitation />
const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) return <UnavailableInvitation />
const enrollmentResult = await readPublicCredentialGroupEnrollment
.execute({ principal, input: {} })
.catch((error: unknown) => {
if (asOrchestrationError(error)?.code === 'not_found') return null
throw error
})
if (!enrollmentResult) return <UnavailableInvitation />
const { enrollment } = enrollmentResult
const resolvedSearchParams = await searchParams
const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth')
const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected')
const submitted = getSearchParam(resolvedSearchParams, 'submitted')
const oauthMessage =
oauthStatus && oauthStatus in OAUTH_MESSAGES
? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES]
: null
const activeOptions = enrollment.options.filter((option) => option.status === 'active')
const connectedOption = connectedOptionId
? activeOptions.find((option) => option.id === connectedOptionId)
: undefined
const notification = submitted
? { message: 'Accounts submitted successfully.', variant: 'success' as const }
: connectedOptionId
? {
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
variant: 'success' as const,
}
: oauthMessage
? { message: oauthMessage, variant: 'error' as const }
: null
const allConnected =
activeOptions.length > 0 &&
activeOptions.every(
(option) => option.connections.length === 1 && option.connections[0]?.status === 'connected'
)
return (
<PageShell>
{notification && (
<Suspense fallback={null}>
<CredentialGroupOAuthToast {...notification} />
</Suspense>
)}
<header>
<h1 className='text-balance text-[36px] text-[var(--text-primary)] leading-[108%] tracking-[-0.025em] max-sm:text-[32px]'>
Connect your accounts
</h1>
<p className='mt-4 max-w-[560px] text-pretty text-[var(--text-muted)] text-base leading-relaxed'>
<span className='font-medium text-[var(--text-body)]'>{enrollment.inviterName}</span>{' '}
invited you to connect accounts for{' '}
<span className='font-medium text-[var(--text-body)]'>{enrollment.workspaceName}</span>.
</p>
</header>
<div className='mt-8'>
<SettingsSection label='Accounts'>
<div className={RESOURCE_LIST_STACK}>
{activeOptions.map((option) => {
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
const connection = option.connections[0]
return (
<SettingsResourceRow
key={option.id}
icon={<ProviderIcon />}
title={option.label}
description={connection?.email ?? 'Not connected'}
trailing={
<OAuthConnectLink
href={`/api/credential-groups/enroll/${token}/oauth/${option.id}`}
/>
}
/>
)
})}
</div>
</SettingsSection>
{(allConnected || enrollment.status === 'completed') && (
<form
action={`/api/credential-groups/enroll/${token}/complete`}
method='post'
className='mt-6 flex justify-end'
>
<Chip type='submit' variant='primary' disabled={enrollment.status === 'completed'}>
{enrollment.status === 'completed' ? 'Submitted' : 'Submit'}
</Chip>
</form>
)}
</div>
</PageShell>
)
}
@@ -0,0 +1,13 @@
import { parseAsString } from 'nuqs/server'
/** One-shot OAuth result signals are nullable because absence means no toast. */
export const credentialGroupEnrollmentStatusParsers = {
connected: parseAsString,
oauth: parseAsString,
submitted: parseAsString,
} as const
export const credentialGroupEnrollmentStatusUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -60,11 +60,13 @@ vi.mock('@/lib/billing/core/subscription', () => ({
}))
vi.mock('@/lib/core/config/env', () => ({
env: {},
getEnv: vi.fn(),
isTruthy: vi.fn(() => false),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isAppConfigEnabled: false,
isBillingEnabled: true,
isHosted: true,
}))
@@ -14,6 +14,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { hasWorkspaceInboxAccess, hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access'
import { isPlatformAdmin } from '@/lib/permissions/super-user'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
@@ -48,6 +49,7 @@ const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => stri
const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSection>> = {
teammates: 'teammates',
secrets: 'secrets',
'credential-groups': 'credential-groups',
byok: 'byok',
sandboxes: 'sandboxes',
'custom-tools': 'custom-tools',
@@ -118,14 +120,16 @@ export default async function WorkspaceSettingsSectionPage({
const workspaceSection = WORKSPACE_SECTION_MAP[parsed]
if (workspaceSection) {
const [permissionGroup, forksAvailable, inboxAvailable, sandboxes] = await Promise.all([
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
: null,
isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id),
hasWorkspaceInboxAccess(workspaceId),
hasWorkspaceSandboxAccess(workspaceId),
])
const [permissionGroup, forksAvailable, inboxAvailable, sandboxes, credentialGroupsAvailable] =
await Promise.all([
hostContext.hostOrganizationId && hostContext.ownerBilling.isEnterprise
? resolveWorkspaceGroup(session.user.id, hostContext.hostOrganizationId, workspaceId)
: null,
isForkingAvailableForWorkspace(hostContext.hostOrganizationId, session.user.id),
hasWorkspaceInboxAccess(workspaceId),
hasWorkspaceSandboxAccess(workspaceId),
isCredentialGroupsAvailable(hostContext.ownerBilling),
])
const customBlocksAvailable = isHosted
? hostContext.ownerBilling.isEnterprise
: isTruthy(getEnv('NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED'))
@@ -134,6 +138,7 @@ export default async function WorkspaceSettingsSectionPage({
permissionConfig: permissionGroup?.config ?? {},
entitlements: {
byok: isHosted,
credentialGroups: credentialGroupsAvailable,
inbox: inboxAvailable,
customBlocks: customBlocksAvailable,
forks: forksAvailable,
@@ -80,6 +80,30 @@ export const groupIdUrlKeys = {
clearOnDefault: true,
} as const
/** `credential-group-id` deep-links Credential Groups to one collection's detail view. */
export const credentialGroupIdParam = {
key: 'credential-group-id',
parser: parseAsString,
} as const
/** Opening a credential group is a destination; closing replaces the detail URL. */
export const credentialGroupIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
/** Active view inside a credential-group detail page. */
export const credentialGroupTabParam = {
key: 'credential-group-tab',
parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'),
} as const
/** Tab view-state: clean URLs, no back-stack churn. */
export const credentialGroupTabUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
/**
* `group-tab` is the active tab inside the deep-linked permission-group detail
* view, so a shared `group-id` link can land on the same tab (mirrors
@@ -84,6 +84,9 @@ const AccessControl = dynamic(() =>
const CustomBlocks = dynamic(() =>
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
)
const CredentialGroups = dynamic(() =>
import('@/ee/credential-groups/components').then((m) => m.CredentialGroupsSettings)
)
const AuditLogs = dynamic(() =>
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
)
@@ -158,6 +161,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'browser' && <Browser />}
{effectiveSection === 'terminal' && <Terminal />}
{effectiveSection === 'secrets' && <Secrets />}
{effectiveSection === 'credential-groups' && (
<CredentialGroups workspaceId={hostContext.workspace.id} />
)}
{effectiveSection === 'access-control' && organizationId && (
<AccessControl
organizationId={organizationId}
@@ -31,6 +31,7 @@ describe('unified settings navigation', () => {
{ id: 'teammates', label: 'Teammates', section: 'workspace' },
{ id: 'organization', label: 'Members', section: 'organization' },
{ id: 'secrets', label: 'Secrets', section: 'workspace' },
{ id: 'credential-groups', label: 'Credential groups', section: 'workspace' },
{ id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
{ id: 'mcp', label: 'MCP tools', section: 'workspace' },
{ id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
@@ -68,6 +69,7 @@ describe('unified settings navigation', () => {
expect(idsForSection('workspace')).toEqual([
'teammates',
'secrets',
'credential-groups',
'mcp',
'custom-tools',
'byok',
@@ -253,7 +253,9 @@ export function CredentialSelector({
const comboboxOptions = useMemo(() => {
if (isAllCredentials) {
const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth')
const oauthCredentials = allWorkspaceCredentials.filter(
(credential) => credential.type === 'oauth'
)
return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id }))
}
if (isMergedKinds) return []
@@ -409,7 +411,9 @@ export function CredentialSelector({
}
const matchedCred = (
isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials
isAllCredentials
? allWorkspaceCredentials.filter((credential) => credential.type === 'oauth')
: credentials
).find((c) => c.id === value)
if (matchedCred) {
handleSelect(value)
@@ -141,6 +141,12 @@ export function SettingsSidebar({
if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) {
return false
}
if (
item.id === 'credential-groups' &&
(!hostContext.features?.credentialGroups || !canAdminWorkspace)
) {
return false
}
if (item.selfHostedOverride && !isHosted) {
/**
+365
View File
@@ -0,0 +1,365 @@
import { GridOffset } from '@sim/emcn/icons'
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
import {
type CanonicalGroup,
resolveActiveCanonicalValue,
} from '@/lib/workflows/subblocks/visibility'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { BlockConfig } from '@/blocks/types'
import {
CREDENTIAL_GROUP_LIST_STALE_TIME,
credentialGroupKeys,
fetchCredentialGroupList,
} from '@/hooks/queries/utils/credential-group-queries'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const CREDENTIAL_GROUP_CANONICAL_GROUP = {
canonicalId: 'credentialGroupId',
basicId: 'credentialGroup',
advancedIds: ['manualCredentialGroup'],
} as const satisfies CanonicalGroup
async function fetchCachedCredentialGroups() {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []
return getQueryClient().fetchQuery({
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
}
function resolveCredentialGroupIdForBlock(blockId: string): string | null {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return null
const values = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}
const canonicalModes = useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes
const value = resolveActiveCanonicalValue(
CREDENTIAL_GROUP_CANONICAL_GROUP,
values,
canonicalModes
)
return typeof value === 'string' && value.trim() ? value.trim() : null
}
interface CredentialGroupBlockOutput {
success: boolean
output: {
credentials: Array<{
credentialId: string
email: string
displayName: string
providerId: string
providerSubjectId: string
providerTenantId: string | null
}>
credentialGroups: Array<{
id: string
name: string
description: string | null
status: 'active' | 'disabled'
providerIds: string[]
createdAt: string
updatedAt: string
}>
people: Array<{
id: string
email: string
status: string
expired: boolean
invitedAt: string
connections: Array<{ provider: string; status: string; count: number }>
}>
enrollmentId: string
email: string
status: string
invitedAt: string
expiresAt: string
count: number
hasMore: boolean
nextCursor: string | null
}
}
const GROUP_OPERATIONS = ['list_credentials', 'send_invite', 'list_people'] as const
const LIST_OPERATIONS = ['list_credentials', 'list_people', 'list_groups'] as const
export const CredentialGroupBlock: BlockConfig<CredentialGroupBlockOutput> = {
type: 'credential_group',
name: 'Credential Groups',
description: 'Invite people and use credentials collected by Credential Groups',
longDescription:
'List usable managed credentials, inspect invited people, send an account-connection invitation, or discover Credential Groups in the current workspace. The block returns credential IDs and account metadata without exposing OAuth tokens.',
bestPractices: `
- Use "List Credentials" with a ForEach loop to run a provider block once for every connected account.
- Filter by email to select credentials belonging to one invited person, by provider to select one account type, or by both for an exact match.
- Continue with nextCursor until hasMore is false when a list operation returns multiple pages.
- "List Credentials" returns active, usable credentials only. Reconnect-needed and revoked credentials are excluded.
- Use "List People" to inspect invitation and connection progress without exposing credential secrets.
- "Send Invite" sends one email. Use a loop when invitations should come from a dynamic list.
`,
docsLink: 'https://docs.sim.ai/workflows/blocks/credential-group',
bgColor: '#7C3AED',
icon: GridOffset,
canvasPresentation: {
defaultTitle: 'Credential Groups',
sentences: {
byOperation: {
list_credentials: [
{
text: 'List credentials from',
field: ['credentialGroup', 'manualCredentialGroup'],
core: true,
},
{ text: ', for', field: 'email' },
{ text: ', from', field: ['providerFilter', 'manualProviderIds'] },
{ text: ', up to', field: 'limit', after: 'credentials' },
],
send_invite: [
{ text: 'Invite', field: 'email', core: true },
{
text: 'to',
field: ['credentialGroup', 'manualCredentialGroup'],
core: true,
},
],
list_people: [
{
text: 'List people in',
field: ['credentialGroup', 'manualCredentialGroup'],
core: true,
},
{ text: ', matching', field: 'email' },
{ text: ', with status', field: 'peopleStatuses' },
],
list_groups: ['List Credential Groups', { text: ', up to', field: 'limit' }],
},
},
},
category: 'blocks',
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Credentials', id: 'list_credentials' },
{ label: 'Send Invite', id: 'send_invite' },
{ label: 'List People', id: 'list_people' },
{ label: 'List Credential Groups', id: 'list_groups' },
],
value: () => 'list_credentials',
},
{
id: 'credentialGroup',
title: 'Credential Group',
type: 'dropdown',
options: [],
required: { field: 'operation', value: [...GROUP_OPERATIONS] },
mode: 'basic',
canonicalParamId: 'credentialGroupId',
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
fetchOptions: async () => {
const groups = await fetchCachedCredentialGroups()
return groups
.filter((group) => group.status === 'active')
.map((group) => ({ label: group.name, id: group.id }))
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchOptionById: async (_blockId: string, optionId: string) => {
const groups = await fetchCachedCredentialGroups()
const group = groups.find((candidate) => candidate.id === optionId)
return group ? { label: group.name, id: group.id } : null
},
},
{
id: 'manualCredentialGroup',
title: 'Credential Group ID',
type: 'short-input',
required: { field: 'operation', value: [...GROUP_OPERATIONS] },
mode: 'advanced',
placeholder: 'Enter credential group ID',
canonicalParamId: 'credentialGroupId',
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
},
{
id: 'email',
title: 'Email',
type: 'short-input',
required: { field: 'operation', value: 'send_invite' },
placeholder: 'person@example.com',
condition: { field: 'operation', value: [...GROUP_OPERATIONS] },
},
{
id: 'providerFilter',
title: 'Provider',
type: 'dropdown',
multiSelect: true,
emptyIsValid: true,
options: [],
required: false,
mode: 'basic',
canonicalParamId: 'credentialProviderIds',
dependsOn: ['credentialGroupId'],
condition: { field: 'operation', value: 'list_credentials' },
fetchOptions: async (blockId: string) => {
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
if (!credentialGroupId) return []
const groups = await fetchCachedCredentialGroups()
const group = groups.find((candidate) => candidate.id === credentialGroupId)
if (!group) return []
return group.options
.filter((option) => option.status === 'active')
.map((option) => {
const service = getCredentialGroupProviderService(option.provider)
return { id: service.providerId, label: service.name }
})
.sort((a, b) => a.label.localeCompare(b.label))
},
fetchOptionById: async (blockId: string, optionId: string) => {
const credentialGroupId = resolveCredentialGroupIdForBlock(blockId)
if (!credentialGroupId) return null
const groups = await fetchCachedCredentialGroups()
const group = groups.find((candidate) => candidate.id === credentialGroupId)
const option = group?.options.find(
(candidate) =>
candidate.status === 'active' &&
getCredentialGroupProviderService(candidate.provider).providerId === optionId
)
if (!option) return null
return {
id: optionId,
label: getCredentialGroupProviderService(option.provider).name,
}
},
},
{
id: 'manualProviderIds',
title: 'Provider IDs',
type: 'short-input',
required: false,
mode: 'advanced',
canonicalParamId: 'credentialProviderIds',
dependsOn: ['credentialGroupId'],
placeholder: '["google-email", "slack"] — leave empty for all providers',
condition: { field: 'operation', value: 'list_credentials' },
},
{
id: 'peopleStatuses',
title: 'Status',
type: 'dropdown',
multiSelect: true,
emptyIsValid: true,
options: [
{ label: 'Invited', id: 'invited' },
{ label: 'Delivery failed', id: 'delivery_failed' },
{ label: 'In progress', id: 'in_progress' },
{ label: 'Connected', id: 'completed' },
{ label: 'Revoked', id: 'revoked' },
],
condition: { field: 'operation', value: 'list_people' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
value: () => '100',
mode: 'advanced',
placeholder: '1-100',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
{
id: 'cursor',
title: 'Cursor',
type: 'short-input',
mode: 'advanced',
placeholder: 'nextCursor from a previous page',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
],
tools: { access: [] },
inputs: {
operation: {
type: 'string',
description: "'list_credentials', 'send_invite', 'list_people', or 'list_groups'",
},
credentialGroupId: { type: 'string', description: 'Credential Group ID' },
email: {
type: 'string',
description: 'Recipient email for invites or exact email filter for list operations',
},
credentialProviderIds: {
type: 'json',
description: 'Optional OAuth provider IDs to include when listing credentials',
},
peopleStatuses: {
type: 'json',
description: 'Optional invitation statuses to include when listing people',
},
limit: { type: 'number', description: 'Maximum results per page (1-100)' },
cursor: { type: 'string', description: 'nextCursor from a previous page' },
},
outputs: {
credentials: {
type: 'json',
description:
'Usable credential references (credentialId, email, displayName, providerId, providerSubjectId, providerTenantId)',
condition: { field: 'operation', value: 'list_credentials' },
},
credentialGroups: {
type: 'json',
description:
'Credential Group summaries (id, name, description, status, providerIds, createdAt, updatedAt)',
condition: { field: 'operation', value: 'list_groups' },
},
people: {
type: 'json',
description:
'Invited people and current connection summaries (id, email, status, expired, invitedAt, connections)',
condition: { field: 'operation', value: 'list_people' },
},
enrollmentId: {
type: 'string',
description: 'Enrollment ID created or refreshed by the invitation',
condition: { field: 'operation', value: 'send_invite' },
},
email: {
type: 'string',
description: 'Normalized invitation recipient email',
condition: { field: 'operation', value: 'send_invite' },
},
status: {
type: 'string',
description: 'Invitation status',
condition: { field: 'operation', value: 'send_invite' },
},
invitedAt: {
type: 'string',
description: 'Invitation timestamp',
condition: { field: 'operation', value: 'send_invite' },
},
expiresAt: {
type: 'string',
description: 'Invitation expiration timestamp',
condition: { field: 'operation', value: 'send_invite' },
},
count: {
type: 'number',
description: 'Number of records returned',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
hasMore: {
type: 'boolean',
description: 'Whether another page is available',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
nextCursor: {
type: 'string',
description: 'Cursor for the next page, or null on the last page',
condition: { field: 'operation', value: [...LIST_OPERATIONS] },
},
},
}
+2
View File
@@ -46,6 +46,7 @@ import { ConfluenceBlock, ConfluenceBlockMeta, ConfluenceV2Block } from '@/block
import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_dev'
import { ConvexBlock, ConvexBlockMeta } from '@/blocks/blocks/convex'
import { CredentialBlock } from '@/blocks/blocks/credential'
import { CredentialGroupBlock } from '@/blocks/blocks/credential-group'
import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike'
import { CursorBlock, CursorBlockMeta, CursorV2Block } from '@/blocks/blocks/cursor'
import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster'
@@ -404,6 +405,7 @@ export const BLOCK_REGISTRY: Record<string, BlockConfig> = {
context_dev: ContextDevBlock,
convex: ConvexBlock,
credential: CredentialBlock,
credential_group: CredentialGroupBlock,
crowdstrike: CrowdStrikeBlock,
cursor: CursorBlock,
cursor_v2: CursorV2Block,
@@ -0,0 +1,50 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CredentialGroupInvitationEmailProps {
recipientEmail: string
inviterName: string
workspaceName: string
credentialGroupName: string
invitationLink: string
}
export function CredentialGroupInvitationEmail({
recipientEmail,
inviterName,
workspaceName,
credentialGroupName,
invitationLink,
}: CredentialGroupInvitationEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout
preview={`${inviterName} invited you to connect accounts for ${workspaceName}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited <strong>{recipientEmail}</strong> to connect accounts
for <strong>{credentialGroupName}</strong> in the <strong>{workspaceName}</strong> workspace
on {brand.name}.
</Text>
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Connect Accounts</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
This private link expires in 7 days. {brand.name} will send you to each provider to sign in
and will never ask for your provider password. If you did not expect this invitation, you
can ignore it.
</Text>
</EmailLayout>
)
}
export default CredentialGroupInvitationEmail
@@ -0,0 +1 @@
export { CredentialGroupInvitationEmail } from './credential-group-invitation-email'
@@ -0,0 +1,12 @@
import { render } from '@react-email/render'
import { CredentialGroupInvitationEmail } from '@/components/emails/credential-groups/credential-group-invitation-email'
export async function renderCredentialGroupInvitationEmail(params: {
recipientEmail: string
inviterName: string
workspaceName: string
credentialGroupName: string
invitationLink: string
}): Promise<string> {
return await render(CredentialGroupInvitationEmail(params))
}
+8
View File
@@ -103,3 +103,11 @@ export function getRequestConfirmationSubject(userSubject: string, requestType?:
export function getOtpSubject(resourceLabel: string): string {
return `Verification code for ${resourceLabel}`
}
/** Names both the inviter and workspace so an external recipient can identify the request. */
export function getCredentialGroupInvitationSubject(
inviterName: string,
workspaceName: string
): string {
return `${inviterName} invited you to connect accounts for ${workspaceName} on ${getBrandConfig().name}`
}
@@ -50,6 +50,7 @@ describe('settings navigation boundaries', () => {
'teammates',
'organization',
'secrets',
'credential-groups',
'custom-tools',
'mcp',
'apikeys',
@@ -99,6 +100,7 @@ describe('settings navigation boundaries', () => {
'inbox',
'recently-deleted',
'forks',
'credential-groups',
'custom-blocks',
'self-host',
])
@@ -114,6 +116,7 @@ describe('settings navigation boundaries', () => {
permissionConfig: {},
entitlements: {
byok: true,
credentialGroups: true,
inbox: true,
customBlocks: true,
forks: true,
@@ -139,6 +142,7 @@ describe('settings navigation boundaries', () => {
permissionConfig: {},
entitlements: {
byok: true,
credentialGroups: true,
inbox: true,
customBlocks: true,
forks: true,
@@ -158,6 +162,7 @@ describe('settings navigation boundaries', () => {
permissionConfig: {},
entitlements: {
byok: true,
credentialGroups: true,
inbox: true,
customBlocks: true,
forks: true,
@@ -450,6 +455,7 @@ describe('settings navigation boundaries', () => {
permissionConfig: {},
entitlements: {
byok: true,
credentialGroups: true,
customBlocks: true,
forks: true,
inbox: true,
@@ -474,6 +480,7 @@ describe('settings navigation boundaries', () => {
},
entitlements: {
byok: true,
credentialGroups: true,
customBlocks: true,
forks: true,
inbox: true,
@@ -488,6 +495,7 @@ describe('settings navigation boundaries', () => {
'workflow-mcp-servers',
'recently-deleted',
'forks',
'credential-groups',
'custom-blocks',
'self-host',
])
@@ -5,6 +5,7 @@ import {
Credit,
Database,
Globe,
GridOffset,
HexSimple,
Key,
KeySquare,
@@ -64,6 +65,7 @@ export type OrganizationSettingsSection =
export type WorkspaceSettingsSection =
| 'teammates'
| 'secrets'
| 'credential-groups'
| 'byok'
| 'sandboxes'
| 'custom-tools'
@@ -99,6 +101,7 @@ export type UnifiedSettingsSection =
| 'browser'
| 'terminal'
| 'secrets'
| 'credential-groups'
| 'access-control'
| 'custom-blocks'
| 'audit-logs'
@@ -527,6 +530,22 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
workspace: { id: 'secrets', group: 'workspace', order: 1 },
},
},
{
label: 'Credential groups',
icon: GridOffset,
unified: {
id: 'credential-groups',
description: 'Collect and manage OAuth credentials for people outside this workspace.',
group: 'workspace',
order: 2,
requiresEnterprise: true,
allowNonOrgAdmin: true,
selfHostedOverride: true,
},
planes: {
workspace: { id: 'credential-groups', group: 'enterprise', order: 10 },
},
},
{
label: 'Custom tools',
icon: Wrench,
@@ -948,6 +967,7 @@ export interface WorkspacePermissionConfig {
export interface WorkspaceSettingsEntitlements {
byok: boolean
credentialGroups: boolean
customBlocks: boolean
forks: boolean
inbox: boolean
@@ -982,6 +1002,7 @@ export interface ResolvedWorkspaceNavigationItem
const WORKSPACE_MUTATION_PERMISSION: Record<WorkspaceSettingsSection, PermissionType> = {
teammates: 'admin',
secrets: 'write',
'credential-groups': 'admin',
byok: 'admin',
sandboxes: 'admin',
'custom-tools': 'write',
@@ -1021,6 +1042,12 @@ export function resolveWorkspaceNavigation({
if (item.id === 'mcp' && permissionConfig.disableMcpTools) return []
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) return []
if (item.id === 'forks' && (permission !== 'admin' || !entitlements.forks)) return []
if (
item.id === 'credential-groups' &&
(permission !== 'admin' || !entitlements.credentialGroups)
) {
return []
}
if (item.id === 'byok' && !entitlements.byok) return []
if (item.id === 'custom-blocks' && !entitlements.customBlocks) return []
// Absent on Sim Cloud, where the managed service owns these settings.
@@ -274,7 +274,7 @@ async function fetchWorksheets(accessToken: string, basePath: string): Promise<W
worksheets.push(...(data.value ?? []))
const next = data['@odata.nextLink']
url = next && next.startsWith(GRAPH_API_BASE) ? next : undefined
url = next?.startsWith(GRAPH_API_BASE) ? next : undefined
}
return worksheets
@@ -0,0 +1,104 @@
'use client'
import { useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useCreateCredentialGroup } from '@/hooks/queries/credential-groups'
interface CredentialGroupCreateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (groupId: string) => void
workspaceId: string
}
export function CredentialGroupCreateModal({
open,
onOpenChange,
onCreated,
workspaceId,
}: CredentialGroupCreateModalProps) {
const createGroup = useCreateCredentialGroup()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const reset = () => {
setName('')
setDescription('')
createGroup.reset()
}
const handleOpenChange = (nextOpen: boolean) => {
if (createGroup.isPending) return
onOpenChange(nextOpen)
if (!nextOpen) reset()
}
const handleCreate = async () => {
if (!name.trim() || createGroup.isPending) return
try {
const result = await createGroup.mutateAsync({
workspaceId,
body: {
name: name.trim(),
description: description.trim() || undefined,
options: [],
},
})
onCreated(result.credentialGroup.id)
handleOpenChange(false)
} catch {
return
}
}
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle='Create credential group'
size='md'
dismissDisabled={createGroup.isPending}
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>
Create credential group
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='input'
title='Name'
value={name}
onChange={setName}
placeholder='Customer support accounts'
required
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='What these accounts will be used for'
/>
<ChipModalError>
{createGroup.error ? getErrorMessage(createGroup.error) : null}
</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
cancelDisabled={createGroup.isPending}
primaryAction={{
label: createGroup.isPending ? 'Creating...' : 'Create',
onClick: handleCreate,
disabled: !name.trim() || createGroup.isPending,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,38 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { CredentialGroupEnrollmentDetail } from '@/lib/api/contracts/credential-groups'
import { getEnrollmentStatus } from '@/ee/credential-groups/components/credential-group-detail'
const ENROLLMENT: CredentialGroupEnrollmentDetail = {
id: 'enrollment-1',
credentialGroupId: 'group-1',
email: 'person@example.com',
status: 'in_progress',
expiresAt: '2026-08-13T00:00:00.000Z',
invitedAt: '2026-08-12T00:00:00.000Z',
sentAt: '2026-08-12T00:00:00.000Z',
completedAt: null,
revokedAt: null,
expired: true,
createdAt: '2026-08-12T00:00:00.000Z',
updatedAt: '2026-08-13T00:00:00.000Z',
connections: [{ provider: 'gmail', status: 'needs_reauth', count: 1 }],
}
describe('Credential Group enrollment status', () => {
it('keeps expired incomplete invitations ahead of credential reauthorization', () => {
expect(getEnrollmentStatus(ENROLLMENT, ['gmail'])).toEqual({
label: 'Expired',
invalid: true,
})
})
it('shows reauthorization for completed enrollments after their invitation expires', () => {
expect(getEnrollmentStatus({ ...ENROLLMENT, status: 'completed' }, ['gmail'])).toEqual({
label: 'Reconnect needed',
invalid: false,
})
})
})
@@ -0,0 +1,301 @@
'use client'
import { useState } from 'react'
import { Chip, ChipConfirmModal, ChipModalTabs, ChipTag, toast } from '@sim/emcn'
import { ArrowLeft, KeySquare, Plus } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryState } from 'nuqs'
import type {
CredentialGroupEnrollment,
CredentialGroupEnrollmentConnection,
CredentialGroupEnrollmentDetail,
} from '@/lib/api/contracts/credential-groups'
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
credentialGroupTabParam,
credentialGroupTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { CredentialGroupDetails } from '@/ee/credential-groups/components/credential-group-details'
import { CredentialGroupInviteModal } from '@/ee/credential-groups/components/credential-group-invite-modal'
import {
useCredentialGroupDetail,
useResendCredentialGroupEnrollment,
useRevokeCredentialGroupEnrollment,
} from '@/hooks/queries/credential-groups'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
interface CredentialGroupDetailProps {
workspaceId: string
groupId: string
onBack: () => void
}
type CredentialGroupTab = 'details' | 'people'
const CREDENTIAL_GROUP_TABS = [
{ value: 'details', label: 'Details' },
{ value: 'people', label: 'People' },
] as const
export function getEnrollmentStatus(
enrollment: CredentialGroupEnrollmentDetail,
activeProviders: CredentialGroupProvider[]
) {
if (enrollment.status === 'revoked') return { label: 'Revoked', invalid: false }
if (enrollment.status === 'delivery_failed') return { label: 'Delivery failed', invalid: true }
if (enrollment.status !== 'completed' && enrollment.expired) {
return { label: 'Expired', invalid: true }
}
const needsReauthorization = enrollment.connections.some(
(connection) => connection.status === 'needs_reauth'
)
if (needsReauthorization) return { label: 'Reconnect needed', invalid: false }
const connectedProviders = new Set(
enrollment.connections
.filter((connection) => connection.status === 'active')
.map((connection) => connection.provider)
)
const allProvidersConnected =
activeProviders.length > 0 &&
activeProviders.every((provider) => connectedProviders.has(provider))
if (enrollment.status === 'completed' && allProvidersConnected) {
return { label: 'Connected', invalid: false }
}
if (enrollment.status === 'completed') return { label: 'In progress', invalid: false }
if (enrollment.status === 'in_progress') return { label: 'In progress', invalid: false }
return { label: 'Invited', invalid: false }
}
interface EnrollmentConnectionsProps {
connections: CredentialGroupEnrollmentConnection[]
}
interface CredentialProviderIconProps {
provider: CredentialGroupProvider
}
function CredentialProviderIcon({ provider }: CredentialProviderIconProps) {
const ProviderIcon = getCredentialGroupProviderService(provider).icon
return <ProviderIcon className='size-[14px]' aria-hidden />
}
function EnrollmentConnections({ connections }: EnrollmentConnectionsProps) {
const connected = connections.filter((connection) => connection.status === 'active')
const count = connected.reduce((total, connection) => total + connection.count, 0)
const providers = [...new Set(connected.map((connection) => connection.provider))]
return (
<span className='flex items-center gap-1.5'>
{providers.map((provider) => {
return <CredentialProviderIcon key={provider} provider={provider} />
})}
<span>
{count} connected {count === 1 ? 'account' : 'accounts'}
</span>
</span>
)
}
export function CredentialGroupDetail({
workspaceId,
groupId,
onBack,
}: CredentialGroupDetailProps) {
const detail = useCredentialGroupDetail(workspaceId, groupId)
const slackBots = useWorkspaceCredentials({
workspaceId,
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
})
const resend = useResendCredentialGroupEnrollment()
const revoke = useRevokeCredentialGroupEnrollment()
const [activeTab, setActiveTab] = useQueryState(credentialGroupTabParam.key, {
...credentialGroupTabParam.parser,
...credentialGroupTabUrlKeys,
})
const [showInvite, setShowInvite] = useState(false)
const [revokingEnrollmentId, setRevokingEnrollmentId] = useState<string | null>(null)
const credentialGroup = detail.data?.pages[0]?.credentialGroup
const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? []
const revokingEnrollment = revokingEnrollmentId
? (enrollments.find((enrollment) => enrollment.id === revokingEnrollmentId) ?? null)
: null
const activeProviders =
credentialGroup?.options
.filter((option) => option.status === 'active')
.map((option) => option.provider) ?? []
const configurationReady =
Boolean(credentialGroup?.options.length) &&
credentialGroup?.options.every(
(option) =>
option.provider !== 'slack' ||
(option.configurationStatus === 'ready' &&
slackBots.data?.some((bot) => bot.id === option.slackBotCredentialId))
)
const actions: SettingsAction[] = credentialGroup
? [
{
text: 'Invite users',
icon: Plus,
variant: 'primary',
onSelect: () => setShowInvite(true),
disabled: credentialGroup.status !== 'active' || !configurationReady,
},
]
: []
const handleResend = async (enrollment: CredentialGroupEnrollment) => {
try {
await resend.mutateAsync({ workspaceId, groupId, enrollmentId: enrollment.id })
toast.success(`Invitation resent to ${enrollment.email}`)
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to resend invitation'))
}
}
const handleRevoke = async () => {
if (!revokingEnrollment) return
try {
await revoke.mutateAsync({
workspaceId,
groupId,
enrollmentId: revokingEnrollment.id,
})
toast.success(`Invitation revoked for ${revokingEnrollment.email}`)
setRevokingEnrollmentId(null)
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to revoke invitation'))
}
}
const handleBack = () => {
void setActiveTab(null, { history: 'replace' })
onBack()
}
return (
<>
<SettingsPanel
back={{ text: 'Credential groups', icon: ArrowLeft, onSelect: handleBack }}
title={credentialGroup?.name ?? 'Credential group'}
description={credentialGroup?.description ?? undefined}
actions={actions}
>
{detail.error ? (
<SettingsEmptyState tone='error'>
{getErrorMessage(detail.error, "Couldn't load credential group")}
</SettingsEmptyState>
) : detail.isPending || !credentialGroup ? null : (
<div className='flex flex-col gap-7'>
<ChipModalTabs
tabs={CREDENTIAL_GROUP_TABS}
value={activeTab}
onChange={(value) => void setActiveTab(value as CredentialGroupTab)}
aria-label='Credential group sections'
/>
{activeTab === 'details' && (
<CredentialGroupDetails workspaceId={workspaceId} credentialGroup={credentialGroup} />
)}
{activeTab === 'people' && (
<SettingsSection
label={`People (${enrollments.length}${detail.hasNextPage ? '+' : ''})`}
action={
detail.hasNextPage ? (
<Chip
onClick={() => void detail.fetchNextPage()}
disabled={detail.isFetchingNextPage}
>
{detail.isFetchingNextPage ? 'Loading...' : 'Load more'}
</Chip>
) : undefined
}
>
{enrollments.length === 0 ? (
<SettingsEmptyState variant='inline'>No people invited yet</SettingsEmptyState>
) : (
<div className={RESOURCE_LIST_STACK}>
{enrollments.map((enrollment) => {
const status = getEnrollmentStatus(enrollment, activeProviders)
return (
<SettingsResourceRow
key={enrollment.id}
icon={<KeySquare />}
title={enrollment.email}
description={
<EnrollmentConnections connections={enrollment.connections} />
}
badge={
<ChipTag
variant={status.invalid ? 'invite' : 'gray'}
invalid={status.invalid}
>
{status.label}
</ChipTag>
}
trailing={
enrollment.status === 'revoked' ? undefined : (
<RowActionsMenu
label={`${enrollment.email} actions`}
actions={[
{
label: 'Resend',
onSelect: () => void handleResend(enrollment),
disabled: resend.isPending,
},
{
label: 'Revoke',
destructive: true,
onSelect: () => setRevokingEnrollmentId(enrollment.id),
},
]}
/>
)
}
/>
)
})}
</div>
)}
</SettingsSection>
)}
</div>
)}
</SettingsPanel>
{credentialGroup && (
<CredentialGroupInviteModal
open={showInvite}
onOpenChange={setShowInvite}
workspaceId={workspaceId}
groupId={groupId}
/>
)}
<ChipConfirmModal
open={Boolean(revokingEnrollment)}
onOpenChange={(open) => !open && !revoke.isPending && setRevokingEnrollmentId(null)}
srTitle='Revoke invitation'
title='Revoke invitation?'
text={`Revoke the invitation for ${revokingEnrollment?.email ?? 'this user'}? Their private link will stop working immediately.`}
dismissLabel='Cancel'
confirm={{
label: revoke.isPending ? 'Revoking...' : 'Revoke',
onClick: handleRevoke,
disabled: revoke.isPending,
}}
/>
</>
)
}
@@ -0,0 +1,299 @@
'use client'
import { useState } from 'react'
import { Chip, ChipConfirmModal, ChipInput, ChipTag, ChipTextarea, toast } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import type {
CredentialGroup,
CredentialGroupOption,
UpdateCredentialGroupBody,
} from '@/lib/api/contracts/credential-groups'
import {
CREDENTIAL_GROUP_PROVIDER_IDS,
type CredentialGroupProvider,
type CredentialGroupStandardOAuthProvider,
getCredentialGroupProviderService,
getCredentialGroupProviderSupport,
isCredentialGroupStandardOAuthProvider,
} from '@/lib/credential-groups/providers'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { SettingRow } from '@/ee/components/setting-row'
import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
interface CredentialGroupDetailsProps {
credentialGroup: CredentialGroup
workspaceId: string
}
function toOptionUpdateInput(
option: CredentialGroupOption
): NonNullable<UpdateCredentialGroupBody['options']>[number] {
const common = {
id: option.id,
label: getCredentialGroupProviderService(option.provider).name,
required: true,
}
if (option.provider !== 'slack') return { ...common, provider: option.provider }
return {
...common,
provider: 'slack',
slackBotCredentialId: option.slackBotCredentialId,
}
}
export function CredentialGroupDetails({
credentialGroup,
workspaceId,
}: CredentialGroupDetailsProps) {
const updateGroup = useUpdateCredentialGroup()
const slackBots = useWorkspaceCredentials({
workspaceId,
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
})
const [name, setName] = useState(credentialGroup.name)
const [description, setDescription] = useState(credentialGroup.description ?? '')
const [slackSetupOpen, setSlackSetupOpen] = useState(false)
const [slackSetupCredentialId, setSlackSetupCredentialId] = useState<string | undefined>()
const [removingProvider, setRemovingProvider] = useState<CredentialGroupProvider | null>(null)
const normalizedDescription = description.trim() || null
const detailsDirty =
name.trim() !== credentialGroup.name || normalizedDescription !== credentialGroup.description
const isUpdating = updateGroup.isPending
const updateOptions = async (
options: NonNullable<UpdateCredentialGroupBody['options']>,
successMessage: string
) => {
try {
await updateGroup.mutateAsync({
workspaceId,
groupId: credentialGroup.id,
body: { options },
})
toast.success(successMessage)
return true
} catch (error) {
toast.error(getErrorMessage(error, 'Could not update account collection'))
return false
}
}
const addProvider = async (provider: CredentialGroupStandardOAuthProvider) => {
const service = getCredentialGroupProviderService(provider)
const existing = credentialGroup.options.map(toOptionUpdateInput)
const nextOption: NonNullable<UpdateCredentialGroupBody['options']>[number] = {
provider,
label: service.name,
required: true,
}
return updateOptions([...existing, nextOption], `${service.name} added`)
}
const openSlackSetup = (credentialId?: string) => {
setSlackSetupCredentialId(credentialId)
setSlackSetupOpen(true)
}
const handleProviderAction = (provider: CredentialGroupProvider) => {
const support = getCredentialGroupProviderSupport(provider)
if (isCredentialGroupStandardOAuthProvider(provider)) {
void addProvider(provider)
return
}
if (support.configuration === 'slack_custom_bot') {
openSlackSetup()
return
}
throw new Error(`Unsupported Credential Group configuration: ${support.configuration}`)
}
const handleSaveDetails = async () => {
if (!detailsDirty || !name.trim() || isUpdating) return
try {
await updateGroup.mutateAsync({
workspaceId,
groupId: credentialGroup.id,
body: { name: name.trim(), description: normalizedDescription },
})
toast.success('Details saved')
} catch (error) {
toast.error(getErrorMessage(error, 'Could not save details'))
}
}
const handleRemoveProvider = async () => {
if (!removingProvider) return
const service = getCredentialGroupProviderService(removingProvider)
const options = credentialGroup.options
.filter((option) => option.provider !== removingProvider)
.map(toOptionUpdateInput)
if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null)
}
return (
<>
<div className='flex flex-col gap-7'>
<SettingsSection
label='Group details'
action={
detailsDirty ? (
<Chip
variant='primary'
onClick={() => void handleSaveDetails()}
disabled={!name.trim() || isUpdating}
>
{isUpdating ? 'Saving...' : 'Save changes'}
</Chip>
) : undefined
}
>
<div className='flex max-w-[560px] flex-col gap-4'>
<SettingRow label='Name' htmlFor='credential-group-name'>
<ChipInput
id='credential-group-name'
value={name}
onChange={(event) => setName(event.target.value)}
error={!name.trim()}
/>
</SettingRow>
<SettingRow label='Description' optional htmlFor='credential-group-description'>
<ChipTextarea
id='credential-group-description'
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder='What these accounts will be used for'
rows={3}
/>
</SettingRow>
</div>
</SettingsSection>
<SettingsSection label='Accounts people can connect'>
<div className={RESOURCE_LIST_STACK}>
{CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => {
const service = getCredentialGroupProviderService(provider)
const support = getCredentialGroupProviderSupport(provider)
const option = credentialGroup.options.find(
(candidate) => candidate.provider === provider
)
const ProviderIcon = service.icon
const slackBot =
provider === 'slack' && option?.provider === 'slack'
? slackBots.data?.find((bot) => bot.id === option.slackBotCredentialId)
: undefined
const slackNeedsSetup =
provider === 'slack' &&
option?.provider === 'slack' &&
(!slackBot || option.configurationStatus !== 'ready')
const descriptionText =
provider === 'slack' && option
? slackBot
? `${slackBot.displayName}${slackNeedsSetup ? ' needs managed-user setup' : ''}`
: slackBots.isPending
? 'Loading custom Slack app...'
: 'Custom Slack app unavailable'
: support.description
return (
<SettingsResourceRow
key={provider}
icon={<ProviderIcon aria-hidden />}
title={service.name}
description={descriptionText}
badge={
option && !slackNeedsSetup ? (
<ChipTag variant='gray'>Connected</ChipTag>
) : undefined
}
trailing={
option ? (
<div className='flex items-center gap-1'>
{slackNeedsSetup && option.provider === 'slack' && slackBot ? (
<Chip onClick={() => openSlackSetup(slackBot.id)} disabled={isUpdating}>
Continue setup
</Chip>
) : null}
<RowActionsMenu
label={`${service.name} actions`}
actions={[
...(provider === 'slack'
? [
{
label: 'Change Slack app',
onSelect: () =>
openSlackSetup(
option?.provider === 'slack'
? option.slackBotCredentialId
: undefined
),
disabled: isUpdating,
},
]
: []),
{
label: 'Remove',
destructive: true,
onSelect: () => setRemovingProvider(provider),
disabled: isUpdating,
},
]}
/>
</div>
) : (
<Chip
onClick={() => handleProviderAction(provider)}
disabled={isUpdating || (provider === 'slack' && slackBots.isPending)}
>
{support.configuration === 'oauth' ? 'Add' : 'Set up'}
</Chip>
)
}
/>
)
})}
</div>
</SettingsSection>
</div>
<SlackManagedUsersModal
open={slackSetupOpen}
workspaceId={workspaceId}
credentialGroupId={credentialGroup.id}
onOpenChange={(nextOpen) => {
setSlackSetupOpen(nextOpen)
if (!nextOpen) setSlackSetupCredentialId(undefined)
}}
bots={slackBots.data ?? []}
isLoading={slackBots.isPending}
error={slackBots.error}
initialCredentialId={slackSetupCredentialId}
/>
<ChipConfirmModal
open={Boolean(removingProvider)}
onOpenChange={(open) => !open && !isUpdating && setRemovingProvider(null)}
srTitle='Remove account type'
title={`Remove ${
removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account'
}`}
text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.'
dismissLabel='Cancel'
confirm={{
label: isUpdating ? 'Removing...' : 'Remove',
onClick: handleRemoveProvider,
disabled: isUpdating,
}}
/>
</>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useCallback, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useInviteCredentialGroupEnrollments } from '@/hooks/queries/credential-groups'
interface CredentialGroupInviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
groupId: string
}
export function CredentialGroupInviteModal({
open,
onOpenChange,
workspaceId,
groupId,
}: CredentialGroupInviteModalProps) {
const invite = useInviteCredentialGroupEnrollments()
const [emails, setEmails] = useState<string[]>([])
const [deliveryError, setDeliveryError] = useState<string | null>(null)
const canSubmit = emails.length > 0 && !invite.isPending
const validateEmail = useCallback((email: string): string | null => {
const result = quickValidateEmail(email)
return result.isValid ? null : (result.reason ?? 'Invalid email')
}, [])
const handleEmailsChange = useCallback((next: string[]) => {
setEmails(next)
setDeliveryError(null)
}, [])
const handleOpenChange = (nextOpen: boolean) => {
if (invite.isPending) return
onOpenChange(nextOpen)
if (!nextOpen) {
setEmails([])
setDeliveryError(null)
invite.reset()
}
}
const handleSubmit = async () => {
if (!canSubmit) return
setDeliveryError(null)
try {
const result = await invite.mutateAsync({
workspaceId,
groupId,
body: { emails },
})
const failures = result.results.filter((item) => !item.success)
if (failures.length === 0) {
toast.success(
result.sentCount === 1 ? 'Invitation sent' : `${result.sentCount} invitations sent`
)
handleOpenChange(false)
return
}
setEmails(failures.map((item) => item.email))
setDeliveryError(
result.sentCount > 0
? `${result.sentCount} sent. ${failures.length} failed: ${failures.map((item) => item.email).join(', ')}`
: `No invitations were sent: ${failures.map((item) => `${item.email} (${item.error})`).join(', ')}`
)
} catch (error) {
setDeliveryError(getErrorMessage(error, 'Failed to send invitations'))
}
}
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle='Invite users'
dismissDisabled={invite.isPending}
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>Invite users</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emails}
onChange={handleEmailsChange}
validate={validateEmail}
placeholder='Enter emails'
disabled={invite.isPending}
/>
<ChipModalError>
{deliveryError ?? (invite.error ? getErrorMessage(invite.error) : null)}
</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
cancelDisabled={invite.isPending}
primaryAction={{
label: invite.isPending ? 'Sending...' : 'Send invites',
onClick: handleSubmit,
disabled: !canSubmit,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,161 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal, ChipTag } from '@sim/emcn'
import { GridOffset, Plus } from '@sim/emcn/icons'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryState } from 'nuqs'
import {
credentialGroupIdParam,
credentialGroupIdUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { CredentialGroupCreateModal } from '@/ee/credential-groups/components/credential-group-create-modal'
import { CredentialGroupDetail } from '@/ee/credential-groups/components/credential-group-detail'
import { useCredentialGroups, useDeleteCredentialGroup } from '@/hooks/queries/credential-groups'
interface CredentialGroupsSettingsProps {
workspaceId: string
}
export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
const deleteGroup = useDeleteCredentialGroup()
const [search, setSearch] = useSettingsSearch()
const [showCreate, setShowCreate] = useState(false)
const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null)
const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, {
...credentialGroupIdParam.parser,
...credentialGroupIdUrlKeys,
})
const deletingGroup = groups.find((group) => group.id === deletingGroupId)
const selectedGroup = selectedGroupId
? groups.find((group) => group.id === selectedGroupId)
: undefined
const query = search.trim().toLowerCase()
const filtered = query
? groups.filter((group) =>
[group.name, group.description ?? '', ...group.options.map((option) => option.label)].some(
(value) => value.toLowerCase().includes(query)
)
)
: groups
const actions: SettingsAction[] = [
{
text: 'Create group',
icon: Plus,
variant: 'primary',
onSelect: () => setShowCreate(true),
},
]
const handleDelete = async () => {
if (!deletingGroupId) return
try {
await deleteGroup.mutateAsync({ workspaceId, groupId: deletingGroupId })
setDeletingGroupId(null)
} catch {
return
}
}
if (selectedGroup) {
return (
<CredentialGroupDetail
workspaceId={workspaceId}
groupId={selectedGroup.id}
onBack={() => void setSelectedGroupId(null, { history: 'replace' })}
/>
)
}
return (
<>
<SettingsPanel
actions={actions}
search={{
value: search,
onChange: setSearch,
placeholder: 'Search credential groups...',
disabled: isPending,
}}
>
{error ? (
<SettingsEmptyState tone='error'>
{getErrorMessage(error, "Couldn't load credential groups")}
</SettingsEmptyState>
) : isPending ? null : groups.length === 0 ? (
<SettingsEmptyState>Click "Create group" above to get started</SettingsEmptyState>
) : filtered.length === 0 ? (
<SettingsEmptyState variant='inline'>No groups match "{search}"</SettingsEmptyState>
) : (
<div className={RESOURCE_LIST_STACK}>
{filtered.map((group) => {
const optionCount = group.options.length
return (
<SettingsResourceRow
key={group.id}
icon={<GridOffset />}
title={group.name}
description={`${optionCount} account type${optionCount === 1 ? '' : 's'} · ${group.description || 'Managed workspace credentials'}`}
onClick={() => void setSelectedGroupId(group.id)}
clickLabel={`Open ${group.name}`}
navigable
badge={
group.status === 'disabled' ? (
<ChipTag variant='gray'>Disabled</ChipTag>
) : undefined
}
trailing={
<RowActionsMenu
label={`${group.name} actions`}
actions={[
{
label: 'Delete',
destructive: true,
onSelect: () => setDeletingGroupId(group.id),
},
]}
/>
}
/>
)
})}
</div>
)}
</SettingsPanel>
<CredentialGroupCreateModal
open={showCreate}
onOpenChange={setShowCreate}
onCreated={(groupId) => void setSelectedGroupId(groupId)}
workspaceId={workspaceId}
/>
<ChipConfirmModal
open={Boolean(deletingGroup)}
onOpenChange={(open) => !open && !deleteGroup.isPending && setDeletingGroupId(null)}
srTitle='Delete credential group'
title='Delete credential group'
text={[
`Delete ${deletingGroup?.name ?? 'this credential group'}?`,
{ text: ' This cannot be undone.', error: true },
]}
dismissLabel='Cancel'
confirm={{
label: deleteGroup.isPending ? 'Deleting...' : 'Delete',
onClick: handleDelete,
disabled: deleteGroup.isPending,
}}
/>
</>
)
}
@@ -0,0 +1 @@
export { CredentialGroupsSettings } from '@/ee/credential-groups/components/credential-groups-settings'
@@ -0,0 +1,21 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { getSlackManagedUsersFailureNotification } from '@/ee/credential-groups/components/slack-managed-users-modal'
describe('Slack managed-user authorization notifications', () => {
it('treats provider cancellation as a non-error outcome', () => {
expect(getSlackManagedUsersFailureNotification('provider_error')).toEqual({
message: 'Slack authorization canceled',
variant: 'warning',
})
})
it('keeps verification failures in the error state', () => {
expect(getSlackManagedUsersFailureNotification('invalid_response')).toEqual({
message: 'Slack app verification failed. Please try again.',
variant: 'error',
})
})
})
@@ -0,0 +1,314 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Skeleton,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useQueryClient } from '@tanstack/react-query'
import { SlackIcon } from '@/components/icons'
import type { WorkspaceCredential } from '@/lib/api/contracts'
import { useStartSlackCredentialGroupConfiguration } from '@/hooks/queries/credential-groups'
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
const CHANNEL_NAME = 'slack-managed-users'
const AUTHORIZATION_TIMEOUT_MS = 10 * 60 * 1000
interface SlackManagedUsersModalProps {
bots: WorkspaceCredential[]
credentialGroupId: string
error: Error | null
initialCredentialId?: string
isLoading: boolean
onOpenChange: (open: boolean) => void
open: boolean
workspaceId: string
}
interface SlackManagedUsersMessage {
type: typeof CHANNEL_NAME
ok: boolean
reason?: string
state?: string
credentialGroupId?: string
slackBotCredentialId?: string
}
export function getSlackManagedUsersFailureNotification(reason?: string): {
message: string
variant: 'error' | 'warning'
} {
return reason === 'provider_error'
? { message: 'Slack authorization canceled', variant: 'warning' }
: { message: 'Slack app verification failed. Please try again.', variant: 'error' }
}
function isSlackManagedUsersMessage(value: unknown): value is SlackManagedUsersMessage {
if (!value || typeof value !== 'object') return false
const message = value as Record<string, unknown>
return (
message.type === CHANNEL_NAME &&
typeof message.ok === 'boolean' &&
(message.reason === undefined || typeof message.reason === 'string') &&
(message.state === undefined || typeof message.state === 'string') &&
(message.credentialGroupId === undefined || typeof message.credentialGroupId === 'string') &&
(message.slackBotCredentialId === undefined || typeof message.slackBotCredentialId === 'string')
)
}
export function SlackManagedUsersModal({
bots,
credentialGroupId,
error,
initialCredentialId,
isLoading,
onOpenChange,
open,
workspaceId,
}: SlackManagedUsersModalProps) {
const queryClient = useQueryClient()
const startAuthorization = useStartSlackCredentialGroupConfiguration()
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [pending, setPending] = useState(false)
const expectedState = useRef<string | null>(null)
const expectedCredentialId = useRef<string | null>(null)
const popup = useRef<Window | null>(null)
const popupWatcher = useRef<number | null>(null)
const defaultCredentialId = initialCredentialId
? bots.some((bot) => bot.id === initialCredentialId)
? initialCredentialId
: ''
: bots.length === 1
? bots[0].id
: ''
const effectiveCredentialId = selectedCredentialId ?? defaultCredentialId
const selectedBot = bots.find((bot) => bot.id === effectiveCredentialId)
useEffect(() => {
if (!open) return
const channel = new BroadcastChannel(CHANNEL_NAME)
channel.onmessage = (event: MessageEvent<unknown>) => {
if (!isSlackManagedUsersMessage(event.data)) return
if (!expectedState.current || event.data.state !== expectedState.current) return
const verifiedCredentialId = expectedCredentialId.current
expectedState.current = null
expectedCredentialId.current = null
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
popupWatcher.current = null
popup.current?.close()
popup.current = null
setPending(false)
if (!event.data.ok) {
const notification = getSlackManagedUsersFailureNotification(event.data.reason)
if (notification.variant === 'warning') toast.warning(notification.message)
else toast.error(notification.message)
return
}
if (
event.data.credentialGroupId !== credentialGroupId ||
!verifiedCredentialId ||
event.data.slackBotCredentialId !== verifiedCredentialId
) {
toast.error('Slack app verification failed. Please try again.')
return
}
if (!bots.some((bot) => bot.id === verifiedCredentialId)) {
toast.error('The verified Slack app is no longer available.')
return
}
void queryClient.invalidateQueries({
queryKey: credentialGroupKeys.list(workspaceId),
})
void queryClient.invalidateQueries({
queryKey: credentialGroupKeys.detail(workspaceId, credentialGroupId),
})
toast.success('Slack configured')
onOpenChange(false)
reset()
}
return () => channel.close()
}, [bots, credentialGroupId, onOpenChange, open, queryClient, workspaceId])
useEffect(
() => () => {
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
popup.current?.close()
},
[]
)
const reset = () => {
popup.current?.close()
popup.current = null
if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
popupWatcher.current = null
expectedState.current = null
expectedCredentialId.current = null
setSelectedCredentialId(null)
setClientId('')
setClientSecret('')
setPending(false)
startAuthorization.reset()
}
const handleOpenChange = (nextOpen: boolean) => {
if (pending && !nextOpen) return
onOpenChange(nextOpen)
if (!nextOpen) reset()
}
const handleSelectBot = (credentialId: string) => {
if (pending) return
setSelectedCredentialId(credentialId)
setClientId('')
setClientSecret('')
startAuthorization.reset()
}
const handleSubmit = async () => {
if (!selectedBot || pending) return
if (!clientId.trim() || !clientSecret.trim()) return
const opened = window.open('about:blank', 'slack-managed-users', 'width=720,height=760')
if (!opened) {
toast.error('Allow popups to verify the Slack app')
return
}
popup.current = opened
setPending(true)
try {
const result = await startAuthorization.mutateAsync({
workspaceId,
credentialGroupId,
body: {
slackBotCredentialId: selectedBot.id,
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
},
})
expectedState.current = result.state
expectedCredentialId.current = selectedBot.id
opened.location.href = result.authorizationUrl
const startedAt = Date.now()
popupWatcher.current = window.setInterval(() => {
if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return
window.clearInterval(popupWatcher.current ?? undefined)
popupWatcher.current = null
opened.close()
popup.current = null
expectedState.current = null
expectedCredentialId.current = null
setPending(false)
toast.error('Slack authorization expired. Please try again.')
}, 500)
} catch (authorizationError) {
opened.close()
popup.current = null
setPending(false)
toast.error(getErrorMessage(authorizationError, 'Could not start Slack authorization'))
}
}
const noBots = !isLoading && bots.length === 0
const primaryLabel = isLoading
? 'Loading...'
: noBots
? 'Add Slack'
: pending
? 'Waiting for Slack...'
: 'Verify and add'
const primaryDisabled =
isLoading || noBots || !selectedBot || pending || !clientId.trim() || !clientSecret.trim()
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
dismissDisabled={pending}
srTitle='Set up Slack'
size='md'
>
<ChipModalHeader
icon={SlackIcon}
onClose={() => handleOpenChange(false)}
closeDisabled={pending}
>
Set up Slack
</ChipModalHeader>
<ChipModalBody>
{isLoading ? (
<div className='flex flex-col gap-3 px-2 py-1'>
<Skeleton className='h-4 w-24 rounded' />
<Skeleton className='h-9 w-full rounded-lg' />
</div>
) : noBots ? (
<p className='px-2 py-4 text-center text-[var(--text-muted)] text-sm'>
Add a custom Slack app from Integrations before adding Slack to this group.
</p>
) : (
<>
<ChipModalField
type='dropdown'
title='Custom bot'
value={effectiveCredentialId || undefined}
onChange={handleSelectBot}
options={bots.map((bot) => ({
value: bot.id,
label: bot.displayName,
icon: SlackIcon,
}))}
placeholder='Select a custom bot'
disabled={pending}
required
/>
{selectedBot ? (
<>
<ChipModalField
type='input'
title='Client ID'
value={clientId}
onChange={setClientId}
placeholder='Paste the Client ID'
autoComplete='off'
disabled={pending}
required
/>
<ChipModalField
type='input'
inputType='password'
title='Client Secret'
value={clientSecret}
onChange={setClientSecret}
placeholder='Paste the Client Secret'
autoComplete='off'
disabled={pending}
required
/>
</>
) : null}
</>
)}
<ChipModalError>{error ? getErrorMessage(error) : null}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
cancelDisabled={pending}
primaryAction={{
label: primaryLabel,
onClick: () => void handleSubmit(),
disabled: primaryDisabled,
}}
/>
</ChipModal>
)
}
+1
View File
@@ -41,6 +41,7 @@ export enum BlockType {
WORKFLOW_INPUT = 'workflow_input',
CREDENTIAL = 'credential',
CREDENTIAL_GROUP = 'credential_group',
WAIT = 'wait',
@@ -0,0 +1,189 @@
/**
* @vitest-environment node
*/
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
buildHeaders: vi.fn(),
enforceInviteRateLimit: vi.fn(),
listCredentials: vi.fn(),
listGroups: vi.fn(),
listPeople: vi.fn(),
sendInvite: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/delegation', () => ({
authenticateCredentialGroupDelegation: mocks.authenticate,
}))
vi.mock('@/lib/credential-groups/application/list-credentials', () => ({
listCredentialGroupCredentials: { execute: mocks.listCredentials },
}))
vi.mock('@/lib/credential-groups/application/list-groups', () => ({
listCredentialGroupsForWorkflow: { execute: mocks.listGroups },
}))
vi.mock('@/lib/credential-groups/application/list-people', () => ({
CREDENTIAL_GROUP_PEOPLE_STATUSES: [
'invited',
'delivery_failed',
'in_progress',
'completed',
'revoked',
],
listCredentialGroupPeople: { execute: mocks.listPeople },
}))
vi.mock('@/lib/credential-groups/application/send-invite', () => ({
sendCredentialGroupInvite: { execute: mocks.sendInvite },
}))
vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit,
}))
vi.mock('@/executor/utils/http', () => ({
buildExecutorDelegationHeaders: mocks.buildHeaders,
}))
import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
const principal: WorkflowExecutionDelegatedPrincipal = {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:credential-groups',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' },
}
const context = {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
userId: 'user-1',
} as ExecutionContext
const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBlock
describe('CredentialGroupBlockHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' })
mocks.authenticate.mockResolvedValue(principal)
})
it('recognizes only Credential Group blocks', () => {
const handler = new CredentialGroupBlockHandler()
expect(handler.canHandle(block)).toBe(true)
expect(handler.canHandle({ metadata: { id: BlockType.CREDENTIAL } } as SerializedBlock)).toBe(
false
)
})
it('lists credentials with normalized provider, email, and page filters', async () => {
mocks.listCredentials.mockResolvedValue({
credentials: [],
count: 0,
hasMore: false,
nextCursor: null,
})
const result = await new CredentialGroupBlockHandler().execute(context, block, {
operation: 'list_credentials',
credentialGroupId: ' group-1 ',
credentialProviderIds: '["google-email", "google-email"]',
email: ' person@example.com ',
limit: '25',
cursor: ' credential-1 ',
})
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
expect(mocks.listCredentials).toHaveBeenCalledWith({
principal,
input: {
credentialGroupId: 'group-1',
credentialProviderIds: ['google-email'],
email: 'person@example.com',
limit: 25,
cursor: 'credential-1',
},
})
expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null })
})
it('lists groups under workspace-scoped delegation', async () => {
mocks.listGroups.mockResolvedValue({
credentialGroups: [],
count: 0,
hasMore: false,
nextCursor: null,
})
await new CredentialGroupBlockHandler().execute(context, block, {
operation: 'list_groups',
limit: 10,
})
expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined)
expect(mocks.listGroups).toHaveBeenCalledWith({
principal,
input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined },
})
})
it('applies the shared workspace invitation budget before sending', async () => {
mocks.sendInvite.mockResolvedValue({
enrollment: {
id: 'enrollment-1',
email: 'person@example.com',
status: 'invited',
invitedAt: '2026-08-13T12:00:00.000Z',
expiresAt: '2026-08-20T12:00:00.000Z',
},
})
await new CredentialGroupBlockHandler().execute(context, block, {
operation: 'send_invite',
credentialGroupId: 'group-1',
email: ' person@example.com ',
})
expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1')
expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan(
mocks.sendInvite.mock.invocationCallOrder[0]!
)
expect(mocks.sendInvite).toHaveBeenCalledWith({
principal,
input: { credentialGroupId: 'group-1', email: 'person@example.com' },
})
})
it('fails fast on unsupported people statuses', async () => {
await expect(
new CredentialGroupBlockHandler().execute(context, block, {
operation: 'list_people',
credentialGroupId: 'group-1',
peopleStatuses: ['unknown'],
})
).rejects.toThrow('People statuses contain an unsupported value')
expect(mocks.listPeople).not.toHaveBeenCalled()
})
it('rejects unsupported operations before delegation', async () => {
await expect(
new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' })
).rejects.toThrow('Unsupported Credential Group operation: unknown')
expect(mocks.buildHeaders).not.toHaveBeenCalled()
expect(mocks.authenticate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,207 @@
import { createLogger } from '@sim/logger'
import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation'
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups'
import {
CREDENTIAL_GROUP_PEOPLE_STATUSES,
listCredentialGroupPeople,
} from '@/lib/credential-groups/application/list-people'
import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/send-invite'
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments'
import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
import { buildExecutorDelegationHeaders } from '@/executor/utils/http'
import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('CredentialGroupBlockHandler')
const CREDENTIAL_GROUP_OPERATION_IDS = [
'list_credentials',
'send_invite',
'list_people',
'list_groups',
] as const
type CredentialGroupOperation = (typeof CREDENTIAL_GROUP_OPERATION_IDS)[number]
function parseOperation(value: unknown): CredentialGroupOperation {
const operation = typeof value === 'string' ? value : 'list_credentials'
const supported = CREDENTIAL_GROUP_OPERATION_IDS.find((candidate) => candidate === operation)
if (!supported) throw new Error(`Unsupported Credential Group operation: ${operation}`)
return supported
}
function parseStringList(value: unknown, label: string): string[] | undefined {
if (value === undefined || value === null || value === '') return undefined
let parsed: unknown = value
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return undefined
if (!trimmed.startsWith('[')) return [trimmed]
try {
parsed = JSON.parse(trimmed)
} catch {
throw new Error(`${label} must be a valid JSON array of strings`)
}
}
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string' && item.trim())) {
throw new Error(`${label} must be an array of non-empty strings`)
}
const values = [...new Set(parsed.map((item) => item.trim()))]
return values.length > 0 ? values : undefined
}
function parseLimit(value: unknown): number {
const raw = value ?? MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE
const limit =
typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE) {
throw new Error(
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}`
)
}
return limit
}
function parseOptionalString(value: unknown, label: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string' || !value.trim())
throw new Error(`${label} must be a non-empty string`)
return value.trim()
}
function requireString(value: unknown, label: string): string {
const parsed = parseOptionalString(value, label)
if (!parsed) throw new Error(`${label} is required`)
return parsed
}
function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin {
const origin =
ctx.executorDelegationOrigin ??
(ctx.userId
? {
subjectUserId: ctx.userId,
workflowId: ctx.workflowId,
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
}
: undefined)
if (!origin) {
throw new Error('Credential Group operations require an authenticated workflow execution')
}
return origin
}
export class CredentialGroupBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CREDENTIAL_GROUP
}
async execute(
ctx: ExecutionContext,
_block: SerializedBlock,
inputs: Record<string, unknown>
): Promise<BlockOutput> {
if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations')
const operation = parseOperation(inputs.operation)
const credentialGroupId =
operation === 'list_groups'
? undefined
: requireString(inputs.credentialGroupId, 'Credential Group')
const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx))
const authorization = headers.Authorization
if (!authorization) throw new Error('Executor delegation authorization is missing')
const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId)
switch (operation) {
case 'list_credentials': {
const credentialProviderIds = parseStringList(
inputs.credentialProviderIds,
'Credential provider IDs'
)
const result = await listCredentialGroupCredentials.execute({
principal,
input: {
credentialGroupId: credentialGroupId!,
limit: parseLimit(inputs.limit),
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
email: parseOptionalString(inputs.email, 'Email'),
credentialProviderIds,
},
})
logger.info('Listed Credential Group credentials', {
credentialGroupId,
count: result.count,
hasMore: result.hasMore,
})
return result
}
case 'send_invite': {
await enforceCredentialGroupInvitationExecutionRateLimit(principal.workspaceId)
const result = await sendCredentialGroupInvite.execute({
principal,
input: {
credentialGroupId: credentialGroupId!,
email: requireString(inputs.email, 'Email'),
},
})
logger.info('Sent Credential Group invitation', {
credentialGroupId,
enrollmentId: result.enrollment.id,
})
return {
enrollmentId: result.enrollment.id,
email: result.enrollment.email,
status: result.enrollment.status,
invitedAt: result.enrollment.invitedAt,
expiresAt: result.enrollment.expiresAt,
}
}
case 'list_people': {
const statuses = parseStringList(inputs.peopleStatuses, 'People statuses')
const allowedStatuses = new Set<string>(CREDENTIAL_GROUP_PEOPLE_STATUSES)
if (statuses?.some((status) => !allowedStatuses.has(status))) {
throw new Error('People statuses contain an unsupported value')
}
const result = await listCredentialGroupPeople.execute({
principal,
input: {
credentialGroupId: credentialGroupId!,
limit: parseLimit(inputs.limit),
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
email: parseOptionalString(inputs.email, 'Email'),
statuses: statuses as CredentialGroupEnrollmentStatus[] | undefined,
},
})
logger.info('Listed Credential Group people', {
credentialGroupId,
count: result.count,
hasMore: result.hasMore,
})
return result
}
case 'list_groups': {
const result = await listCredentialGroupsForWorkflow.execute({
principal,
input: {
workspaceId: ctx.workspaceId,
limit: parseLimit(inputs.limit),
cursor: parseOptionalString(inputs.cursor, 'Cursor'),
},
})
logger.info('Listed Credential Groups', {
workspaceId: ctx.workspaceId,
count: result.count,
hasMore: result.hasMore,
})
return result
}
}
}
}
@@ -25,11 +25,14 @@ export class CredentialBlockHandler implements BlockHandler {
const operation = typeof inputs.operation === 'string' ? inputs.operation : 'select'
if (operation === 'list') {
return this.listCredentials(ctx.workspaceId, inputs)
switch (operation) {
case 'select':
return this.selectCredential(ctx.workspaceId, inputs)
case 'list':
return this.listCredentials(ctx.workspaceId, inputs)
default:
throw new Error(`Unsupported Credential operation: ${operation}`)
}
return this.selectCredential(ctx.workspaceId, inputs)
}
private async selectCredential(
+2
View File
@@ -9,6 +9,7 @@ import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import { ApiBlockHandler } from '@/executor/handlers/api/api-handler'
import { ConditionBlockHandler } from '@/executor/handlers/condition/condition-handler'
import { CredentialBlockHandler } from '@/executor/handlers/credential/credential-handler'
import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
import { EvaluatorBlockHandler } from '@/executor/handlers/evaluator/evaluator-handler'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler'
@@ -45,6 +46,7 @@ export function createBlockHandlers(): BlockHandler[] {
new WorkflowBlockHandler(),
new WaitBlockHandler(),
new EvaluatorBlockHandler(),
new CredentialGroupBlockHandler(),
new CredentialBlockHandler(),
new GenericBlockHandler(),
]
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput } from '@/lib/api/contracts'
import {
createCredentialGroupContract,
deleteCredentialGroupContract,
getCredentialGroupContract,
inviteCredentialGroupEnrollmentsContract,
resendCredentialGroupEnrollmentContract,
revokeCredentialGroupEnrollmentContract,
startSlackCredentialGroupConfigurationContract,
updateCredentialGroupContract,
} from '@/lib/api/contracts/credential-groups'
import type { ContractJsonResponse } from '@/lib/api/contracts/types'
import {
CREDENTIAL_GROUP_DETAIL_STALE_TIME,
CREDENTIAL_GROUP_LIST_STALE_TIME,
credentialGroupKeys,
fetchCredentialGroupList,
} from '@/hooks/queries/utils/credential-group-queries'
export function useCredentialGroups(workspaceId?: string) {
return useQuery({
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: async ({ signal }) => {
if (!workspaceId) return []
return fetchCredentialGroupList(workspaceId, signal)
},
enabled: Boolean(workspaceId),
staleTime: CREDENTIAL_GROUP_DETAIL_STALE_TIME,
})
}
export function useCredentialGroupDetail(workspaceId?: string, groupId?: string) {
return useInfiniteQuery({
queryKey: credentialGroupKeys.detail(workspaceId, groupId),
queryFn: ({ signal, pageParam }) => {
if (!workspaceId || !groupId)
throw new Error('Credential group detail identifiers are required')
return requestJson(getCredentialGroupContract, {
params: { id: workspaceId, groupId },
query: { limit: 50, ...(pageParam ? { cursor: pageParam } : {}) },
signal,
})
},
initialPageParam: null as string | null,
getNextPageParam: (lastPage: ContractJsonResponse<typeof getCredentialGroupContract>) =>
lastPage.nextCursor ?? undefined,
enabled: Boolean(workspaceId && groupId),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
}
export function useCreateCredentialGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
body,
}: {
workspaceId: string
body: ContractBodyInput<typeof createCredentialGroupContract>
}) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
},
})
}
export function useDeleteCredentialGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, groupId }: { workspaceId: string; groupId: string }) =>
requestJson(deleteCredentialGroupContract, {
params: { id: workspaceId, groupId },
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
},
})
}
export function useUpdateCredentialGroup() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
groupId,
body,
}: {
workspaceId: string
groupId: string
body: ContractBodyInput<typeof updateCredentialGroupContract>
}) =>
requestJson(updateCredentialGroupContract, {
params: { id: workspaceId, groupId },
body,
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) })
queryClient.invalidateQueries({
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
})
},
})
}
export function useStartSlackCredentialGroupConfiguration() {
return useMutation({
mutationFn: async ({
workspaceId,
credentialGroupId,
body,
}: {
workspaceId: string
credentialGroupId: string
body: ContractBodyInput<typeof startSlackCredentialGroupConfigurationContract>
}) =>
requestJson(startSlackCredentialGroupConfigurationContract, {
params: { id: workspaceId, groupId: credentialGroupId },
body,
}),
})
}
export function useInviteCredentialGroupEnrollments() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
groupId,
body,
}: {
workspaceId: string
groupId: string
body: ContractBodyInput<typeof inviteCredentialGroupEnrollmentsContract>
}) =>
requestJson(inviteCredentialGroupEnrollmentsContract, {
params: { id: workspaceId, groupId },
body,
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
})
},
})
}
export function useResendCredentialGroupEnrollment() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
groupId,
enrollmentId,
}: {
workspaceId: string
groupId: string
enrollmentId: string
}) =>
requestJson(resendCredentialGroupEnrollmentContract, {
params: { id: workspaceId, groupId, enrollmentId },
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
})
},
})
}
export function useRevokeCredentialGroupEnrollment() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
groupId,
enrollmentId,
}: {
workspaceId: string
groupId: string
enrollmentId: string
}) =>
requestJson(revokeCredentialGroupEnrollmentContract, {
params: { id: workspaceId, groupId, enrollmentId },
}),
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId),
})
},
})
}
@@ -0,0 +1,26 @@
import { requestJson } from '@/lib/api/client/request'
import type { CredentialGroup } from '@/lib/api/contracts/credential-groups'
import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'
export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY
export const CREDENTIAL_GROUP_LIST_STALE_TIME = 30 * 1000
export const credentialGroupKeys = {
all: ['credential-groups'] as const,
lists: () => [...credentialGroupKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...credentialGroupKeys.lists(), workspaceId ?? ''] as const,
details: () => [...credentialGroupKeys.all, 'detail'] as const,
detail: (workspaceId?: string, groupId?: string) =>
[...credentialGroupKeys.details(), workspaceId ?? '', groupId ?? ''] as const,
}
export async function fetchCredentialGroupList(
workspaceId: string,
signal?: AbortSignal
): Promise<CredentialGroup[]> {
const data = await requestJson(listCredentialGroupsContract, {
params: { id: workspaceId },
signal,
})
return data.credentialGroups
}
+6 -1
View File
@@ -20,6 +20,7 @@ import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { overlayVisibility } from '@/blocks/visibility/context'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
@@ -56,6 +57,7 @@ export function usePermissionConfig(): PermissionConfigResult {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
const blockOverlayVersion = useCustomBlockOverlayVersion()
const hostContext = useOptionalWorkspaceHostContext()
const { data: permissionData, isLoading: isPermissionLoading } =
useUserPermissionConfig(workspaceId)
@@ -94,6 +96,9 @@ export function usePermissionConfig(): PermissionConfigResult {
const isBlockAllowed = useMemo(() => {
return (blockType: string) => {
const normalizedBlockType = blockType.toLowerCase()
if (normalizedBlockType === 'credential_group' && !hostContext?.features?.credentialGroups) {
return false
}
const availability = integrationAvailability.get(normalizedBlockType)
if (
isDeploymentGatedIntegrationType(normalizedBlockType) &&
@@ -106,7 +111,7 @@ export function usePermissionConfig(): PermissionConfigResult {
if (mergedAllowedIntegrations === null) return true
return mergedAllowedIntegrations.includes(normalizedBlockType)
}
}, [integrationAvailability, mergedAllowedIntegrations])
}, [hostContext?.features?.credentialGroups, integrationAvailability, mergedAllowedIntegrations])
const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
createCredentialGroupBodySchema,
credentialGroupEnrollmentDetailSchema,
credentialGroupEnrollmentListQuerySchema,
credentialGroupSchema,
inviteCredentialGroupEnrollmentsBodySchema,
updateCredentialGroupBodySchema,
} from '@/lib/api/contracts/credential-groups'
describe('credential group contracts', () => {
it('accepts a group before account types are added', () => {
const parsed = createCredentialGroupBodySchema.parse({
name: 'Support team',
options: [],
})
expect(parsed.options).toEqual([])
})
it('accepts one option per provider after the group exists', () => {
const parsed = updateCredentialGroupBodySchema.parse({
options: [
{ provider: 'gmail', label: 'Gmail', required: true },
{ provider: 'google-calendar', label: 'Google Calendar', required: true },
{
provider: 'slack',
label: 'Slack',
required: true,
slackBotCredentialId: '11111111-1111-4111-8111-111111111111',
},
],
})
expect(parsed.options).toHaveLength(3)
})
it('rejects the removed multiple-account option', () => {
const result = createCredentialGroupBodySchema.safeParse({
name: 'Support team',
options: [
{
provider: 'gmail',
label: 'Primary inbox',
required: true,
allowMultiple: true,
},
],
})
expect(result.success).toBe(false)
})
it('rejects duplicate option labels case-insensitively', () => {
const result = createCredentialGroupBodySchema.safeParse({
name: 'Support team',
options: [
{
provider: 'gmail',
label: 'Inbox',
required: true,
},
{
provider: 'gmail',
label: 'inbox',
required: true,
},
],
})
expect(result.success).toBe(false)
})
it('rejects duplicate providers', () => {
const result = createCredentialGroupBodySchema.safeParse({
name: 'Support team',
options: [
{ provider: 'gmail', label: 'Primary inbox', required: true },
{ provider: 'gmail', label: 'Escalations', required: true },
],
})
expect(result.success).toBe(false)
})
it('requires a custom bot for Slack option updates', () => {
const missingApp = updateCredentialGroupBodySchema.safeParse({
options: [
{
provider: 'slack',
label: 'Slack',
required: true,
},
],
})
const withApp = updateCredentialGroupBodySchema.safeParse({
options: [
{
provider: 'slack',
label: 'Slack',
required: true,
slackBotCredentialId: '11111111-1111-4111-8111-111111111111',
},
],
})
expect(missingApp.success).toBe(false)
expect(withApp.success).toBe(true)
})
it('rejects duplicate option IDs on update', () => {
const option = {
id: 'option-1',
provider: 'gmail' as const,
required: true,
}
const result = updateCredentialGroupBodySchema.safeParse({
options: [
{ ...option, label: 'Inbox' },
{ ...option, label: 'Escalations' },
],
})
expect(result.success).toBe(false)
})
it('rejects the authorization-app identity from settings responses', () => {
const result = credentialGroupSchema.safeParse({
id: 'group-1',
workspaceId: 'workspace-1',
name: 'Support team',
description: null,
options: [
{
id: 'option-1',
provider: 'gmail',
label: 'Inbox',
required: true,
status: 'active',
authorizationAppId: 'server-only',
},
],
status: 'active',
createdAt: '2026-08-10T12:00:00.000Z',
updatedAt: '2026-08-10T12:00:00.000Z',
})
expect(result.success).toBe(false)
})
it('accepts a batch of invitation emails', () => {
const result = inviteCredentialGroupEnrollmentsBodySchema.parse({
emails: ['alex@example.com', 'sam@example.com'],
})
expect(result.emails).toEqual(['alex@example.com', 'sam@example.com'])
})
it('rejects invitation batches larger than 100 recipients', () => {
const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({
emails: Array.from({ length: 101 }, (_, index) => `user-${index}@example.com`),
})
expect(result.success).toBe(false)
})
it('rejects invalid invitation email addresses', () => {
const result = inviteCredentialGroupEnrollmentsBodySchema.safeParse({
emails: ['not-an-email'],
})
expect(result.success).toBe(false)
})
it('bounds enrollment pages and defaults them to 50 rows', () => {
expect(credentialGroupEnrollmentListQuerySchema.parse({})).toEqual({ limit: 50 })
expect(credentialGroupEnrollmentListQuerySchema.safeParse({ limit: 101 }).success).toBe(false)
})
it('accepts aggregated provider connections on an enrollment', () => {
const result = credentialGroupEnrollmentDetailSchema.parse({
id: 'enrollment-1',
credentialGroupId: 'group-1',
email: 'alex@example.com',
status: 'completed',
expiresAt: '2026-08-18T12:00:00.000Z',
invitedAt: '2026-08-11T12:00:00.000Z',
sentAt: '2026-08-11T12:00:01.000Z',
completedAt: '2026-08-11T12:05:00.000Z',
revokedAt: null,
expired: false,
createdAt: '2026-08-11T12:00:00.000Z',
updatedAt: '2026-08-11T12:05:00.000Z',
connections: [{ provider: 'gmail', status: 'active', count: 2 }],
})
expect(result.connections).toEqual([{ provider: 'gmail', status: 'active', count: 2 }])
})
})
@@ -0,0 +1,426 @@
import { z } from 'zod'
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
import {
CREDENTIAL_GROUP_PROVIDER_IDS,
CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS,
} from '@/lib/credential-groups/providers'
export const credentialGroupProviderSchema = z.enum(CREDENTIAL_GROUP_PROVIDER_IDS)
export const credentialGroupStatusSchema = z.enum(['active', 'disabled'])
export const credentialGroupEnrollmentStatusSchema = z.enum([
'invited',
'delivery_failed',
'in_progress',
'completed',
'revoked',
])
export const credentialGroupOptionConfigurationStatusSchema = z.enum([
'not_configured',
'ready',
'needs_update',
])
const credentialGroupOptionFields = {
label: z.string().trim().min(1, 'Option label is required').max(100),
required: z.boolean(),
} as const
const standardOAuthCredentialGroupOptionInputSchema = z
.object({
provider: z.enum(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS),
...credentialGroupOptionFields,
})
.strict()
const slackCredentialGroupOptionInputSchema = z
.object({
provider: z.literal('slack'),
...credentialGroupOptionFields,
slackBotCredentialId: z.string().uuid('Select a custom Slack bot'),
})
.strict()
export const credentialGroupOptionInputSchema = z.discriminatedUnion('provider', [
standardOAuthCredentialGroupOptionInputSchema,
slackCredentialGroupOptionInputSchema,
])
export const credentialGroupOptionSchema = z.discriminatedUnion('provider', [
standardOAuthCredentialGroupOptionInputSchema.extend({
id: z.string().min(1),
status: z.enum(['active', 'disabled']),
configurationStatus: credentialGroupOptionConfigurationStatusSchema,
}),
slackCredentialGroupOptionInputSchema.extend({
id: z.string().min(1),
status: z.enum(['active', 'disabled']),
configurationStatus: credentialGroupOptionConfigurationStatusSchema,
}),
])
export const credentialGroupOptionUpdateInputSchema = z.discriminatedUnion('provider', [
standardOAuthCredentialGroupOptionInputSchema.extend({
id: z.string().min(1).max(128).optional(),
}),
slackCredentialGroupOptionInputSchema.extend({ id: z.string().min(1).max(128).optional() }),
])
export const credentialGroupSchema = z.object({
id: z.string(),
workspaceId: z.string(),
name: z.string(),
description: z.string().nullable(),
options: z.array(credentialGroupOptionSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length),
status: credentialGroupStatusSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
export type CredentialGroup = z.output<typeof credentialGroupSchema>
export type CredentialGroupOption = z.output<typeof credentialGroupOptionSchema>
export type CredentialGroupOptionInput = z.input<typeof credentialGroupOptionInputSchema>
export const credentialGroupEnrollmentSchema = z.object({
id: z.string(),
credentialGroupId: z.string(),
email: z.string().email(),
status: credentialGroupEnrollmentStatusSchema,
expiresAt: z.string(),
invitedAt: z.string(),
sentAt: z.string().nullable(),
completedAt: z.string().nullable(),
revokedAt: z.string().nullable(),
expired: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type CredentialGroupEnrollment = z.output<typeof credentialGroupEnrollmentSchema>
export const credentialGroupEnrollmentConnectionSchema = z.object({
provider: credentialGroupProviderSchema,
status: z.enum(['active', 'needs_reauth', 'revoked']),
count: z.number().int().positive(),
})
export const credentialGroupEnrollmentDetailSchema = credentialGroupEnrollmentSchema.extend({
connections: z
.array(credentialGroupEnrollmentConnectionSchema)
.max(CREDENTIAL_GROUP_PROVIDER_IDS.length * 3),
})
export type CredentialGroupEnrollmentConnection = z.output<
typeof credentialGroupEnrollmentConnectionSchema
>
export type CredentialGroupEnrollmentDetail = z.output<typeof credentialGroupEnrollmentDetailSchema>
export const credentialGroupWorkspaceParamsSchema = z.object({
id: workspaceIdSchema,
})
export const credentialGroupDetailParamsSchema = credentialGroupWorkspaceParamsSchema.extend({
groupId: z.string().min(1, 'Credential group ID is required').max(128),
})
export const credentialGroupEnrollmentParamsSchema = credentialGroupDetailParamsSchema.extend({
enrollmentId: z.string().min(1, 'Enrollment ID is required').max(128),
})
export const publicCredentialGroupEnrollmentParamsSchema = z.object({
token: z.string().min(1, 'Invitation token is required').max(128),
})
export const startCredentialGroupOAuthParamsSchema =
publicCredentialGroupEnrollmentParamsSchema.extend({
optionId: z.string().min(1, 'Credential option ID is required').max(128),
})
export const credentialGroupOAuthCallbackQuerySchema = z
.object({
state: z.string().min(1, 'OAuth state is required').max(512),
code: z.string().min(1).max(2048).optional(),
error: z.string().min(1).max(256).optional(),
error_description: z.string().max(1000).optional(),
})
.superRefine((query, ctx) => {
if (!query.code && !query.error) {
ctx.addIssue({
code: 'custom',
path: ['code'],
message: 'OAuth callback must include a code or error',
})
}
})
export const credentialGroupOAuthCallbackParamsSchema = z.object({
provider: credentialGroupProviderSchema,
})
export const startSlackCredentialGroupConfigurationBodySchema = z
.object({
slackBotCredentialId: z.string().uuid('Select a custom Slack bot'),
clientId: z.string().trim().min(1, 'Slack Client ID is required').max(256),
clientSecret: z.string().trim().min(1, 'Slack Client Secret is required').max(512),
})
.strict()
export const slackCredentialGroupConfigurationCallbackQuerySchema =
credentialGroupOAuthCallbackQuerySchema
export const credentialGroupEnrollmentListQuerySchema = z.object({
cursor: z.string().min(1, 'Enrollment cursor cannot be empty').max(128).optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
})
export const inviteCredentialGroupEnrollmentsBodySchema = z
.object({
emails: z
.array(z.string().trim().email('Enter a valid email address').max(320))
.min(1, 'At least one email address is required')
.max(100, 'You can invite at most 100 people at once'),
})
.strict()
export type InviteCredentialGroupEnrollmentsBody = z.input<
typeof inviteCredentialGroupEnrollmentsBodySchema
>
export const credentialGroupEnrollmentInviteResultSchema = z.discriminatedUnion('success', [
z.object({
email: z.string().email(),
success: z.literal(true),
enrollment: credentialGroupEnrollmentSchema,
}),
z.object({
email: z.string().email(),
success: z.literal(false),
error: z.string(),
}),
])
export const createCredentialGroupBodySchema = z
.object({
name: z.string().trim().min(1, 'Name is required').max(100),
description: z.string().trim().max(500).optional(),
options: z.array(credentialGroupOptionInputSchema).max(CREDENTIAL_GROUP_PROVIDER_IDS.length),
})
.strict()
.superRefine((body, ctx) => {
const labels = new Set<string>()
const providers = new Set<string>()
for (const [index, option] of body.options.entries()) {
if (option.provider === 'slack') {
ctx.addIssue({
code: 'custom',
path: ['options', index],
message: 'Create the Credential Group before configuring Slack',
})
}
const normalized = option.label.toLocaleLowerCase()
if (labels.has(normalized)) {
ctx.addIssue({
code: 'custom',
path: ['options', index, 'label'],
message: 'Credential option labels must be unique within a group',
})
}
labels.add(normalized)
if (providers.has(option.provider)) {
ctx.addIssue({
code: 'custom',
path: ['options', index, 'provider'],
message: 'Each provider can only be added once',
})
}
providers.add(option.provider)
}
})
export type CreateCredentialGroupBody = z.input<typeof createCredentialGroupBodySchema>
export const updateCredentialGroupBodySchema = z
.object({
name: z.string().trim().min(1, 'Name is required').max(100).optional(),
description: z.string().trim().max(500).nullable().optional(),
options: z
.array(credentialGroupOptionUpdateInputSchema)
.max(CREDENTIAL_GROUP_PROVIDER_IDS.length)
.optional(),
status: credentialGroupStatusSchema.optional(),
})
.strict()
.superRefine((body, ctx) => {
if (Object.keys(body).length === 0) {
ctx.addIssue({ code: 'custom', message: 'At least one field must be updated' })
}
if (!body.options) return
const labels = new Set<string>()
const optionIds = new Set<string>()
const providers = new Set<string>()
for (const [index, option] of body.options.entries()) {
const normalized = option.label.toLowerCase()
if (labels.has(normalized)) {
ctx.addIssue({
code: 'custom',
path: ['options', index, 'label'],
message: 'Credential option labels must be unique within a group',
})
}
labels.add(normalized)
if (providers.has(option.provider)) {
ctx.addIssue({
code: 'custom',
path: ['options', index, 'provider'],
message: 'Each provider can only be added once',
})
}
providers.add(option.provider)
if (option.id && optionIds.has(option.id)) {
ctx.addIssue({
code: 'custom',
path: ['options', index, 'id'],
message: 'Credential option IDs must be unique within a group',
})
}
if (option.id) optionIds.add(option.id)
}
})
export type UpdateCredentialGroupBody = z.input<typeof updateCredentialGroupBodySchema>
export const listCredentialGroupsContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/credential-groups',
params: credentialGroupWorkspaceParamsSchema,
response: {
mode: 'json',
schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }),
},
})
export const createCredentialGroupContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/credential-groups',
params: credentialGroupWorkspaceParamsSchema,
body: createCredentialGroupBodySchema,
response: {
mode: 'json',
status: 201,
schema: z.object({ credentialGroup: credentialGroupSchema }),
},
})
export const getCredentialGroupContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/credential-groups/[groupId]',
params: credentialGroupDetailParamsSchema,
query: credentialGroupEnrollmentListQuerySchema,
response: {
mode: 'json',
schema: z.object({
credentialGroup: credentialGroupSchema,
enrollments: z.array(credentialGroupEnrollmentDetailSchema),
nextCursor: z.string().nullable(),
}),
},
})
export const inviteCredentialGroupEnrollmentsContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments',
params: credentialGroupDetailParamsSchema,
body: inviteCredentialGroupEnrollmentsBodySchema,
response: {
mode: 'json',
schema: z.object({
results: z.array(credentialGroupEnrollmentInviteResultSchema).min(1).max(100),
sentCount: z.number().int().nonnegative(),
failedCount: z.number().int().nonnegative(),
}),
},
})
export const resendCredentialGroupEnrollmentContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]/resend',
params: credentialGroupEnrollmentParamsSchema,
response: {
mode: 'json',
schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }),
},
})
export const revokeCredentialGroupEnrollmentContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/credential-groups/[groupId]/enrollments/[enrollmentId]',
params: credentialGroupEnrollmentParamsSchema,
response: {
mode: 'json',
schema: z.object({ credentialGroupEnrollment: credentialGroupEnrollmentSchema }),
},
})
export const deleteCredentialGroupContract = defineRouteContract({
method: 'DELETE',
path: '/api/workspaces/[id]/credential-groups/[groupId]',
params: credentialGroupDetailParamsSchema,
response: {
mode: 'json',
schema: z.object({ success: z.literal(true) }),
},
})
export const updateCredentialGroupContract = defineRouteContract({
method: 'PATCH',
path: '/api/workspaces/[id]/credential-groups/[groupId]',
params: credentialGroupDetailParamsSchema,
body: updateCredentialGroupBodySchema,
response: {
mode: 'json',
schema: z.object({ credentialGroup: credentialGroupSchema }),
},
})
export const startSlackCredentialGroupConfigurationContract = defineRouteContract({
method: 'POST',
path: '/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users',
params: credentialGroupDetailParamsSchema,
body: startSlackCredentialGroupConfigurationBodySchema,
response: {
mode: 'json',
schema: z.object({
authorizationUrl: z.string().url(),
state: z.string().min(1),
}),
},
})
export const slackCredentialGroupConfigurationCallbackContract = defineRouteContract({
method: 'GET',
path: '/api/credential-groups/slack-managed-users/callback',
query: slackCredentialGroupConfigurationCallbackQuerySchema,
response: { mode: 'text' },
})
export const startCredentialGroupOAuthContract = defineRouteContract({
method: 'GET',
path: '/api/credential-groups/enroll/[token]/oauth/[optionId]',
params: startCredentialGroupOAuthParamsSchema,
response: { mode: 'empty' },
})
export const completeCredentialGroupEnrollmentContract = defineRouteContract({
method: 'POST',
path: '/api/credential-groups/enroll/[token]/complete',
params: publicCredentialGroupEnrollmentParamsSchema,
response: { mode: 'empty' },
})
export const credentialGroupOAuthCallbackContract = defineRouteContract({
method: 'GET',
path: '/api/credential-groups/oauth/[provider]/callback',
params: credentialGroupOAuthCallbackParamsSchema,
query: credentialGroupOAuthCallbackQuerySchema,
response: { mode: 'empty' },
})
+7 -2
View File
@@ -17,9 +17,14 @@ export const workspaceCredentialTypeSchema = z.enum([
'env_personal',
'service_account',
])
const creatableWorkspaceCredentialTypeSchema = z.enum([
'oauth',
'env_workspace',
'env_personal',
'service_account',
])
export const workspaceCredentialRoleSchema = z.enum(['admin', 'member'])
export const workspaceCredentialMemberStatusSchema = z.enum(['active', 'pending', 'revoked'])
export const workspaceCredentialSchema = z.object({
id: z.string(),
workspaceId: z.string(),
@@ -112,7 +117,7 @@ export const serviceAccountJsonSchema = z
export const createCredentialBodySchema = z
.object({
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
type: workspaceCredentialTypeSchema,
type: creatableWorkspaceCredentialTypeSchema,
displayName: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(500).optional(),
providerId: z.string().trim().min(1).optional(),
@@ -7,6 +7,8 @@ import type {
} from '@/lib/api/contracts/types'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const MANAGED_OAUTH_DELEGATION_HEADER = 'x-sim-managed-oauth-delegation'
export const oauthAccountSummarySchema = z.object({
id: z.string(),
name: z.string(),
@@ -70,6 +72,7 @@ export const oauthTokenRequestBodySchema = z
credentialId: z.string().min(1).optional(),
credentialAccountUserId: z.string().min(1).optional(),
providerId: z.string().min(1).optional(),
toolId: z.string().min(1).optional(),
workflowId: z.string().min(1).nullish(),
scopes: z.array(z.string()).optional(),
impersonateEmail: impersonateEmailSchema.optional(),
@@ -91,6 +94,10 @@ export const oauthTokenPostQuerySchema = z.object({
userId: z.string().min(1).optional(),
})
export const oauthTokenPostHeadersSchema = z.object({
[MANAGED_OAUTH_DELEGATION_HEADER]: z.string().min(1).optional(),
})
const oauthTokenResponseSchema = z.object({
accessToken: z.string(),
idToken: z.string().optional(),
@@ -119,6 +126,7 @@ export const oauthTokenPostContract = defineRouteContract({
method: 'POST',
path: '/api/auth/oauth/token',
query: oauthTokenPostQuerySchema,
headers: oauthTokenPostHeadersSchema,
body: oauthTokenRequestBodySchema,
response: {
mode: 'json',
+5
View File
@@ -256,6 +256,11 @@ export const workspaceHostContextSchema = z.object({
isHostOrganizationMember: z.boolean(),
isHostOrganizationAdmin: z.boolean(),
}),
features: z
.object({
credentialGroups: z.boolean(),
})
.optional(),
})
export type WorkspaceHostContext = z.output<typeof workspaceHostContextSchema>
@@ -0,0 +1,133 @@
import { createHash } from 'node:crypto'
import type { OAuth2Tokens } from '@better-auth/core/oauth2'
import type { GenericOAuthConfig } from 'better-auth/plugins'
import { OAuth2Client, type TokenPayload } from 'google-auth-library'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
const GOOGLE_OPENID_SCOPE = 'openid'
const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
const GOOGLE_PROFILE_SCOPE = 'https://www.googleapis.com/auth/userinfo.profile'
const GMAIL_READONLY_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'
const GMAIL_MODIFY_SCOPE = 'https://www.googleapis.com/auth/gmail.modify'
const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send'
const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
export interface ManagedOAuthConnectorIdentity {
providerSubjectId: string
providerTenantId: string | null
email: string
emailVerified: boolean
displayName?: string
avatarUrl?: string
nonce?: string
grantedScopes: string[]
}
export interface ManagedOAuthConnectorConfig {
additionalScopes: string[]
requiresRefreshToken: boolean
pkce: boolean
prompt?: string
authorizationUrlParams?: Record<string, string>
getAuthorizationAppId(clientId: string): string
verifyIdentity(params: {
tokens: OAuth2Tokens
clientId: string
}): Promise<ManagedOAuthConnectorIdentity>
hasRequiredScopes(grantedScopes: string[], requiredScopes: string[]): boolean
isTerminalRefreshError(errorCode: string | undefined): boolean
}
export interface ConnectorProviderConfig extends GenericOAuthConfig {
managedOAuth: ManagedOAuthConnectorConfig
}
function canonicalGoogleScope(scope: string): string {
if (scope === 'email') return GOOGLE_EMAIL_SCOPE
if (scope === 'profile') return GOOGLE_PROFILE_SCOPE
return scope
}
function hasRequiredGoogleScopes(
providerId: string,
grantedScopes: string[],
requiredScopes: string[]
): boolean {
const granted = new Set(grantedScopes.map(canonicalGoogleScope))
return requiredScopes.every((requestedScope) => {
const required = canonicalGoogleScope(requestedScope)
if (granted.has(required)) return true
return (
providerId === 'google-email' &&
granted.has(GMAIL_MODIFY_SCOPE) &&
(required === GMAIL_READONLY_SCOPE ||
required === GMAIL_SEND_SCOPE ||
required === GMAIL_LABELS_SCOPE)
)
})
}
function requireVerifiedGooglePayload(payload: TokenPayload | undefined): TokenPayload & {
sub: string
email: string
} {
if (!payload?.sub || !payload.email || payload.email_verified !== true) {
throw new Error('Google returned an invalid identity token')
}
return payload as TokenPayload & { sub: string; email: string }
}
export function createGoogleManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig {
return {
additionalScopes: [GOOGLE_OPENID_SCOPE],
requiresRefreshToken: true,
pkce: true,
prompt: 'consent select_account',
authorizationUrlParams: { include_granted_scopes: 'false' },
getAuthorizationAppId(clientId) {
return `google:${createHash('sha256').update(clientId).digest('hex')}`
},
async verifyIdentity({ tokens, clientId }) {
if (!tokens.idToken || !tokens.accessToken) {
throw new Error('Google returned an incomplete authorization')
}
const client = new OAuth2Client({ clientId })
const ticket = await client.verifyIdToken({ idToken: tokens.idToken, audience: clientId })
const payload = requireVerifiedGooglePayload(ticket.getPayload())
const tokenInfo = await client.getTokenInfo(tokens.accessToken)
if (tokenInfo.aud !== clientId || tokenInfo.sub !== payload.sub) {
throw new Error('Google returned an access token for another identity')
}
return {
providerSubjectId: payload.sub,
providerTenantId: payload.hd ?? null,
email: payload.email,
emailVerified: true,
...(payload.name ? { displayName: payload.name } : {}),
...(payload.picture ? { avatarUrl: payload.picture } : {}),
...(payload.nonce ? { nonce: payload.nonce } : {}),
grantedScopes: [...new Set(tokenInfo.scopes)],
}
},
hasRequiredScopes(grantedScopes, requiredScopes) {
return hasRequiredGoogleScopes(providerId, grantedScopes, requiredScopes)
},
isTerminalRefreshError(errorCode) {
return errorCode === 'invalid_grant'
},
}
}
export function getManagedOAuthConnectorProviderConfig(
providerId: string
): ConnectorProviderConfig | undefined {
if (providerId !== 'google-email' && providerId !== 'google-calendar') return undefined
const connector = buildConnectorProviders().find(
(candidate) => candidate.providerId === providerId
)
if (!connector) return undefined
return {
...connector,
managedOAuth: createGoogleManagedOAuthConnector(providerId),
}
}
+8 -1
View File
@@ -18,7 +18,7 @@ export interface CredentialAccessResult {
credentialOwnerUserId?: string
workspaceId?: string
resolvedCredentialId?: string
credentialType?: 'oauth' | 'service_account'
credentialType?: 'oauth' | 'managed_oauth' | 'service_account'
}
const NO_CREDENTIAL_ACCESS =
@@ -123,6 +123,13 @@ export async function authorizeCredentialUseForAuth(
const accessError = credentialAccessError(platformAccess)
if (accessError) return { ok: false, error: accessError }
if (platformCredential.type === 'managed_oauth') {
return {
ok: false,
error: 'Managed credential access requires scoped workflow delegation',
}
}
if (platformCredential.type === 'service_account') {
return {
ok: true,
+46
View File
@@ -49,6 +49,19 @@ describe('principal subject users', () => {
})
).toThrow(PrincipalSubjectUserRequiredError)
})
it('fails fast instead of fabricating a Sim user for an external enrollment', () => {
expect(() =>
requirePrincipalSubjectUserId({
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
invitationTokenHash: 'hash-1',
})
).toThrow(PrincipalSubjectUserRequiredError)
})
})
describe('principal actors', () => {
@@ -86,6 +99,26 @@ describe('principal actors', () => {
actorId: null,
actorName: 'Workspace API key',
})
expect(
resolvePrincipalAuditAttribution({
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
invitationTokenHash: 'hash-1',
})
).toEqual({
actor: {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
},
actorId: null,
actorName: 'person@example.com',
})
})
it('projects principals into their shared actor identity', () => {
@@ -158,4 +191,17 @@ describe('principal actors', () => {
})
).toThrow('Workspace API key attribution requires a workspace billing owner')
})
it('fails fast when external enrollment identity is used for user attribution', () => {
expect(() =>
resolvePrincipalAttribution({
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
invitationTokenHash: 'hash-1',
})
).toThrow(PrincipalSubjectUserRequiredError)
})
})
@@ -1,14 +1,22 @@
import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal'
import type {
CredentialGroupEnrollmentPrincipal,
DelegatedPrincipal,
DelegatedServiceId,
Principal,
} from '@sim/auth/principal'
import type { PermissionType } from '@sim/platform-authz/workspace'
import type { ApplicationOperation } from '@/lib/core/application/operation'
type WorkspaceApiKeyPolicy<R extends PermissionType> = R extends 'admin' ? 'deny' : 'allow' | 'deny'
export type PrincipalKind = Principal['kind']
export type PrincipalKind = Exclude<Principal['kind'], 'credential_group_enrollment'>
type NonDelegatedPrincipalForOperation<
O extends { readonly principalKinds: readonly PrincipalKind[] },
> = Exclude<Extract<Principal, { kind: O['principalKinds'][number] }>, DelegatedPrincipal>
> = Exclude<
Extract<Principal, { kind: O['principalKinds'][number] }>,
DelegatedPrincipal | CredentialGroupEnrollmentPrincipal
>
type DelegatedPrincipalForOperation<
O extends {
+1
View File
@@ -528,6 +528,7 @@ export const env = createEnv({
TABLES_V2_API: z.boolean().optional(), // Enable the v2 tables HTTP API (public /api/v2/tables + internal /api/table/[tableId]/query predicate-grammar route)
TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles)
TABLE_VIEWS: z.boolean().optional(), // Enable saved table views (named filter/sort/column-visibility presets) and the column show/hide menu
CREDENTIAL_GROUPS: z.boolean().optional(), // Enable enterprise Credential Groups globally
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
@@ -14,6 +14,8 @@ const { mockFetch, mockIsPlatformAdmin, envRef } = vi.hoisted(() => ({
FORKING_ENABLED: undefined as boolean | undefined,
DEPLOY_AS_BLOCK: undefined as boolean | undefined,
TABLES_V2_API: undefined as boolean | undefined,
TABLE_VIEWS: undefined as boolean | undefined,
CREDENTIAL_GROUPS: undefined as boolean | undefined,
},
}))
@@ -122,6 +124,8 @@ describe('isFeatureEnabled', () => {
setEnvFlags({ isAppConfigEnabled: false })
envRef.FORKING_ENABLED = undefined
envRef.DEPLOY_AS_BLOCK = undefined
envRef.CREDENTIAL_GROUPS = undefined
envRef.TABLE_VIEWS = undefined
})
describe('workspace-forking flag', () => {
@@ -160,6 +164,20 @@ describe('isFeatureEnabled', () => {
})
})
describe('credential-groups flag', () => {
it('uses a global fallback switch off AppConfig', async () => {
expect(await isFeatureEnabled('credential-groups')).toBe(false)
envRef.CREDENTIAL_GROUPS = true
expect(await isFeatureEnabled('credential-groups')).toBe(true)
})
it('uses only the global AppConfig clause', async () => {
withAppConfig({ 'credential-groups': { enabled: true } })
expect(await isFeatureEnabled('credential-groups')).toBe(true)
})
})
describe('table-views flag', () => {
it('falls back to TABLE_VIEWS when AppConfig is disabled', async () => {
envRef.TABLE_VIEWS = undefined
@@ -144,6 +144,12 @@ const FEATURE_FLAGS = {
'Off-AppConfig falls back to TABLE_VIEWS.',
fallback: 'TABLE_VIEWS',
},
'credential-groups': {
description:
'Workspace-owned collections that gather managed OAuth credentials from external users. ' +
'Global on/off only; hosted workspaces must also have an Enterprise subscription.',
fallback: 'CREDENTIAL_GROUPS',
},
} satisfies Record<string, FeatureFlagDefinition>
/**
@@ -0,0 +1,26 @@
import type { Principal } from '@sim/auth/principal'
import type {
WorkspaceAuthorizationContext,
WorkspaceDelegationPolicy,
} from '@/lib/core/application'
import type { CredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials'
export const CREDENTIAL_GROUP_DELEGATION_AUDIENCE = 'sim:credential-groups'
export interface CredentialGroupApplicationContext
extends WorkspaceAuthorizationContext,
CredentialGroupCredentialListContext {}
export const credentialGroupDelegationPolicy = {
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
isWithinScope: (
principal: Extract<Principal, { kind: 'delegated' }>,
context: CredentialGroupApplicationContext
) => principal.resourceScope?.credentialGroupId === context.credentialGroupId,
} satisfies WorkspaceDelegationPolicy<CredentialGroupApplicationContext>
export const credentialGroupWorkspaceDelegationPolicy = {
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
isWithinScope: (principal: Extract<Principal, { kind: 'delegated' }>) =>
principal.resourceScope?.credentialGroupId === undefined,
} satisfies WorkspaceDelegationPolicy<WorkspaceAuthorizationContext>
@@ -0,0 +1,53 @@
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { CredentialGroupApplicationContext } from '@/lib/credential-groups/application/authorization'
import {
isCredentialGroupsAvailable,
resolveCredentialGroupsAvailability,
} from '@/lib/credential-groups/availability'
import { loadCredentialGroupCredentialListContext } from '@/lib/credential-groups/credentials'
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
export async function requireCredentialGroupsAvailable(workspaceId: string): Promise<void> {
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
const availability = await resolveCredentialGroupsAvailability(ownerBilling)
if (!availability.available) {
const message =
availability.reason === 'enterprise_plan_required'
? 'Credential Groups are not available. Enterprise plan required.'
: 'Credential Groups are not available'
throw new OrchestrationError('forbidden', message)
}
}
export async function requireCredentialGroupSettingsAvailable(workspaceId: string): Promise<void> {
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
if (!(await isCredentialGroupsAvailable(ownerBilling))) {
throw new OrchestrationError('not_found', 'Credential Groups are not available')
}
}
export async function resolveCredentialGroupWorkspaceContext(workspaceId: string) {
const workspace = await loadActiveWorkspaceApplicationContext(workspaceId)
if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found')
return workspace
}
export async function resolveCredentialGroupContext(
credentialGroupId: string
): Promise<CredentialGroupApplicationContext> {
const group = await loadCredentialGroupCredentialListContext(credentialGroupId)
if (!group) throw new OrchestrationError('not_found', 'Credential group not found')
return { ...(await resolveCredentialGroupWorkspaceContext(group.workspaceId)), ...group }
}
export async function resolveCredentialGroupSettingsContext(
credentialGroupId: string,
assertedWorkspaceId: string
): Promise<CredentialGroupApplicationContext> {
const context = await resolveCredentialGroupContext(credentialGroupId)
if (context.workspaceId !== assertedWorkspaceId) {
throw new OrchestrationError('not_found', 'Credential group not found')
}
return context
}
@@ -0,0 +1,41 @@
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import {
InvalidInternalDelegationTokenError,
verifyInternalDelegationToken,
} from '@/lib/auth/internal'
import {
bindInternalExecutorDelegation,
InvalidInternalDelegationBindingError,
} from '@/lib/auth/internal-delegation'
import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization'
export class InvalidCredentialGroupDelegationError extends Error {
constructor() {
super('Credential Group execution requires valid workflow delegation')
this.name = 'InvalidCredentialGroupDelegationError'
}
}
/** Authenticates and binds executor claims to Credential Group application scope. */
export async function authenticateCredentialGroupDelegation(
authorization: string,
credentialGroupId?: string
): Promise<WorkflowExecutionDelegatedPrincipal> {
if (!authorization.startsWith('Bearer ')) throw new InvalidCredentialGroupDelegationError()
try {
const claims = await verifyInternalDelegationToken(authorization.slice('Bearer '.length))
return await bindInternalExecutorDelegation(claims, {
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}),
})
} catch (error) {
if (
error instanceof InvalidInternalDelegationTokenError ||
error instanceof InvalidInternalDelegationBindingError
) {
throw new InvalidCredentialGroupDelegationError()
}
throw error
}
}
@@ -0,0 +1,12 @@
import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal'
import { authenticatePublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments'
/** Exchanges a valid invitation bearer for its bounded external enrollment principal. */
export async function authenticateCredentialGroupEnrollment(
invitationToken: string
): Promise<CredentialGroupEnrollmentPrincipal | null> {
if (!invitationToken.trim() || invitationToken.length > 128) return null
const identity = await authenticatePublicCredentialGroupEnrollment(invitationToken)
if (!identity) return null
return Object.freeze({ kind: 'credential_group_enrollment' as const, ...identity })
}
@@ -0,0 +1,32 @@
import type { ApplicationOperation } from '@/lib/core/application'
export interface CredentialGroupEnrollmentOperation<Id extends string = string>
extends ApplicationOperation<Id> {
readonly principalKind: 'credential_group_enrollment'
}
function defineCredentialGroupEnrollmentOperation<const Id extends string>(
operation: CredentialGroupEnrollmentOperation<Id>
): CredentialGroupEnrollmentOperation<Id> {
if (!operation.id.trim()) throw new Error('Credential Group enrollment operation ID is required')
return Object.freeze(operation)
}
export const credentialGroupEnrollmentOperations = {
read: defineCredentialGroupEnrollmentOperation({
id: 'credential_groups.enrollment.read',
principalKind: 'credential_group_enrollment',
}),
startOAuth: defineCredentialGroupEnrollmentOperation({
id: 'credential_groups.enrollment.oauth.start',
principalKind: 'credential_group_enrollment',
}),
completeOAuth: defineCredentialGroupEnrollmentOperation({
id: 'credential_groups.enrollment.oauth.complete',
principalKind: 'credential_group_enrollment',
}),
complete: defineCredentialGroupEnrollmentOperation({
id: 'credential_groups.enrollment.complete',
principalKind: 'credential_group_enrollment',
}),
} as const
@@ -0,0 +1,276 @@
/**
* @vitest-environment node
*/
import type { SessionPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getWorkspaceOwnerSubscriptionAccess: vi.fn(),
listCredentials: vi.fn(),
loadGroup: vi.fn(),
loadWorkspace: vi.fn(),
resolveCredentialGroupsAvailability: vi.fn(),
resolvePermission: vi.fn(),
}))
vi.mock('@/lib/billing/core/workspace-access', () => ({
getWorkspaceOwnerSubscriptionAccess: mocks.getWorkspaceOwnerSubscriptionAccess,
}))
vi.mock('@/lib/credential-groups/availability', () => ({
resolveCredentialGroupsAvailability: mocks.resolveCredentialGroupsAvailability,
}))
vi.mock('@/lib/credential-groups/credentials', () => ({
CredentialGroupCredentialCursorNotFoundError: class extends Error {
constructor() {
super('Credential group credential cursor not found')
this.name = 'CredentialGroupCredentialCursorNotFoundError'
}
},
listCredentialGroupCredentialReferences: mocks.listCredentials,
loadCredentialGroupCredentialListContext: mocks.loadGroup,
MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE: 100,
}))
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
loadActiveWorkspaceApplicationContext: mocks.loadWorkspace,
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: (permission: string | null, required: string) =>
permission === 'admin' || permission === 'write' || permission === required,
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
import { CredentialGroupCredentialCursorNotFoundError } from '@/lib/credential-groups/credentials'
const groupContext = {
credentialGroupId: 'group-1',
workspaceId: 'workspace-1',
name: 'Credential Group',
status: 'active' as const,
options: [
{
id: 'option-1',
provider: 'gmail' as const,
label: 'Work Gmail',
authorizationAppId: 'google:client-1',
requiredScopes: ['gmail.readonly'],
scopeVersion: 1,
required: true,
status: 'active' as const,
},
{
id: 'option-disabled',
provider: 'gmail' as const,
label: 'Old Gmail',
authorizationAppId: 'google:client-1',
requiredScopes: ['gmail.readonly'],
scopeVersion: 1,
required: false,
status: 'disabled' as const,
},
],
}
const workspaceContext = {
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'billing-owner-1',
}
const input = { credentialGroupId: 'group-1', limit: 50 }
function executorPrincipal(credentialGroupId = 'group-1'): WorkflowExecutionDelegatedPrincipal {
return {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:credential-groups',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialGroupId },
delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' },
}
}
describe('listCredentialGroupCredentials', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.loadGroup.mockResolvedValue(groupContext)
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('read')
mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: true })
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({ available: true })
mocks.listCredentials.mockResolvedValue({
credentials: [
{
credentialId: 'credential-1',
email: 'person@example.com',
displayName: 'person@example.com',
providerId: 'google-email',
providerSubjectId: 'google-subject-1',
providerTenantId: null,
},
],
nextCursor: 'credential-1',
})
})
it('rejects unsupported principals before loading the group', async () => {
const principal: SessionPrincipal = {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
}
await expect(
listCredentialGroupCredentials.execute({ principal, input })
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.loadGroup).not.toHaveBeenCalled()
})
it('rejects executor delegation scoped to another group', async () => {
await expect(
listCredentialGroupCredentials.execute({
principal: executorPrincipal('group-2'),
input,
})
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('returns a bounded page after current workspace and entitlement checks', async () => {
const result = await listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input,
})
expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', 'workspace-1', null, undefined, {
forUpdate: undefined,
})
expect(mocks.listCredentials).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
limit: 50,
cursor: undefined,
email: undefined,
credentialProviderIds: undefined,
credentialGroupOptionIds: ['option-1'],
})
expect(result).toEqual({
credentials: [
{
credentialId: 'credential-1',
email: 'person@example.com',
displayName: 'person@example.com',
providerId: 'google-email',
providerSubjectId: 'google-subject-1',
providerTenantId: null,
},
],
count: 1,
hasMore: true,
nextCursor: 'credential-1',
})
})
it('filters by canonical providers active in the group', async () => {
await listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input: { ...input, credentialProviderIds: ['google-email', 'google-email'] },
})
expect(mocks.listCredentials).toHaveBeenCalledWith(
expect.objectContaining({ credentialProviderIds: ['google-email'] })
)
})
it('normalizes an exact enrollment email filter', async () => {
await listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input: { ...input, email: ' Person@Example.COM ' },
})
expect(mocks.listCredentials).toHaveBeenCalledWith(
expect.objectContaining({ email: 'person@example.com' })
)
})
it('rejects providers that are not active in the group before credential access', async () => {
await expect(
listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input: { ...input, credentialProviderIds: ['slack'] },
})
).rejects.toMatchObject({ code: 'validation' })
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('fails before listing when the group is disabled', async () => {
mocks.loadGroup.mockResolvedValue({ ...groupContext, status: 'disabled' })
await expect(
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
).rejects.toMatchObject({ code: 'conflict' })
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('fails before listing when Credential Groups are unavailable', async () => {
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({
available: false,
reason: 'feature_disabled',
})
await expect(
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
).rejects.toMatchObject({
code: 'forbidden',
message: 'Credential Groups are not available',
})
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('identifies the Enterprise requirement for unavailable hosted workspaces', async () => {
mocks.getWorkspaceOwnerSubscriptionAccess.mockResolvedValue({ isEnterprise: false })
mocks.resolveCredentialGroupsAvailability.mockResolvedValue({
available: false,
reason: 'enterprise_plan_required',
})
await expect(
listCredentialGroupCredentials.execute({ principal: executorPrincipal(), input })
).rejects.toMatchObject({
code: 'forbidden',
message: 'Credential Groups are not available. Enterprise plan required.',
})
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('rejects limits outside the bounded page size', async () => {
await expect(
listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input: { ...input, limit: 101 },
})
).rejects.toMatchObject({ code: 'validation' })
expect(mocks.getWorkspaceOwnerSubscriptionAccess).not.toHaveBeenCalled()
expect(mocks.listCredentials).not.toHaveBeenCalled()
})
it('classifies a stale or cross-group cursor as invalid input', async () => {
mocks.listCredentials.mockRejectedValueOnce(new CredentialGroupCredentialCursorNotFoundError())
await expect(
listCredentialGroupCredentials.execute({
principal: executorPrincipal(),
input: { ...input, cursor: 'credential-other' },
})
).rejects.toMatchObject({ code: 'validation' })
})
})
@@ -0,0 +1,111 @@
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
resolveCredentialGroupContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupCredentialCursorNotFoundError,
type CredentialGroupCredentialReference,
listCredentialGroupCredentialReferences,
MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE,
} from '@/lib/credential-groups/credentials'
import {
getCredentialGroupProviderId,
isCredentialGroupProvider,
} from '@/lib/credential-groups/providers'
export interface ListCredentialGroupCredentialsInput {
credentialGroupId: string
limit: number
cursor?: string
email?: string
credentialProviderIds?: string[]
}
export interface ListCredentialGroupCredentialsResult {
credentials: CredentialGroupCredentialReference[]
count: number
hasMore: boolean
nextCursor: string | null
}
export const listCredentialGroupCredentials = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.listCredentials,
resolveContext: ({ input }: { input: ListCredentialGroupCredentialsInput }) =>
resolveCredentialGroupContext(input.credentialGroupId),
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
execute: async ({ input, context }): Promise<ListCredentialGroupCredentialsResult> => {
if (
!Number.isInteger(input.limit) ||
input.limit < 1 ||
input.limit > MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE
) {
throw new OrchestrationError(
'validation',
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE}`
)
}
if (context.status !== 'active') {
throw new OrchestrationError('conflict', 'Credential group is disabled')
}
const email = input.email ? normalizeEmail(input.email) : undefined
if (email && !isValidEmailSyntax(email)) {
throw new OrchestrationError('validation', 'Email must be a valid address')
}
const credentialProviderIds = [...new Set(input.credentialProviderIds ?? [])]
if (credentialProviderIds.some((providerId) => !providerId.trim())) {
throw new OrchestrationError('validation', 'Credential provider IDs must not be empty')
}
const activeOptions = context.options.filter((option) => option.status === 'active')
const activeProviderIds = new Set(
activeOptions.map((option) => {
if (!isCredentialGroupProvider(option.provider)) {
throw new Error(`Credential Group provider is not registered: ${option.provider}`)
}
return getCredentialGroupProviderId(option.provider)
})
)
const invalidProviderIds = credentialProviderIds.filter(
(providerId) => !activeProviderIds.has(providerId)
)
if (invalidProviderIds.length > 0) {
throw new OrchestrationError(
'validation',
`Credential providers are not active in this group: ${invalidProviderIds.join(', ')}`
)
}
await requireCredentialGroupsAvailable(context.workspaceId)
let page
try {
page = await listCredentialGroupCredentialReferences({
workspaceId: context.workspaceId,
credentialGroupId: context.credentialGroupId,
credentialGroupOptionIds: activeOptions.map((option) => option.id),
limit: input.limit,
cursor: input.cursor,
email,
credentialProviderIds: credentialProviderIds.length > 0 ? credentialProviderIds : undefined,
})
} catch (error) {
if (error instanceof CredentialGroupCredentialCursorNotFoundError) {
throw new OrchestrationError('validation', error.message)
}
throw error
}
return {
credentials: page.credentials,
count: page.credentials.length,
hasMore: page.nextCursor !== null,
nextCursor: page.nextCursor,
}
},
})
@@ -0,0 +1,68 @@
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupWorkspaceDelegationPolicy } from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
resolveCredentialGroupWorkspaceContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupCursorNotFoundError,
type CredentialGroupSummary,
listCredentialGroupSummaries,
MAX_CREDENTIAL_GROUP_PAGE_SIZE,
} from '@/lib/credential-groups/groups'
export interface ListCredentialGroupsInput {
workspaceId: string
limit: number
cursor?: string
}
export interface ListCredentialGroupsResult {
credentialGroups: CredentialGroupSummary[]
count: number
hasMore: boolean
nextCursor: string | null
}
export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.listGroups,
resolveContext: ({ input }: { input: ListCredentialGroupsInput }) =>
resolveCredentialGroupWorkspaceContext(input.workspaceId),
authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy },
execute: async ({ input, context }): Promise<ListCredentialGroupsResult> => {
if (
!Number.isInteger(input.limit) ||
input.limit < 1 ||
input.limit > MAX_CREDENTIAL_GROUP_PAGE_SIZE
) {
throw new OrchestrationError(
'validation',
`Limit must be an integer between 1 and ${MAX_CREDENTIAL_GROUP_PAGE_SIZE}`
)
}
await requireCredentialGroupsAvailable(context.workspaceId)
let page
try {
page = await listCredentialGroupSummaries({
workspaceId: context.workspaceId,
limit: input.limit,
cursor: input.cursor,
})
} catch (error) {
if (error instanceof CredentialGroupCursorNotFoundError) {
throw new OrchestrationError('validation', error.message)
}
throw error
}
return {
credentialGroups: page.credentialGroups,
count: page.credentialGroups.length,
hasMore: page.nextCursor !== null,
nextCursor: page.nextCursor,
}
},
})
@@ -0,0 +1,79 @@
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
resolveCredentialGroupContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupEnrollmentError,
type CredentialGroupEnrollmentStatus,
listCredentialGroupEnrollments,
} from '@/lib/credential-groups/enrollments'
export const CREDENTIAL_GROUP_PEOPLE_STATUSES = [
'invited',
'delivery_failed',
'in_progress',
'completed',
'revoked',
] as const satisfies readonly CredentialGroupEnrollmentStatus[]
export interface ListCredentialGroupPeopleInput {
credentialGroupId: string
limit: number
cursor?: string
email?: string
statuses?: CredentialGroupEnrollmentStatus[]
}
export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.listPeople,
resolveContext: ({ input }: { input: ListCredentialGroupPeopleInput }) =>
resolveCredentialGroupContext(input.credentialGroupId),
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
execute: async ({ input, context }) => {
if (context.status !== 'active') {
throw new OrchestrationError('conflict', 'Credential group is disabled')
}
if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) {
throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100')
}
const email = input.email ? normalizeEmail(input.email) : undefined
if (email && !isValidEmailSyntax(email)) {
throw new OrchestrationError('validation', 'Email must be a valid address')
}
const statuses = [...new Set(input.statuses ?? [])]
const allowedStatuses = new Set<string>(CREDENTIAL_GROUP_PEOPLE_STATUSES)
if (statuses.some((status) => !allowedStatuses.has(status))) {
throw new OrchestrationError('validation', 'People status filter is invalid')
}
await requireCredentialGroupsAvailable(context.workspaceId)
try {
const page = await listCredentialGroupEnrollments(
context.workspaceId,
context.credentialGroupId,
input.limit,
input.cursor,
{ email, statuses: statuses.length > 0 ? statuses : undefined }
)
return {
people: page.enrollments,
count: page.enrollments.length,
hasMore: page.nextCursor !== null,
nextCursor: page.nextCursor,
}
} catch (error) {
if (error instanceof CredentialGroupEnrollmentError) {
throw new OrchestrationError(
error.status === 404 ? 'validation' : error.status === 409 ? 'conflict' : 'internal',
error.message
)
}
throw error
}
},
})
@@ -0,0 +1,104 @@
/**
* @vitest-environment node
*/
import type { SessionPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
invite: vi.fn(),
loadInviter: vi.fn(),
requireAvailable: vi.fn(),
resolveGroup: vi.fn(),
resolvePermission: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/context', () => ({
requireCredentialGroupSettingsAvailable: mocks.requireAvailable,
resolveCredentialGroupSettingsContext: mocks.resolveGroup,
}))
vi.mock('@/lib/credential-groups/enrollments', () => ({
CredentialGroupEnrollmentError: class CredentialGroupEnrollmentError extends Error {
constructor(
message: string,
readonly status: 404 | 409 | 502
) {
super(message)
}
},
inviteCredentialGroupEnrollments: mocks.invite,
loadCredentialGroupInviterIdentity: mocks.loadInviter,
resendCredentialGroupEnrollment: vi.fn(),
revokeCredentialGroupEnrollment: vi.fn(),
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: (permission: string | null, required: string) =>
permission === 'admin' || permission === required,
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
import { inviteCredentialGroupEnrollmentsSettings } from '@/lib/credential-groups/application/manage-enrollments'
const context = {
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'billing-owner-1',
credentialGroupId: 'group-1',
name: 'Support',
status: 'active' as const,
options: [],
}
const principal: SessionPrincipal = {
kind: 'session',
userId: 'admin-1',
sessionId: 'session-1',
}
const input = {
assertedWorkspaceId: 'workspace-1',
credentialGroupId: 'group-1',
emails: [' Person@Example.com ', 'person@example.com'],
}
describe('Credential Group enrollment Settings operations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.resolveGroup.mockResolvedValue(context)
mocks.resolvePermission.mockResolvedValue('admin')
mocks.requireAvailable.mockResolvedValue(undefined)
mocks.loadInviter.mockResolvedValue({ name: 'Admin', email: 'admin@example.com' })
mocks.invite.mockResolvedValue({ results: [], sentCount: 0, failedCount: 0 })
})
it('requires current workspace-admin permission before delivery', async () => {
mocks.resolvePermission.mockResolvedValue('write')
await expect(
inviteCredentialGroupEnrollmentsSettings.execute({ principal, input })
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.invite).not.toHaveBeenCalled()
})
it('derives the inviter and normalizes recipients inside the application command', async () => {
await inviteCredentialGroupEnrollmentsSettings.execute({ principal, input })
expect(mocks.loadInviter).toHaveBeenCalledWith('admin-1')
expect(mocks.invite).toHaveBeenCalledWith('workspace-1', 'group-1', 'admin-1', 'Admin', {
emails: ['person@example.com'],
})
})
it('rejects an unbounded batch even outside the HTTP adapter', async () => {
await expect(
inviteCredentialGroupEnrollmentsSettings.execute({
principal,
input: {
...input,
emails: Array.from({ length: 101 }, (_, index) => `person-${index}@example.com`),
},
})
).rejects.toMatchObject({ code: 'validation' })
expect(mocks.invite).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,143 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
requireCredentialGroupSettingsAvailable,
resolveCredentialGroupSettingsContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { validateCredentialGroupInvitationEmails } from '@/lib/credential-groups/application/validation'
import {
CredentialGroupEnrollmentError,
inviteCredentialGroupEnrollments,
loadCredentialGroupInviterIdentity,
resendCredentialGroupEnrollment,
revokeCredentialGroupEnrollment,
} from '@/lib/credential-groups/enrollments'
interface CredentialGroupEnrollmentSettingsInput {
assertedWorkspaceId: string
credentialGroupId: string
}
function normalizeEnrollmentError(error: unknown): never {
if (error instanceof CredentialGroupEnrollmentError) {
if (error.status === 404) throw new OrchestrationError('not_found', error.message)
if (error.status === 409) throw new OrchestrationError('conflict', error.message)
}
throw error
}
async function requireInviterIdentity(userId: string): Promise<string> {
const inviter = await loadCredentialGroupInviterIdentity(userId)
const inviterName = inviter?.name?.trim() || inviter?.email
if (!inviterName) {
throw new OrchestrationError('conflict', 'Inviting user has no display identity')
}
return inviterName
}
export interface InviteCredentialGroupEnrollmentsSettingsInput
extends CredentialGroupEnrollmentSettingsInput {
emails: string[]
}
export const inviteCredentialGroupEnrollmentsSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.inviteBatch,
resolveContext: ({ input }: { input: InviteCredentialGroupEnrollmentsSettingsInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ principal, input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
const inviterName = await requireInviterIdentity(principal.userId)
const emails = validateCredentialGroupInvitationEmails(input.emails)
try {
return await inviteCredentialGroupEnrollments(
context.workspaceId,
context.credentialGroupId,
principal.userId,
inviterName,
{ emails }
)
} catch (error) {
normalizeEnrollmentError(error)
}
},
projectAudit: ({ context, result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: context.credentialGroupId,
resourceName: context.name,
description: `Sent ${result.sentCount} Credential Group invitation${result.sentCount === 1 ? '' : 's'}`,
metadata: { sentCount: result.sentCount, failedCount: result.failedCount },
}),
})
export interface ResendCredentialGroupEnrollmentSettingsInput
extends CredentialGroupEnrollmentSettingsInput {
enrollmentId: string
}
export const resendCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.resendEnrollment,
resolveContext: ({ input }: { input: ResendCredentialGroupEnrollmentSettingsInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ principal, input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
const inviterName = await requireInviterIdentity(principal.userId)
try {
const credentialGroupEnrollment = await resendCredentialGroupEnrollment(
context.workspaceId,
context.credentialGroupId,
input.enrollmentId,
principal.userId,
inviterName
)
return { credentialGroupEnrollment }
} catch (error) {
normalizeEnrollmentError(error)
}
},
projectAudit: ({ context, result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: context.credentialGroupId,
resourceName: context.name,
description: `Resent a Credential Group invitation to ${result.credentialGroupEnrollment.email}`,
metadata: { enrollmentId: result.credentialGroupEnrollment.id },
}),
})
export interface RevokeCredentialGroupEnrollmentSettingsInput
extends CredentialGroupEnrollmentSettingsInput {
enrollmentId: string
}
export const revokeCredentialGroupEnrollmentSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.revokeEnrollment,
resolveContext: ({ input }: { input: RevokeCredentialGroupEnrollmentSettingsInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
try {
const credentialGroupEnrollment = await revokeCredentialGroupEnrollment(
context.workspaceId,
context.credentialGroupId,
input.enrollmentId
)
return { credentialGroupEnrollment }
} catch (error) {
normalizeEnrollmentError(error)
}
},
projectAudit: ({ context, result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: context.credentialGroupId,
resourceName: context.name,
description: `Revoked Credential Group access for ${result.credentialGroupEnrollment.email}`,
metadata: { enrollmentId: result.credentialGroupEnrollment.id },
}),
})
@@ -0,0 +1,118 @@
/**
* @vitest-environment node
*/
import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
create: vi.fn(),
list: vi.fn(),
requireAvailable: vi.fn(),
resolveGroup: vi.fn(),
resolvePermission: vi.fn(),
resolveWorkspace: vi.fn(),
}))
vi.mock('@/lib/credential-groups/application/context', () => ({
requireCredentialGroupSettingsAvailable: mocks.requireAvailable,
resolveCredentialGroupSettingsContext: mocks.resolveGroup,
resolveCredentialGroupWorkspaceContext: mocks.resolveWorkspace,
}))
vi.mock('@/lib/credential-groups/service', () => ({
createCredentialGroup: mocks.create,
deleteCredentialGroup: vi.fn(),
getCredentialGroup: vi.fn(),
listCredentialGroups: mocks.list,
updateCredentialGroup: vi.fn(),
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: (permission: string | null, required: string) =>
permission === 'admin' || permission === required,
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
import {
createCredentialGroupSettings,
listCredentialGroupSettings,
} from '@/lib/credential-groups/application/manage-groups'
const workspaceContext = {
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
allowPersonalApiKeys: true,
billedAccountUserId: 'billing-owner-1',
}
const sessionPrincipal: SessionPrincipal = {
kind: 'session',
userId: 'admin-1',
sessionId: 'session-1',
}
const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
invitationTokenHash: 'hash-1',
}
describe('Credential Group Settings application operations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.resolveWorkspace.mockResolvedValue(workspaceContext)
mocks.resolvePermission.mockResolvedValue('admin')
mocks.requireAvailable.mockResolvedValue(undefined)
mocks.list.mockResolvedValue([])
mocks.create.mockResolvedValue({ id: 'group-1', name: 'Support' })
})
it('rejects an enrollment bearer before loading workspace settings', async () => {
await expect(
listCredentialGroupSettings.execute({
principal: enrollmentPrincipal,
input: { workspaceId: 'workspace-1' },
})
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.resolveWorkspace).not.toHaveBeenCalled()
})
it('requires current workspace-admin permission before listing', async () => {
mocks.resolvePermission.mockResolvedValue('read')
await expect(
listCredentialGroupSettings.execute({
principal: sessionPrincipal,
input: { workspaceId: 'workspace-1' },
})
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.list).not.toHaveBeenCalled()
})
it('lists settings only after authorization and entitlement checks', async () => {
const result = await listCredentialGroupSettings.execute({
principal: sessionPrincipal,
input: { workspaceId: 'workspace-1' },
})
expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1')
expect(mocks.list).toHaveBeenCalledWith('workspace-1')
expect(result).toEqual({ credentialGroups: [] })
})
it('derives created-by identity from the authenticated session principal', async () => {
await createCredentialGroupSettings.execute({
principal: sessionPrincipal,
input: {
workspaceId: 'workspace-1',
credentialGroup: { name: 'Support', options: [] },
},
})
expect(mocks.create).toHaveBeenCalledWith('workspace-1', 'admin-1', {
name: 'Support',
options: [],
})
})
})
@@ -0,0 +1,175 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
requireCredentialGroupSettingsAvailable,
resolveCredentialGroupSettingsContext,
resolveCredentialGroupWorkspaceContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
validateCreateCredentialGroupInput,
validateCredentialGroupEnrollmentPage,
validateUpdateCredentialGroupInput,
} from '@/lib/credential-groups/application/validation'
import {
CredentialGroupEnrollmentError,
listCredentialGroupEnrollments,
} from '@/lib/credential-groups/enrollments'
import {
createCredentialGroup,
deleteCredentialGroup,
getCredentialGroup,
listCredentialGroups,
updateCredentialGroup,
} from '@/lib/credential-groups/service'
import type {
CreateCredentialGroupInput,
UpdateCredentialGroupInput,
} from '@/lib/credential-groups/types'
function throwCredentialGroupConflict(error: unknown): never {
if (getPostgresErrorCode(error) === '23505') {
throw new OrchestrationError('conflict', 'A credential group with this name already exists')
}
throw error
}
export interface ListCredentialGroupSettingsInput {
workspaceId: string
}
export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.listSettings,
resolveContext: ({ input }: { input: ListCredentialGroupSettingsInput }) =>
resolveCredentialGroupWorkspaceContext(input.workspaceId),
authorizationOptions: {},
async execute({ context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
return { credentialGroups: await listCredentialGroups(context.workspaceId) }
},
})
export interface CreateCredentialGroupSettingsInput {
workspaceId: string
credentialGroup: CreateCredentialGroupInput
}
export const createCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.create,
resolveContext: ({ input }: { input: CreateCredentialGroupSettingsInput }) =>
resolveCredentialGroupWorkspaceContext(input.workspaceId),
authorizationOptions: {},
async execute({ principal, input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
try {
const credentialGroup = await createCredentialGroup(
context.workspaceId,
principal.userId,
validateCreateCredentialGroupInput(input.credentialGroup)
)
return { credentialGroup }
} catch (error) {
throwCredentialGroupConflict(error)
}
},
projectAudit: ({ result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: result.credentialGroup.id,
resourceName: result.credentialGroup.name,
description: 'Created a Credential Group',
}),
})
interface CredentialGroupSettingsTargetInput {
assertedWorkspaceId: string
credentialGroupId: string
}
export interface GetCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput {
limit: number
cursor?: string
}
export const getCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.readSettings,
resolveContext: ({ input }: { input: GetCredentialGroupSettingsInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
validateCredentialGroupEnrollmentPage(input.limit)
const credentialGroup = await getCredentialGroup(context.workspaceId, context.credentialGroupId)
if (!credentialGroup) throw new OrchestrationError('not_found', 'Credential group not found')
try {
const enrollmentPage = await listCredentialGroupEnrollments(
context.workspaceId,
context.credentialGroupId,
input.limit,
input.cursor
)
return { credentialGroup, ...enrollmentPage }
} catch (error) {
if (error instanceof CredentialGroupEnrollmentError && error.status === 404) {
throw new OrchestrationError('not_found', error.message)
}
throw error
}
},
})
export interface UpdateCredentialGroupSettingsInput extends CredentialGroupSettingsTargetInput {
update: UpdateCredentialGroupInput
}
export const updateCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.update,
resolveContext: ({ input }: { input: UpdateCredentialGroupSettingsInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ input, context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
try {
const credentialGroup = await updateCredentialGroup(
context.workspaceId,
context.credentialGroupId,
validateUpdateCredentialGroupInput(input.update)
)
if (!credentialGroup) {
throw new OrchestrationError('not_found', 'Credential group not found')
}
return { credentialGroup }
} catch (error) {
throwCredentialGroupConflict(error)
}
},
projectAudit: ({ result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: result.credentialGroup.id,
resourceName: result.credentialGroup.name,
description: 'Updated a Credential Group',
}),
})
export const deleteCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.delete,
resolveContext: ({ input }: { input: CredentialGroupSettingsTargetInput }) =>
resolveCredentialGroupSettingsContext(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ context }) {
await requireCredentialGroupSettingsAvailable(context.workspaceId)
const deleted = await deleteCredentialGroup(context.workspaceId, context.credentialGroupId)
if (!deleted) throw new OrchestrationError('not_found', 'Credential group not found')
return { success: true as const }
},
projectAudit: ({ context }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: context.credentialGroupId,
resourceName: context.name,
description: 'Deleted a Credential Group',
}),
})
@@ -0,0 +1,92 @@
import { defineWorkspaceOperation } from '@/lib/core/application'
export const credentialGroupOperations = {
listSettings: defineWorkspaceOperation({
id: 'credential_groups.settings.list',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
create: defineWorkspaceOperation({
id: 'credential_groups.create',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
readSettings: defineWorkspaceOperation({
id: 'credential_groups.settings.read',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
update: defineWorkspaceOperation({
id: 'credential_groups.update',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
delete: defineWorkspaceOperation({
id: 'credential_groups.delete',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
inviteBatch: defineWorkspaceOperation({
id: 'credential_groups.invites.send_batch',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
resendEnrollment: defineWorkspaceOperation({
id: 'credential_groups.enrollments.resend',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
revokeEnrollment: defineWorkspaceOperation({
id: 'credential_groups.enrollments.revoke',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
listCredentials: defineWorkspaceOperation({
id: 'credential_groups.credentials.list',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['executor'],
}),
listGroups: defineWorkspaceOperation({
id: 'credential_groups.list',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['executor'],
}),
listPeople: defineWorkspaceOperation({
id: 'credential_groups.people.list',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['executor'],
}),
sendInvite: defineWorkspaceOperation({
id: 'credential_groups.invites.send',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['executor'],
}),
startSlackConfiguration: defineWorkspaceOperation({
id: 'credential_groups.slack_configuration.start',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
completeSlackConfiguration: defineWorkspaceOperation({
id: 'credential_groups.slack_configuration.complete',
minimumRole: 'admin',
workspaceApiKey: 'deny',
principalKinds: ['session'],
}),
} as const
@@ -0,0 +1,107 @@
/**
* @vitest-environment node
*/
import type { CredentialGroupEnrollmentPrincipal, SessionPrincipal } from '@sim/auth/principal'
import { sha256Hex } from '@sim/security/hash'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
completeEnrollment: vi.fn(),
getEnrollment: vi.fn(),
getOAuthContext: vi.fn(),
startOAuth: vi.fn(),
}))
vi.mock('@/lib/credential-groups/enrollments', () => ({
completeAuthorizedCredentialGroupEnrollment: mocks.completeEnrollment,
getAuthorizedCredentialGroupOAuthContext: mocks.getOAuthContext,
getAuthorizedPublicCredentialGroupEnrollment: mocks.getEnrollment,
}))
vi.mock('@/lib/credential-groups/oauth', () => ({
completeCredentialGroupOAuth: vi.fn(),
startCredentialGroupOAuth: mocks.startOAuth,
}))
import {
readPublicCredentialGroupEnrollment,
startPublicCredentialGroupOAuth,
} from '@/lib/credential-groups/application/public-enrollment'
const invitationToken = 'invitation-token'
const principal: CredentialGroupEnrollmentPrincipal = {
kind: 'credential_group_enrollment',
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
enrollmentId: 'enrollment-1',
email: 'person@example.com',
invitationTokenHash: sha256Hex(invitationToken),
}
const identity = {
workspaceId: principal.workspaceId,
credentialGroupId: principal.credentialGroupId,
enrollmentId: principal.enrollmentId,
email: principal.email,
invitationTokenHash: principal.invitationTokenHash,
}
describe('public Credential Group enrollment application operations', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getEnrollment.mockResolvedValue({ status: 'invited', options: [] })
mocks.getOAuthContext.mockResolvedValue({
enrollmentId: 'enrollment-1',
credentialGroupId: 'group-1',
option: { id: 'option-1' },
})
mocks.startOAuth.mockResolvedValue('https://accounts.example/authorize')
})
it('rejects a workspace session before resolving invitation data', async () => {
const session: SessionPrincipal = {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
}
await expect(
readPublicCredentialGroupEnrollment.execute({ principal: session, input: {} })
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.getEnrollment).not.toHaveBeenCalled()
})
it('revalidates the invitation identity before returning enrollment metadata', async () => {
const result = await readPublicCredentialGroupEnrollment.execute({ principal, input: {} })
expect(mocks.getEnrollment).toHaveBeenCalledWith(identity)
expect(result).toEqual({ enrollment: { status: 'invited', options: [] } })
})
it('fails closed when the current invitation no longer resolves', async () => {
mocks.getEnrollment.mockResolvedValue(null)
await expect(
readPublicCredentialGroupEnrollment.execute({ principal, input: {} })
).rejects.toMatchObject({ code: 'not_found' })
})
it('rejects a substituted bearer before creating provider state', async () => {
await expect(
startPublicCredentialGroupOAuth.execute({
principal,
input: { invitationToken: 'different-token', optionId: 'option-1' },
})
).rejects.toMatchObject({ code: 'not_found' })
expect(mocks.startOAuth).not.toHaveBeenCalled()
})
it('starts OAuth only for the option bound to the current enrollment principal', async () => {
const result = await startPublicCredentialGroupOAuth.execute({
principal,
input: { invitationToken, optionId: 'option-1' },
})
expect(mocks.getOAuthContext).toHaveBeenCalledWith(identity, 'option-1')
expect(result).toEqual({ authorizationUrl: 'https://accounts.example/authorize' })
})
})
@@ -0,0 +1,188 @@
import type { CredentialGroupEnrollmentPrincipal, Principal } from '@sim/auth/principal'
import { safeCompare } from '@sim/security/compare'
import { sha256Hex } from '@sim/security/hash'
import type { OperationUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupEnrollmentOperations } from '@/lib/credential-groups/application/enrollment-operations'
import {
completeAuthorizedCredentialGroupEnrollment,
getAuthorizedCredentialGroupOAuthContext,
getAuthorizedPublicCredentialGroupEnrollment,
type PublicCredentialGroupEnrollmentIdentity,
} from '@/lib/credential-groups/enrollments'
import {
completeCredentialGroupOAuth,
startCredentialGroupOAuth,
} from '@/lib/credential-groups/oauth'
import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
interface AuthorizedCredentialGroupEnrollmentUseCaseDefinition<O, I, C, R> {
operation: O
resolveContext(args: { principal: CredentialGroupEnrollmentPrincipal; input: I }): Promise<C>
execute(args: { principal: CredentialGroupEnrollmentPrincipal; input: I; context: C }): Promise<R>
}
function requireCredentialGroupEnrollmentPrincipal(
principal: Principal
): asserts principal is CredentialGroupEnrollmentPrincipal {
if (principal.kind !== 'credential_group_enrollment') {
throw new OrchestrationError(
'forbidden',
'This operation requires a Credential Group invitation'
)
}
}
function requireMatchingContext(
principal: CredentialGroupEnrollmentPrincipal,
context: PublicCredentialGroupEnrollmentIdentity
): void {
if (
context.workspaceId !== principal.workspaceId ||
context.credentialGroupId !== principal.credentialGroupId ||
context.enrollmentId !== principal.enrollmentId ||
context.email !== principal.email ||
!safeCompare(context.invitationTokenHash, principal.invitationTokenHash)
) {
throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
}
}
function defineAuthorizedCredentialGroupEnrollmentUseCase<
const O extends
(typeof credentialGroupEnrollmentOperations)[keyof typeof credentialGroupEnrollmentOperations],
I,
C extends PublicCredentialGroupEnrollmentIdentity,
R,
>(
definition: AuthorizedCredentialGroupEnrollmentUseCaseDefinition<O, I, C, R>
): OperationUseCase<O, I, R> {
async function authorize(principal: Principal, input: I) {
requireCredentialGroupEnrollmentPrincipal(principal)
const context = await definition.resolveContext({ principal, input })
requireMatchingContext(principal, context)
return { principal, input, context }
}
return {
operation: definition.operation,
async authorize({ principal, input }) {
await authorize(principal, input)
},
async execute({ principal, input }) {
const authorized = await authorize(principal, input)
return definition.execute(authorized)
},
}
}
function identityFromPrincipal(
principal: CredentialGroupEnrollmentPrincipal
): PublicCredentialGroupEnrollmentIdentity {
return {
workspaceId: principal.workspaceId,
credentialGroupId: principal.credentialGroupId,
enrollmentId: principal.enrollmentId,
email: principal.email,
invitationTokenHash: principal.invitationTokenHash,
}
}
function requireInvitationToken(
principal: CredentialGroupEnrollmentPrincipal,
invitationToken: string
): void {
if (!safeCompare(sha256Hex(invitationToken), principal.invitationTokenHash)) {
throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
}
}
interface PublicEnrollmentContext extends PublicCredentialGroupEnrollmentIdentity {
enrollment: NonNullable<Awaited<ReturnType<typeof getAuthorizedPublicCredentialGroupEnrollment>>>
}
async function resolvePublicEnrollmentContext(
principal: CredentialGroupEnrollmentPrincipal
): Promise<PublicEnrollmentContext> {
const identity = identityFromPrincipal(principal)
const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(identity)
if (!enrollment) throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
return { ...identity, enrollment }
}
export const readPublicCredentialGroupEnrollment = defineAuthorizedCredentialGroupEnrollmentUseCase(
{
operation: credentialGroupEnrollmentOperations.read,
resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal),
async execute({ context }) {
return { enrollment: context.enrollment }
},
}
)
export const completePublicCredentialGroupEnrollment =
defineAuthorizedCredentialGroupEnrollmentUseCase({
operation: credentialGroupEnrollmentOperations.complete,
resolveContext: ({ principal }) => resolvePublicEnrollmentContext(principal),
async execute({ context }) {
const completed = await completeAuthorizedCredentialGroupEnrollment(context)
return { completed }
},
})
interface PublicCredentialGroupOAuthInput {
invitationToken: string
optionId: string
}
interface PublicCredentialGroupOAuthContext extends PublicCredentialGroupEnrollmentIdentity {
oauth: NonNullable<Awaited<ReturnType<typeof getAuthorizedCredentialGroupOAuthContext>>>
}
async function resolvePublicOAuthContext(
principal: CredentialGroupEnrollmentPrincipal,
optionId: string
): Promise<PublicCredentialGroupOAuthContext> {
const identity = identityFromPrincipal(principal)
const oauth = await getAuthorizedCredentialGroupOAuthContext(identity, optionId)
if (!oauth) throw new OrchestrationError('not_found', 'Invitation is invalid or expired')
return { ...identity, oauth }
}
export const startPublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({
operation: credentialGroupEnrollmentOperations.startOAuth,
resolveContext: ({
principal,
input,
}: {
principal: CredentialGroupEnrollmentPrincipal
input: PublicCredentialGroupOAuthInput
}) => resolvePublicOAuthContext(principal, input.optionId),
async execute({ principal, input, context }) {
requireInvitationToken(principal, input.invitationToken)
return {
authorizationUrl: await startCredentialGroupOAuth(context.oauth, input.invitationToken),
}
},
})
interface CompletePublicCredentialGroupOAuthInput {
attempt: CredentialGroupOAuthAttempt
code: string
}
export const completePublicCredentialGroupOAuth = defineAuthorizedCredentialGroupEnrollmentUseCase({
operation: credentialGroupEnrollmentOperations.completeOAuth,
resolveContext: ({
principal,
input,
}: {
principal: CredentialGroupEnrollmentPrincipal
input: CompletePublicCredentialGroupOAuthInput
}) => resolvePublicOAuthContext(principal, input.attempt.optionId),
async execute({ principal, input, context }) {
requireInvitationToken(principal, input.attempt.invitationToken)
await completeCredentialGroupOAuth(context.oauth, input.attempt, input.code)
return { connectedOptionId: context.oauth.option.id }
},
})
@@ -0,0 +1,72 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupDelegationPolicy } from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
resolveCredentialGroupContext,
} from '@/lib/credential-groups/application/context'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import {
CredentialGroupEnrollmentError,
inviteCredentialGroupEnrollment,
loadCredentialGroupInviterIdentity,
} from '@/lib/credential-groups/enrollments'
export interface SendCredentialGroupInviteInput {
credentialGroupId: string
email: string
}
export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.sendInvite,
resolveContext: ({ input }: { input: SendCredentialGroupInviteInput }) =>
resolveCredentialGroupContext(input.credentialGroupId),
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
execute: async ({ principal, input, context }) => {
if (context.status !== 'active') {
throw new OrchestrationError('conflict', 'Credential group is disabled')
}
const email = normalizeEmail(input.email)
if (!isValidEmailSyntax(email)) {
throw new OrchestrationError('validation', 'Email must be a valid address')
}
await requireCredentialGroupsAvailable(context.workspaceId)
const userId = requirePrincipalSubjectUserId(principal)
const inviter = await loadCredentialGroupInviterIdentity(userId)
const inviterName = inviter?.name?.trim() || inviter?.email
if (!inviterName) {
throw new OrchestrationError('conflict', 'Inviting user has no display identity')
}
try {
const enrollment = await inviteCredentialGroupEnrollment(
context.workspaceId,
context.credentialGroupId,
userId,
inviterName,
email
)
return { enrollment }
} catch (error) {
if (error instanceof CredentialGroupEnrollmentError) {
throw new OrchestrationError(
error.status === 404 ? 'not_found' : error.status === 409 ? 'conflict' : 'internal',
error.message
)
}
throw error
}
},
projectAudit: ({ input, context, result }) => ({
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: context.credentialGroupId,
resourceName: context.name,
description: `Invited ${result.enrollment.email} to connect accounts`,
metadata: { email: normalizeEmail(input.email), enrollmentId: result.enrollment.id },
}),
})
@@ -0,0 +1,138 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { db } from '@sim/db'
import { credentialGroup } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { getWorkspaceOwnerSubscriptionAccess } from '@/lib/billing/core/workspace-access'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { isCredentialGroupsAvailable } from '@/lib/credential-groups/availability'
import {
consumeSlackManagedUsersAttempt,
createSlackManagedUsersAttempt,
exchangeAndConfigureSlackManagedUsers,
loadSlackManagedUsersAttempt,
type SlackManagedUsersAttempt,
} from '@/lib/credential-groups/slack-managed-users'
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
async function requireCredentialGroups(workspaceId: string): Promise<void> {
const ownerBilling = await getWorkspaceOwnerSubscriptionAccess(workspaceId)
if (!(await isCredentialGroupsAvailable(ownerBilling))) {
throw new OrchestrationError('not_found', 'Credential Groups are not available')
}
}
async function resolveWorkspace(workspaceId: string) {
const context = await loadActiveWorkspaceApplicationContext(workspaceId)
if (!context) throw new OrchestrationError('not_found', 'Workspace not found')
return context
}
async function resolveCredentialGroup(groupId: string, assertedWorkspaceId: string) {
const [group] = await db
.select({ id: credentialGroup.id, workspaceId: credentialGroup.workspaceId })
.from(credentialGroup)
.where(eq(credentialGroup.id, groupId))
.limit(1)
if (!group || group.workspaceId !== assertedWorkspaceId) {
throw new OrchestrationError('not_found', 'Credential Group not found')
}
return { ...(await resolveWorkspace(group.workspaceId)), credentialGroupId: group.id }
}
export interface StartSlackCredentialGroupConfigurationInput {
assertedWorkspaceId: string
credentialGroupId: string
slackBotCredentialId: string
clientId: string
clientSecret: string
}
export const startSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.startSlackConfiguration,
resolveContext: ({ input }: { input: StartSlackCredentialGroupConfigurationInput }) =>
resolveCredentialGroup(input.credentialGroupId, input.assertedWorkspaceId),
authorizationOptions: {},
async execute({ principal, input, context }) {
await requireCredentialGroups(context.workspaceId)
return createSlackManagedUsersAttempt({
workspaceId: context.workspaceId,
userId: principal.userId,
credentialGroupId: context.credentialGroupId,
slackBotCredentialId: input.slackBotCredentialId,
clientId: input.clientId,
clientSecret: input.clientSecret,
})
},
})
interface SlackCredentialGroupConfigurationCallbackInput {
state: string
code?: string
providerError?: string
}
type SlackCredentialGroupConfigurationCallbackContext = Awaited<
ReturnType<typeof resolveWorkspace>
> & {
attempt: SlackManagedUsersAttempt
}
export const completeSlackCredentialGroupConfiguration = defineAuthorizedWorkspaceUseCase({
operation: credentialGroupOperations.completeSlackConfiguration,
resolveContext: async ({
principal,
input,
}: {
principal: { kind: 'session'; userId: string; sessionId: string }
input: SlackCredentialGroupConfigurationCallbackInput
}): Promise<SlackCredentialGroupConfigurationCallbackContext> => {
const attempt = await loadSlackManagedUsersAttempt(input.state)
if (!attempt) {
throw new OrchestrationError('validation', 'Authorization state is invalid or expired')
}
if (attempt.userId !== principal.userId) {
throw new OrchestrationError(
'forbidden',
'Authorization must be completed by the user who started it'
)
}
return { ...(await resolveWorkspace(attempt.workspaceId)), attempt }
},
authorizationOptions: {},
async execute({ input, context }) {
await requireCredentialGroups(context.workspaceId)
const attempt = await consumeSlackManagedUsersAttempt(input.state)
if (
!attempt ||
attempt.workspaceId !== context.attempt.workspaceId ||
attempt.userId !== context.attempt.userId ||
attempt.credentialGroupId !== context.attempt.credentialGroupId ||
attempt.slackBotCredentialId !== context.attempt.slackBotCredentialId ||
attempt.clientId !== context.attempt.clientId ||
attempt.createdAt !== context.attempt.createdAt
) {
throw new OrchestrationError('validation', 'Authorization state is invalid or expired')
}
if (input.providerError) return { ok: false as const, reason: 'provider_error' as const }
if (!input.code) throw new OrchestrationError('validation', 'Authorization code is missing')
const result = await exchangeAndConfigureSlackManagedUsers({ attempt, code: input.code })
return { ok: true as const, reason: 'authorized' as const, result }
},
projectAudit: ({ result }) =>
result.ok
? {
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_GROUP,
resourceId: result.result.credentialGroupId,
resourceName: result.result.credentialGroupName,
description: 'Configured Slack for a Credential Group',
metadata: {
slackBotCredentialId: result.result.slackBotCredentialId,
slackAppId: result.result.appId,
slackTeamId: result.result.teamId,
},
}
: [],
})
@@ -0,0 +1,130 @@
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
CREDENTIAL_GROUP_PROVIDER_IDS,
isCredentialGroupProvider,
} from '@/lib/credential-groups/providers'
import type {
CreateCredentialGroupInput,
CredentialGroupOptionInput,
CredentialGroupOptionUpdateInput,
UpdateCredentialGroupInput,
} from '@/lib/credential-groups/types'
function validateOption(
option: CredentialGroupOptionInput | CredentialGroupOptionUpdateInput,
index: number
): void {
if (!isCredentialGroupProvider(option.provider)) {
throw new OrchestrationError('validation', `Credential option ${index + 1} is unsupported`)
}
if (!option.label.trim() || option.label.trim().length > 100) {
throw new OrchestrationError(
'validation',
`Credential option ${index + 1} requires a label of at most 100 characters`
)
}
if (option.provider === 'slack' && !option.slackBotCredentialId.trim()) {
throw new OrchestrationError('validation', 'Select a custom Slack bot')
}
}
function validateOptions(
options: Array<CredentialGroupOptionInput | CredentialGroupOptionUpdateInput>
): void {
if (options.length > CREDENTIAL_GROUP_PROVIDER_IDS.length) {
throw new OrchestrationError('validation', 'Too many credential options')
}
const labels = new Set<string>()
const providers = new Set<string>()
const ids = new Set<string>()
options.forEach((option, index) => {
validateOption(option, index)
const label = option.label.trim().toLocaleLowerCase()
if (labels.has(label)) {
throw new OrchestrationError('validation', 'Credential option labels must be unique')
}
if (providers.has(option.provider)) {
throw new OrchestrationError('validation', 'Each provider can only be added once')
}
if ('id' in option && option.id) {
if (ids.has(option.id)) {
throw new OrchestrationError('validation', 'Credential option IDs must be unique')
}
ids.add(option.id)
}
labels.add(label)
providers.add(option.provider)
})
}
function normalizeOption<T extends CredentialGroupOptionInput | CredentialGroupOptionUpdateInput>(
option: T
): T {
return { ...option, label: option.label.trim() }
}
export function validateCreateCredentialGroupInput(
input: CreateCredentialGroupInput
): CreateCredentialGroupInput {
const name = input.name.trim()
if (!name || name.length > 100) {
throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters')
}
const description = input.description?.trim()
if (description && description.length > 500) {
throw new OrchestrationError('validation', 'Description must be at most 500 characters')
}
validateOptions(input.options)
if (input.options.some((option) => option.provider === 'slack')) {
throw new OrchestrationError(
'validation',
'Create the Credential Group before configuring Slack'
)
}
return {
name,
...(description ? { description } : {}),
options: input.options.map(normalizeOption),
}
}
export function validateUpdateCredentialGroupInput(
input: UpdateCredentialGroupInput
): UpdateCredentialGroupInput {
if (Object.keys(input).length === 0) {
throw new OrchestrationError('validation', 'At least one field must be updated')
}
const name = input.name?.trim()
if (input.name !== undefined && (!name || name.length > 100)) {
throw new OrchestrationError('validation', 'Name must be between 1 and 100 characters')
}
const description = input.description?.trim()
if (description && description.length > 500) {
throw new OrchestrationError('validation', 'Description must be at most 500 characters')
}
if (input.options) validateOptions(input.options)
return {
...(name ? { name } : {}),
...(input.description !== undefined ? { description: description || null } : {}),
...(input.options ? { options: input.options.map(normalizeOption) } : {}),
...(input.status ? { status: input.status } : {}),
}
}
export function validateCredentialGroupEnrollmentPage(limit: number): void {
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new OrchestrationError('validation', 'Limit must be an integer between 1 and 100')
}
}
export function validateCredentialGroupInvitationEmails(emails: string[]): string[] {
if (emails.length < 1 || emails.length > 100) {
throw new OrchestrationError('validation', 'Invite between 1 and 100 people at once')
}
const normalized = [...new Set(emails.map(normalizeEmail))]
if (normalized.some((email) => !isValidEmailSyntax(email))) {
throw new OrchestrationError('validation', 'Every invitation email must be valid')
}
return normalized
}
@@ -0,0 +1,50 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIsFeatureEnabled } = vi.hoisted(() => ({
mockIsFeatureEnabled: vi.fn(),
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isHosted: true,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
isFeatureEnabled: mockIsFeatureEnabled,
}))
import { resolveCredentialGroupsAvailability } from '@/lib/credential-groups/availability'
describe('resolveCredentialGroupsAvailability', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('attributes a disabled feature flag before considering the plan', async () => {
mockIsFeatureEnabled.mockResolvedValue(false)
await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({
available: false,
reason: 'feature_disabled',
})
})
it('requires Enterprise when the hosted feature is enabled', async () => {
mockIsFeatureEnabled.mockResolvedValue(true)
await expect(resolveCredentialGroupsAvailability({ isEnterprise: false })).resolves.toEqual({
available: false,
reason: 'enterprise_plan_required',
})
})
it('allows Enterprise workspaces when the hosted feature is enabled', async () => {
mockIsFeatureEnabled.mockResolvedValue(true)
await expect(resolveCredentialGroupsAvailability({ isEnterprise: true })).resolves.toEqual({
available: true,
})
})
})
@@ -0,0 +1,25 @@
import { isHosted } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
export type CredentialGroupsAvailability =
| { available: true }
| { available: false; reason: 'feature_disabled' | 'enterprise_plan_required' }
export async function resolveCredentialGroupsAvailability(ownerBilling: {
isEnterprise: boolean
}): Promise<CredentialGroupsAvailability> {
if (!(await isFeatureEnabled('credential-groups'))) {
return { available: false, reason: 'feature_disabled' }
}
if (isHosted && !ownerBilling.isEnterprise) {
return { available: false, reason: 'enterprise_plan_required' }
}
return { available: true }
}
/** Credential Groups are globally gated and restricted to Enterprise workspaces on Sim Cloud. */
export async function isCredentialGroupsAvailable(ownerBilling: {
isEnterprise: boolean
}): Promise<boolean> {
return (await resolveCredentialGroupsAvailability(ownerBilling)).available
}
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listCredentialGroupCredentialReferences } from '@/lib/credential-groups/credentials'
describe('listCredentialGroupCredentialReferences', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns the invited email associated with each managed credential', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
id: 'credential-1',
email: 'person@example.com',
displayName: 'Personal Gmail',
providerId: 'google-email',
providerSubjectId: 'google-subject-1',
providerTenantId: null,
createdAt: new Date('2026-08-12T12:00:00.000Z'),
},
])
const result = await listCredentialGroupCredentialReferences({
workspaceId: 'workspace-1',
credentialGroupId: 'group-1',
credentialGroupOptionIds: ['option-1'],
limit: 50,
})
expect(result).toEqual({
credentials: [
{
credentialId: 'credential-1',
email: 'person@example.com',
displayName: 'Personal Gmail',
providerId: 'google-email',
providerSubjectId: 'google-subject-1',
providerTenantId: null,
},
],
nextCursor: null,
})
})
})

Some files were not shown because too many files have changed in this diff Show More