feat: implement SSO group synchronization for SAML and OIDC

- Add is_group_sync_enabled column to auth_providers table
- Extract groups from SAML attributes (memberOf, groups, roles)
- Extract groups from OIDC claims (groups, roles)
- Implement case-insensitive group matching with auto-creation
- Sync user groups on each SSO login
- Ensure only one provider can have group sync enabled at a time
- Add group sync toggle to SAML and OIDC configuration forms
This commit is contained in:
Philipinho
2025-07-10 01:31:31 -07:00
parent 29388636bf
commit 837ab802a0
6 changed files with 49 additions and 1 deletions

View File

@ -16,6 +16,7 @@ const ssoSchema = z.object({
oidcClientSecret: z.string().min(1, "Client secret is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
isGroupSyncEnabled: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@ -36,6 +37,7 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
oidcClientSecret: provider.oidcClientSecret || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
isGroupSyncEnabled: provider.isGroupSyncEnabled || false,
},
validate: zodResolver(ssoSchema),
});
@ -67,6 +69,9 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("isGroupSyncEnabled")) {
ssoData.isGroupSyncEnabled = values.isGroupSyncEnabled;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@ -119,6 +124,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
/>
</Group>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.isGroupSyncEnabled}
{...form.getInputProps("isGroupSyncEnabled")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch

View File

@ -26,6 +26,7 @@ const ssoSchema = z.object({
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
isGroupSyncEnabled: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
@ -45,6 +46,7 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
samlCertificate: provider.samlCertificate || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
isGroupSyncEnabled: provider.isGroupSyncEnabled || false,
},
validate: zodResolver(ssoSchema),
});
@ -75,6 +77,9 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("isGroupSyncEnabled")) {
ssoData.isGroupSyncEnabled = values.isGroupSyncEnabled;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
@ -132,6 +137,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
/>
</Group>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.isGroupSyncEnabled}
{...form.getInputProps("isGroupSyncEnabled")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch

View File

@ -11,6 +11,7 @@ export interface IAuthProvider {
oidcClientSecret: string;
allowSignup: boolean;
isEnabled: boolean;
isGroupSyncEnabled: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;

View File

@ -0,0 +1,17 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('auth_providers')
.addColumn('is_group_sync_enabled', 'boolean', (col) =>
col.defaultTo(false).notNull(),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('auth_providers')
.dropColumn('is_group_sync_enabled')
.execute();
}

View File

@ -62,6 +62,7 @@ export interface AuthProviders {
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
isGroupSyncEnabled: Generated<boolean>;
name: string;
oidcClientId: string | null;
oidcClientSecret: string | null;
@ -122,6 +123,7 @@ export interface Comments {
pageId: string;
parentCommentId: string | null;
resolvedAt: Timestamp | null;
resolvedById: string | null;
selection: string | null;
type: string | null;
workspaceId: string;