Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { QueryRunner } from 'typeorm'
import { system } from '../../../helper/system/system'
import { AppSystemProp } from '../../../helper/system/system-props'
import { DatabaseType } from '../../database-type'
import { Migration } from '../../migration'

const isPGlite = system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE

export class AddSsoDomainVerification1787100000000 implements Migration {
name = 'AddSsoDomainVerification1787100000000'
breaking = false
release = '0.83.0'
transaction = false

public async up(queryRunner: QueryRunner): Promise<void> {
const concurrently = isPGlite ? '' : 'CONCURRENTLY'

await queryRunner.query(`
ALTER TABLE "platform"
ADD COLUMN IF NOT EXISTS "ssoDomain" character varying
`)

await queryRunner.query(`
ALTER TABLE "platform"
ADD COLUMN IF NOT EXISTS "ssoDomainVerification" jsonb
`)

await queryRunner.query(`
CREATE UNIQUE INDEX ${concurrently} IF NOT EXISTS "idx_platform_sso_domain"
ON "platform" ("ssoDomain")
WHERE "ssoDomain" IS NOT NULL
`)
}

public async down(queryRunner: QueryRunner): Promise<void> {
const concurrently = isPGlite ? '' : 'CONCURRENTLY'

await queryRunner.query(`DROP INDEX ${concurrently} IF EXISTS "idx_platform_sso_domain"`)
await queryRunner.query('ALTER TABLE "platform" DROP COLUMN IF EXISTS "ssoDomainVerification"')
await queryRunner.query('ALTER TABLE "platform" DROP COLUMN IF EXISTS "ssoDomain"')
}
}
2 changes: 2 additions & 0 deletions packages/server/api/src/app/database/postgres-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ import { DropChatTokenColumns1782000000000 } from './migration/postgres/17820000
import { AddUserSandboxTable1784000000000 } from './migration/postgres/1784000000000-AddUserSandboxTable'
import { ReplacesSandboxWithVercelAiSdk1785000000000 } from './migration/postgres/1785000000000-ReplacesSandboxWithVercelAiSdk'
import { AddChatCompactionColumns1786000000000 } from './migration/postgres/1786000000000-AddChatCompactionColumns'
import { AddSsoDomainVerification1787100000000 } from './migration/postgres/1787100000000-AddSsoDomainVerification'

const getSslConfig = (): boolean | TlsOptions => {
const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL)
Expand Down Expand Up @@ -749,6 +750,7 @@ export const getMigrations = (): (new () => Migration)[] => {
AddLastLoggedInPlatformIdToUserIdentity1777491000474,
ReplacesSandboxWithVercelAiSdk1785000000000,
AddChatCompactionColumns1786000000000,
AddSsoDomainVerification1787100000000,
]
return migrations
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApplicationEventName, assertNotNullOrUndefined, SAMLAuthnProviderConfig } from '@activepieces/shared'
import { ApplicationEventName, assertNotNullOrUndefined, PrincipalType, SAMLAuthnProviderConfig } from '@activepieces/shared'
import { FastifyBaseLogger, FastifyRequest } from 'fastify'
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'
Expand All @@ -9,6 +9,7 @@ import { system } from '../../../helper/system/system'
import { AppSystemProp } from '../../../helper/system/system-props'
import { platformService } from '../../../platform/platform.service'
import { platformUtils } from '../../../platform/platform.utils'
import { platformMustHaveFeatureEnabled } from '../ee-authorization'
import { authnSsoSamlService } from './authn-sso-saml-service'

export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => {
Expand Down Expand Up @@ -39,6 +40,19 @@ export const authnSsoSamlController: FastifyPluginAsyncZod = async (app) => {
})
return res.redirect(url.toString())
})

app.post('/sso-domain', UpdateSsoDomainRequest, async (req) => {
return authnSsoSamlService(req.log).updateSsoDomain({
platformId: req.principal.platform.id,
ssoDomain: req.body.ssoDomain,
})
})

app.post('/sso-domain/verify', VerifySsoDomainRequest, async (req) => {
return authnSsoSamlService(req.log).verifySsoDomain({
platformId: req.principal.platform.id,
})
})
}

