feat(EE): implement SSO group sync for SAML and OIDC (#1452)

* feat: implement SSO group synchronization for SAML and OIDC

- Add group_sync 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

* rename column
This commit is contained in:
Philip Okugbe
2025-08-31 20:33:37 +01:00
committed by GitHub
parent 509622af54
commit 74cd890bdd
6 changed files with 34 additions and 6 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(),
groupSync: 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,
groupSync: provider.groupSync || 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("groupSync")) {
ssoData.groupSync = values.groupSync;
}
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.groupSync}
{...form.getInputProps("groupSync")}
/>
</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(),
groupSync: 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,
groupSync: provider.groupSync || 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("groupSync")) {
ssoData.groupSync = values.groupSync;
}
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.groupSync}
{...form.getInputProps("groupSync")}
/>
</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;
groupSync: boolean;
creatorId: string;
workspaceId: string;
createdAt: Date;

View File

@ -3,15 +3,13 @@ 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(),
)
.addColumn('group_sync', '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')
.dropColumn('group_sync')
.execute();
}
}

View File

@ -62,6 +62,7 @@ export interface AuthProviders {
deletedAt: Timestamp | null;
id: Generated<string>;
isEnabled: Generated<boolean>;
groupSync: Generated<boolean>;
name: string;
oidcClientId: string | null;
oidcClientSecret: string | null;