feat: add organisation sso portal (#1946)

Allow organisations to manage an SSO OIDC compliant portal. This method
is intended to streamline the onboarding process and paves the way to
allow organisations to manage their members in a more strict way.
This commit is contained in:
David Nguyen
2025-09-09 17:14:07 +10:00
committed by GitHub
parent 374f2c45b4
commit 9ac7b94d9a
56 changed files with 2922 additions and 200 deletions

View File

@ -0,0 +1,75 @@
/*
Warnings:
- A unique constraint covering the columns `[organisationAuthenticationPortalId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organisationAuthenticationPortalId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ACCOUNT_SSO_UNLINK';
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_LINK';
ALTER TYPE "UserSecurityAuditLogType" ADD VALUE 'ORGANISATION_SSO_UNLINK';
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- [CUSTOM_CHANGE] This is supposed to be NOT NULL but we reapply it at the end.
ALTER TABLE "Organisation" ADD COLUMN "organisationAuthenticationPortalId" TEXT;
-- AlterTable
ALTER TABLE "VerificationToken" ADD COLUMN "metadata" JSONB;
-- CreateTable
CREATE TABLE "OrganisationAuthenticationPortal" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"clientId" TEXT NOT NULL DEFAULT '',
"clientSecret" TEXT NOT NULL DEFAULT '',
"wellKnownUrl" TEXT NOT NULL DEFAULT '',
"defaultOrganisationRole" "OrganisationMemberRole" NOT NULL DEFAULT 'MEMBER',
"autoProvisionUsers" BOOLEAN NOT NULL DEFAULT true,
"allowedDomains" TEXT[] DEFAULT ARRAY[]::TEXT[],
"organisationId" TEXT, -- [CUSTOM_CHANGE] This is a temporary column for migration purposes.
CONSTRAINT "OrganisationAuthenticationPortal_pkey" PRIMARY KEY ("id")
);
-- [CUSTOM_CHANGE] Create default OrganisationAuthenticationPortal for all organisations
INSERT INTO "OrganisationAuthenticationPortal" ("id", "enabled", "clientId", "clientSecret", "wellKnownUrl", "defaultOrganisationRole", "autoProvisionUsers", "allowedDomains", "organisationId")
SELECT
generate_prefix_id('org_sso'),
false,
'',
'',
'',
'MEMBER',
true,
ARRAY[]::TEXT[],
o."id"
FROM "Organisation" o
WHERE o."organisationAuthenticationPortalId" IS NULL;
-- [CUSTOM_CHANGE] Update organisations with their corresponding organisationAuthenticationPortalId
UPDATE "Organisation" o
SET "organisationAuthenticationPortalId" = oap."id"
FROM "OrganisationAuthenticationPortal" oap
WHERE oap."organisationId" = o."id" AND o."organisationAuthenticationPortalId" IS NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Organisation_organisationAuthenticationPortalId_key" ON "Organisation"("organisationAuthenticationPortalId");
-- AddForeignKey
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationAuthenticationPortalId_fkey" FOREIGN KEY ("organisationAuthenticationPortalId") REFERENCES "OrganisationAuthenticationPortal"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- [CUSTOM_CHANGE] Reapply NOT NULL constraint.
ALTER TABLE "Organisation" ALTER COLUMN "organisationAuthenticationPortalId" SET NOT NULL;
-- [CUSTOM_CHANGE] Drop temporary column.
ALTER TABLE "OrganisationAuthenticationPortal" DROP COLUMN "organisationId";

View File

@ -90,6 +90,9 @@ model TeamProfile {
enum UserSecurityAuditLogType {
ACCOUNT_PROFILE_UPDATE
ACCOUNT_SSO_LINK
ACCOUNT_SSO_UNLINK
ORGANISATION_SSO_LINK
ORGANISATION_SSO_UNLINK
AUTH_2FA_DISABLE
AUTH_2FA_ENABLE
PASSKEY_CREATED
@ -157,6 +160,7 @@ model VerificationToken {
completed Boolean @default(false)
expires DateTime
createdAt DateTime @default(now())
metadata Json?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@ -277,13 +281,15 @@ model OrganisationClaim {
}
model Account {
id String @id @default(cuid())
id String @id @default(cuid())
// When this record was created, unrelated to anything passed back by the provider.
createdAt DateTime @default(now())
userId Int
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
// Some providers return created_at so we need to make it optional
created_at Int?
@ -291,7 +297,7 @@ model Account {
ext_expires_in Int?
token_type String?
scope String?
id_token String? @db.Text
id_token String? @db.Text
session_state String?
password String?
@ -632,6 +638,9 @@ model Organisation {
organisationGlobalSettingsId String @unique
organisationGlobalSettings OrganisationGlobalSettings @relation(fields: [organisationGlobalSettingsId], references: [id])
organisationAuthenticationPortalId String @unique
organisationAuthenticationPortal OrganisationAuthenticationPortal @relation(fields: [organisationAuthenticationPortalId], references: [id])
}
model OrganisationMember {
@ -1026,3 +1035,18 @@ model OrganisationEmail {
organisationGlobalSettings OrganisationGlobalSettings[]
teamGlobalSettings TeamGlobalSettings[]
}
model OrganisationAuthenticationPortal {
id String @id
organisation Organisation?
enabled Boolean @default(false)
clientId String @default("")
clientSecret String @default("")
wellKnownUrl String @default("")
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
}

View File

@ -1,10 +1,8 @@
import type { OrganisationMemberRole, OrganisationType } from '@prisma/client';
import { OrganisationMemberInviteStatus, type User } from '@prisma/client';
import { nanoid } from 'nanoid';
import { OrganisationGroupType, type User } from '@prisma/client';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { prefixedId } from '@documenso/lib/universal/id';
import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { prisma } from '..';
import { seedTestEmail } from './users';
@ -27,6 +25,13 @@ export const seedOrganisationMembers = async ({
const createdMembers: User[] = [];
const organisationGroups = await prisma.organisationGroup.findMany({
where: {
organisationId,
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
});
for (const member of members) {
const email = member.email ?? seedTestEmail();
@ -53,33 +58,15 @@ export const seedOrganisationMembers = async ({
email: newUser.email,
organisationRole: member.organisationRole,
});
await addUserToOrganisation({
userId: newUser.id,
organisationId,
organisationGroups,
organisationMemberRole: member.organisationRole,
});
}
await prisma.organisationMemberInvite.createMany({
data: membersToInvite.map((invite) => ({
id: prefixedId('member_invite'),
email: invite.email,
organisationId,
organisationRole: invite.organisationRole,
token: nanoid(32),
})),
});
const invites = await prisma.organisationMemberInvite.findMany({
where: {
organisationId,
status: OrganisationMemberInviteStatus.PENDING,
},
});
await Promise.all(
invites.map(async (invite) => {
await acceptOrganisationInvitation({
token: invite.token,
});
}),
);
return createdMembers;
};