async function getSamlConfigOrThrow(req: FastifyRequest, log: FastifyBaseLogger): Promise<{ saml: SAMLAuthnProviderConfig, platformId: string }> {
Expand Down Expand Up @@ -68,3 +82,22 @@ const LoginRequest = {
security: securityAccess.public(),
},
}

const UpdateSsoDomainRequest = {
config: {
security: securityAccess.platformAdminOnly([PrincipalType.USER]),
},
preHandler: platformMustHaveFeatureEnabled((platform) => platform.plan.ssoEnabled),
schema: {
body: z.object({
ssoDomain: z.string().max(253).nullable(),
}),
},
}

const VerifySsoDomainRequest = {
config: {
security: securityAccess.platformAdminOnly([PrincipalType.USER]),
},
preHandler: platformMustHaveFeatureEnabled((platform) => platform.plan.ssoEnabled),
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { SystemJobName } from '../../../helper/system-jobs/common'
import { systemJobHandlers } from '../../../helper/system-jobs/job-handlers'
import { systemJobsSchedule } from '../../../helper/system-jobs/system-job'
import { authnSsoSamlController } from './authn-sso-saml-controller'
import { authnSsoSamlService } from './authn-sso-saml-service'

export const authnSsoSamlModule: FastifyPluginAsyncZod = async (app) => {
systemJobHandlers.registerJobHandler(SystemJobName.EXPIRE_PENDING_SSO_DOMAINS, async () => authnSsoSamlService(app.log).expirePendingSsoDomains())
void systemJobsSchedule(app.log).upsertJob({
job: {
name: SystemJobName.EXPIRE_PENDING_SSO_DOMAINS,
data: {},
jobId: SystemJobName.EXPIRE_PENDING_SSO_DOMAINS,
},
schedule: {
type: 'repeated',
cron: '0 * * * *',
},
})
await app.register(authnSsoSamlController, { prefix: '/v1/authn/saml' })
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { AuthenticationResponse, SAMLAuthnProviderConfig, UserIdentityProvider } from '@activepieces/shared'
import { resolveTxt } from 'dns/promises'
import { ActivepiecesError, apId, AuthenticationResponse, ErrorCode, isNil, PlatformId, SAMLAuthnProviderConfig, SsoDomainVerification, SsoDomainVerificationRecordType, SsoDomainVerificationStatus, tryCatch, UserIdentityProvider } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { z } from 'zod'
import { authenticationService } from '../../../authentication/authentication.service'
import { platformRepo, platformService } from '../../../platform/platform.service'
import { createSamlClient, IdpLoginResponse } from './saml-client'

export const authnSsoSamlService = (log: FastifyBaseLogger) => {
Expand All @@ -25,9 +28,124 @@ export const authnSsoSamlService = (log: FastifyBaseLogger) => {
predefinedPlatformId: platformId,
})
},
async updateSsoDomain({ platformId, ssoDomain }: UpdateSsoDomainParams): Promise<SsoDomainState> {
const normalized = ssoDomain?.trim().toLowerCase() ?? null
const value = normalized && normalized.length > 0 ? normalized : null
if (!isNil(value)) {
if (!z.hostname().safeParse(value).success || !value.includes('.')) {
throw new ActivepiecesError({
code: ErrorCode.VALIDATION,
params: {
message: 'SSO domain must be a valid lowercase domain (e.g. acme.com)',
},
})
}
const existing = await platformRepo().findOneBy({ ssoDomain: value })
if (!isNil(existing) && existing.id !== platformId) {
throw new ActivepiecesError({
code: ErrorCode.VALIDATION,
params: {
message: 'This SSO domain is already in use',
},
})
}
}

const current = await platformService(log).getOneOrThrow(platformId)
const verification = computeNextVerification({ nextDomain: value, currentDomain: current.ssoDomain ?? null, currentVerification: current.ssoDomainVerification ?? null })

await platformService(log).update({ id: platformId, ssoDomain: value, ssoDomainVerification: verification })
return { ssoDomain, ssoDomainVerification: verification }
},
async verifySsoDomain({ platformId }: VerifySsoDomainParams): Promise<SsoDomainState> {
const platform = await platformService(log).getOneOrThrow(platformId)
if (isNil(platform.ssoDomain) || isNil(platform.ssoDomainVerification)) {
throw new ActivepiecesError({
code: ErrorCode.VALIDATION,
params: {
message: 'No SSO domain configured for this platform',
},
})
}
if (platform.ssoDomainVerification.status === SsoDomainVerificationStatus.VERIFIED) {
return { ssoDomain: platform.ssoDomain, ssoDomainVerification: platform.ssoDomainVerification }
}

const matched = await txtRecordMatches({ name: platform.ssoDomainVerification.record.name, expected: platform.ssoDomainVerification.record.value, log })
if (!matched) {
return { ssoDomain: platform.ssoDomain, ssoDomainVerification: platform.ssoDomainVerification }
}

const verified: SsoDomainVerification = {
...platform.ssoDomainVerification,
status: SsoDomainVerificationStatus.VERIFIED,
}
const updated = await platformService(log).update({ id: platformId, ssoDomainVerification: verified })
return { ssoDomain: updated.ssoDomain ?? null, ssoDomainVerification: updated.ssoDomainVerification ?? null }
},
async expirePendingSsoDomains(): Promise<void> {
const result = await platformRepo()
.createQueryBuilder()
.update()
.set({ ssoDomain: null, ssoDomainVerification: null })
.where('"ssoDomain" IS NOT NULL')
.andWhere('"ssoDomainVerification"->>\'status\' = :status', { status: SsoDomainVerificationStatus.PENDING_VERIFICATION })
.andWhere(`("ssoDomainVerification"->>'createdAt')::timestamptz < NOW() - INTERVAL '${PENDING_DOMAIN_TTL_HOURS} hour'`)
.execute()
const affected = result.affected ?? 0
if (affected > 0) {
log.info({ affected }, 'Expired pending SSO domain verifications')
}
},
}
}

const PENDING_DOMAIN_TTL_HOURS = 3

function computeNextVerification({ nextDomain, currentDomain, currentVerification }: { nextDomain: string | null, currentDomain: string | null, currentVerification: SsoDomainVerification | null }): SsoDomainVerification | null {
if (isNil(nextDomain)) {
return null
}
if (nextDomain === currentDomain && !isNil(currentVerification)) {
return currentVerification
}
return {
status: SsoDomainVerificationStatus.PENDING_VERIFICATION,
record: {
type: SsoDomainVerificationRecordType.TXT,
name: `${VERIFICATION_NAME_PREFIX}.${nextDomain}`,
value: `${VERIFICATION_VALUE_PREFIX}=${apId()}`,
},
createdAt: new Date().toISOString(),
}
}

async function txtRecordMatches({ name, expected, log }: { name: string, expected: string, log: FastifyBaseLogger }): Promise<boolean> {
const lookup = await tryCatch(() => resolveTxt(name))
if (lookup.error) {
log.warn({ name, error: lookup.error }, 'TXT record lookup failed for SSO domain verification')
return false
}
return lookup.data.some((chunks) => chunks.join('').trim() === expected)
}

const VERIFICATION_NAME_PREFIX = '_activepieces-verify'
const VERIFICATION_VALUE_PREFIX = 'activepieces-verify'

type LoginResponse = {
redirectUrl: string
}
}

type UpdateSsoDomainParams = {
platformId: PlatformId
ssoDomain: string | null
}

type VerifySsoDomainParams = {
platformId: PlatformId
}

type SsoDomainState = {
ssoDomain: string | null
ssoDomainVerification: SsoDomainVerification | null
}
2 changes: 2 additions & 0 deletions packages/server/api/src/app/helper/system-jobs/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum SystemJobName {
HARD_DELETE_PROJECT = 'hard-delete-project',
HARD_DELETE_PLATFORM = 'hard-delete-platform',
RESUME_DELAY_WAITPOINT = 'resume-delay-waitpoint',
EXPIRE_PENDING_SSO_DOMAINS = 'expire-pending-sso-domains',
}

type DeleteFlowDurableSystemJobData = {
Expand Down Expand Up @@ -54,6 +55,7 @@ type SystemJobDataMap = {
[SystemJobName.HARD_DELETE_PROJECT]: HardDeleteProjectSystemJobData
[SystemJobName.HARD_DELETE_PLATFORM]: HardDeletePlatformSystemJobData
[SystemJobName.RESUME_DELAY_WAITPOINT]: ResumeDelayWaitpointSystemJobData
[SystemJobName.EXPIRE_PENDING_SSO_DOMAINS]: Record<string, never>
}

export type SystemJobData<T extends SystemJobName = SystemJobName> = T extends SystemJobName ? SystemJobDataMap[T] : never
Expand Down
17 changes: 16 additions & 1 deletion packages/server/api/src/app/platform/platform.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export const PlatformEntity = new EntitySchema<PlatformSchema>({
type: String,
array: true,
},
ssoDomain: {
type: String,
nullable: true,
},
ssoDomainVerification: {
type: 'jsonb',
nullable: true,
},
enforceAllowedAuthDomains: {
type: Boolean,
nullable: false,
Expand All @@ -73,7 +81,14 @@ export const PlatformEntity = new EntitySchema<PlatformSchema>({
nullable: false,
},
},
indices: [],
indices: [
{
name: 'idx_platform_sso_domain',
columns: ['ssoDomain'],
unique: true,
where: '"ssoDomain" IS NOT NULL',
},
],
relations: {
owner: {
type: 'one-to-one',
Expand Down
5 changes: 5 additions & 0 deletions packages/server/api/src/app/platform/platform.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ActivepiecesError,
PlatformWithoutSensitiveData,
ProjectType,
spreadIfDefined,
SsoDomainVerification,
UpdatePlatformRequestBody,
UserId,
UserStatus,
Expand Down Expand Up @@ -149,6 +150,8 @@ export const platformService = (log: FastifyBaseLogger) => ({
params.enforceAllowedAuthDomains,
),
...spreadIfDefined('allowedAuthDomains', params.allowedAuthDomains),
...spreadIfDefined('ssoDomain', params.ssoDomain),
...spreadIfDefined('ssoDomainVerification', params.ssoDomainVerification),
...spreadIfDefined('pinnedPieces', params.pinnedPieces),
}
if (!isNil(params.plan)) {
Expand Down Expand Up @@ -266,6 +269,8 @@ type UpdateParams = UpdatePlatformRequestBody & {
logoIconUrl?: string
fullLogoUrl?: string
favIconUrl?: string
ssoDomain?: string | null
ssoDomainVerification?: SsoDomainVerification | null
}

type CreatePlatformWithProjectParams = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('Platform API', () => {
// assert
expect(response?.statusCode).toBe(StatusCodes.OK)

expect(Object.keys(responseBody).length).toBe(19)
expect(Object.keys(responseBody).length).toBe(21)
expect(responseBody.id).toBe(mockPlatform.id)
expect(responseBody.ownerId).toBe(mockOwner.id)
expect(responseBody.name).toBe(mockPlatform.name)
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@activepieces/shared",
"version": "0.69.0",
"version": "0.70.1",
"type": "commonjs",
"sideEffects": false,
"main": "./dist/src/index.js",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/management/platform/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './platform.model'
export * from './platform.request'
export * from './concurrency-pool'
export * from './sso-domain-verification'
5 changes: 5 additions & 0 deletions packages/shared/src/lib/management/platform/platform.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod'
import { BaseModelSchema, DateOrString, Nullable } from '../../core/common/base-model'
import { ApId } from '../../core/common/id-generator'
import { FederatedAuthnProviderConfig, FederatedAuthnProviderConfigWithoutSensitiveData } from '../../core/federated-authn'
import { SsoDomainVerification } from './sso-domain-verification'

export type PlatformId = ApId

Expand Down Expand Up @@ -127,6 +128,8 @@ export const Platform = z.object({
cloudAuthEnabled: z.boolean(),
enforceAllowedAuthDomains: z.boolean(),
allowedAuthDomains: z.array(z.string()),
ssoDomain: Nullable(z.string()),
ssoDomainVerification: Nullable(SsoDomainVerification),
federatedAuthProviders: FederatedAuthnProviderConfig,
emailAuthEnabled: z.boolean(),
pinnedPieces: z.array(z.string()),
Expand All @@ -151,6 +154,8 @@ export const PlatformWithoutSensitiveData = z.object({
cloudAuthEnabled: z.boolean(),
enforceAllowedAuthDomains: z.boolean(),
allowedAuthDomains: z.array(z.string()),
ssoDomain: Nullable(z.string()),
ssoDomainVerification: Nullable(SsoDomainVerification),
emailAuthEnabled: z.boolean(),
pinnedPieces: z.array(z.string()),
})
Expand Down
Loading
Loading