Compare commits

...

1 Commits

Author SHA1 Message Date
Karan Balani
6a20aeaba4 feat: sso attributes mapping fields 2026-01-20 23:22:39 +05:30
8 changed files with 641 additions and 31 deletions

View File

@@ -4,12 +4,15 @@ import { Button, Form, Modal } from 'antd';
import put from 'api/v1/domains/id/put';
import post from 'api/v1/domains/post';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import {
GettableAuthDomain,
GoogleAuthConfig,
RoleMapping,
} from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
import AuthnProviderSelector from './AuthnProviderSelector';
@@ -17,6 +20,133 @@ import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
interface GroupMappingItem {
group: string;
role: string;
}
interface DomainAdminEmailItem {
domain: string;
adminEmail: string;
}
// Convert groupMappings object to array format for form
function groupMappingsToList(
groupMappings?: Record<string, string>,
): GroupMappingItem[] {
if (!groupMappings) return [];
return Object.entries(groupMappings).map(([group, role]) => ({
group,
role,
}));
}
// Convert array format back to groupMappings object for API
function listToGroupMappings(
list?: GroupMappingItem[],
): Record<string, string> {
if (!list || list.length === 0) return {};
return list.reduce<Record<string, string>>((acc, { group, role }) => {
if (group && role) {
acc[group] = role;
}
return acc;
}, {});
}
// Convert domainToAdminEmail object to array format for form
function domainToAdminEmailToList(
domainToAdminEmail?: Record<string, string>,
): DomainAdminEmailItem[] {
if (!domainToAdminEmail) return [];
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
domain,
adminEmail,
}));
}
// Convert array format back to domainToAdminEmail object for API
function listToDomainToAdminEmail(
list?: DomainAdminEmailItem[],
): Record<string, string> {
if (!list || list.length === 0) return {};
return list.reduce<Record<string, string>>((acc, { domain, adminEmail }) => {
if (domain && adminEmail) {
acc[domain] = adminEmail;
}
return acc;
}, {});
}
// Build roleMapping object for API submission
function buildRoleMapping(formRoleMapping?: {
defaultRole?: string;
useRoleAttribute?: boolean;
groupMappingsList?: GroupMappingItem[];
}): RoleMapping | undefined {
if (!formRoleMapping) return undefined;
const { defaultRole, useRoleAttribute, groupMappingsList } = formRoleMapping;
const groupMappings = listToGroupMappings(groupMappingsList);
// Only include roleMapping if there's actually something configured
if (
!defaultRole &&
!useRoleAttribute &&
Object.keys(groupMappings).length === 0
) {
return undefined;
}
return {
defaultRole: defaultRole || '',
useRoleAttribute: useRoleAttribute || false,
groupMappings,
};
}
// Build googleAuthConfig for API submission
function buildGoogleAuthConfig(formGoogleAuthConfig?: {
clientId?: string;
clientSecret?: string;
redirectURI?: string;
fetchGroups?: boolean;
serviceAccountJson?: string;
domainToAdminEmailList?: DomainAdminEmailItem[];
fetchTransitiveGroupMembership?: boolean;
allowedGroupsList?: string[];
insecureSkipEmailVerified?: boolean;
}): GoogleAuthConfig | undefined {
if (!formGoogleAuthConfig) return undefined;
const {
clientId,
clientSecret,
redirectURI,
fetchGroups,
serviceAccountJson,
domainToAdminEmailList,
fetchTransitiveGroupMembership,
allowedGroupsList,
insecureSkipEmailVerified,
} = formGoogleAuthConfig;
// Return undefined if required fields are missing
if (!clientId || !clientSecret) return undefined;
return {
clientId,
clientSecret,
redirectURI: redirectURI || '',
fetchGroups: fetchGroups || false,
serviceAccountJson,
domainToAdminEmail: listToDomainToAdminEmail(domainToAdminEmailList),
fetchTransitiveGroupMembership,
allowedGroups: allowedGroupsList,
insecureSkipEmailVerified: insecureSkipEmailVerified || false,
};
}
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
@@ -51,11 +181,46 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const samlEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
// Transform record for form initial values (convert objects to list format for forms)
const initialValues = useMemo(() => {
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
};
}
return {
...record,
roleMapping: record.roleMapping
? {
defaultRole: record.roleMapping.defaultRole,
useRoleAttribute: record.roleMapping.useRoleAttribute,
groupMappingsList: groupMappingsToList(record.roleMapping.groupMappings),
}
: undefined,
googleAuthConfig: record.googleAuthConfig
? {
...record.googleAuthConfig,
domainToAdminEmailList: domainToAdminEmailToList(
record.googleAuthConfig.domainToAdminEmail,
),
allowedGroupsList: record.googleAuthConfig.allowedGroups,
}
: undefined,
};
}, [record]);
const onSubmitHandler = async (): Promise<void> => {
const name = form.getFieldValue('name');
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
const formGoogleAuthConfig = form.getFieldValue('googleAuthConfig');
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const formRoleMapping = form.getFieldValue('roleMapping');
const googleAuthConfig = buildGoogleAuthConfig(formGoogleAuthConfig);
const roleMapping = buildRoleMapping(formRoleMapping);
try {
if (isCreate) {
@@ -67,6 +232,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
});
} else {
@@ -78,6 +244,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
});
}
@@ -96,11 +263,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
<Modal open footer={null} onCancel={onClose}>
<Form
name="auth-domain"
initialValues={defaultTo(record, {
name: '',
ssoEnabled: false,
ssoType: '',
})}
initialValues={initialValues}
form={form}
layout="vertical"
>

View File

@@ -1,13 +1,27 @@
import './Providers.styles.scss';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Callout } from '@signozhq/callout';
import { Form, Input, Typography } from 'antd';
import {
Button,
Checkbox,
Collapse,
Form,
Input,
Select,
Space,
Typography,
} from 'antd';
import RoleMappingSection from './RoleMappingSection';
function ConfigureGoogleAuthAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups']);
return (
<div className="google-auth">
<section className="header">
@@ -62,11 +76,168 @@ function ConfigureGoogleAuthAuthnProvider({
<Input />
</Form.Item>
<Form.Item
label="Skip Email Verification"
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip email verification. Defaults to "false"`,
}}
>
<Checkbox />
</Form.Item>
<Collapse
ghost
items={[
{
key: 'groupFetching',
label: (
<Typography.Text strong>
Google Workspace Groups (Advanced)
</Typography.Text>
),
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
Enable group fetching to retrieve user groups from Google Workspace.
This requires a Google Service Account with domain-wide delegation and
the Admin SDK Directory API enabled.
</Typography.Paragraph>
<Form.Item
label="Fetch Groups"
name={['googleAuthConfig', 'fetchGroups']}
valuePropName="checked"
className="field"
tooltip={{
title: `Enable fetching Google Workspace groups for the user. Requires service account configuration.`,
}}
>
<Checkbox />
</Form.Item>
{fetchGroups && (
<>
<Form.Item
label="Service Account JSON"
name={['googleAuthConfig', 'serviceAccountJson']}
tooltip={{
title: `The JSON content of the Google Service Account credentials file. Required for group fetching.`,
}}
rules={[
{
required: fetchGroups,
message:
'Service Account JSON is required when Fetch Groups is enabled',
},
]}
>
<Input.TextArea
rows={4}
placeholder='{"type": "service_account", ...}'
/>
</Form.Item>
<Typography.Text
strong
style={{ display: 'block', marginBottom: 8, marginTop: 16 }}
>
Domain to Admin Email Mapping
</Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
Map workspace domains to admin emails for service account
impersonation. Use &quot;*&quot; as a wildcard for any domain.
</Typography.Paragraph>
<Form.List name={['googleAuthConfig', 'domainToAdminEmailList']}>
{(fields, { add, remove }): JSX.Element => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: 'flex', marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, 'domain']}
rules={[{ required: true, message: 'Domain required' }]}
>
<Input
placeholder="Domain (e.g., example.com or *)"
style={{ width: 180 }}
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, 'adminEmail']}
rules={[
{ required: true, message: 'Admin email required' },
{ type: 'email', message: 'Must be a valid email' },
]}
>
<Input placeholder="Admin Email" style={{ width: 200 }} />
</Form.Item>
<MinusCircleOutlined onClick={(): void => remove(name)} />
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={(): void => add()}
block
icon={<PlusOutlined />}
>
Add Domain Mapping
</Button>
</Form.Item>
</>
)}
</Form.List>
<Form.Item
label="Fetch Transitive Group Membership"
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
valuePropName="checked"
className="field"
tooltip={{
title: `If enabled, recursively fetch groups that contain other groups (transitive membership).`,
}}
>
<Checkbox />
</Form.Item>
<Form.Item
label="Allowed Groups"
name={['googleAuthConfig', 'allowedGroupsList']}
tooltip={{
title: `Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.`,
}}
>
<Select
mode="tags"
placeholder="Enter group emails and press Enter"
style={{ width: '100%' }}
tokenSeparators={[',']}
/>
</Form.Item>
</>
)}
</>
),
},
]}
/>
<RoleMappingSection />
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 wont be enabled unless you enter all the attributes above"
description="Google OAuth2 won't be enabled unless you enter all the required attributes above"
className="callout"
/>
</div>

View File

@@ -1,7 +1,9 @@
import './Providers.styles.scss';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
import { Checkbox, Collapse, Form, Input, Typography } from 'antd';
import RoleMappingSection from './RoleMappingSection';
function ConfigureOIDCAuthnProvider({
isCreate,
@@ -64,16 +66,6 @@ function ConfigureOIDCAuthnProvider({
<Input />
</Form.Item>
<Form.Item
label="Email Claim Mapping"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `Mapping of email claims to the corresponding email field in the token.`,
}}
>
<Input />
</Form.Item>
<Form.Item
label="Skip Email Verification"
name={['oidcConfig', 'insecureSkipEmailVerified']}
@@ -98,11 +90,71 @@ function ConfigureOIDCAuthnProvider({
<Checkbox />
</Form.Item>
<Collapse
ghost
items={[
{
key: 'claimMapping',
label: <Typography.Text strong>Claim Mapping (Advanced)</Typography.Text>,
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
Configure how claims from your Identity Provider map to SigNoz user
attributes. Leave empty to use default values.
</Typography.Paragraph>
<Form.Item
label="Email Claim"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `The claim key that contains the user's email address. Default: "email"`,
}}
>
<Input placeholder="email" />
</Form.Item>
<Form.Item
label="Name Claim"
name={['oidcConfig', 'claimMapping', 'name']}
tooltip={{
title: `The claim key that contains the user's display name. Default: "name"`,
}}
>
<Input placeholder="name" />
</Form.Item>
<Form.Item
label="Groups Claim"
name={['oidcConfig', 'claimMapping', 'groups']}
tooltip={{
title: `The claim key that contains the user's group memberships. Used for role mapping. Default: "groups"`,
}}
>
<Input placeholder="groups" />
</Form.Item>
<Form.Item
label="Role Claim"
name={['oidcConfig', 'claimMapping', 'role']}
tooltip={{
title: `The claim key that contains the user's role directly from the IDP. Default: "role"`,
}}
>
<Input placeholder="role" />
</Form.Item>
</>
),
},
]}
/>
<RoleMappingSection />
<Callout
type="warning"
size="small"
showIcon
description="OIDC wont be enabled unless you enter all the attributes above"
description="OIDC won't be enabled unless you enter all the required attributes above"
className="callout"
/>
</div>

View File

@@ -1,7 +1,9 @@
import './Providers.styles.scss';
import { Callout } from '@signozhq/callout';
import { Checkbox, Form, Input, Typography } from 'antd';
import { Checkbox, Collapse, Form, Input, Typography } from 'antd';
import RoleMappingSection from './RoleMappingSection';
function ConfigureSAMLAuthnProvider({
isCreate,
@@ -70,11 +72,64 @@ function ConfigureSAMLAuthnProvider({
<Checkbox />
</Form.Item>
<Collapse
ghost
items={[
{
key: 'attributeMapping',
label: (
<Typography.Text strong>Attribute Mapping (Advanced)</Typography.Text>
),
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
Configure how SAML assertion attributes from your Identity Provider map
to SigNoz user attributes. Leave empty to use default values. Note:
Email is always extracted from the NameID assertion.
</Typography.Paragraph>
<Form.Item
label="Name Attribute"
name={['samlConfig', 'attributeMapping', 'name']}
tooltip={{
title: `The SAML attribute key that contains the user's display name. Default: "name"`,
}}
>
<Input placeholder="name" />
</Form.Item>
<Form.Item
label="Groups Attribute"
name={['samlConfig', 'attributeMapping', 'groups']}
tooltip={{
title: `The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: "groups"`,
}}
>
<Input placeholder="groups" />
</Form.Item>
<Form.Item
label="Role Attribute"
name={['samlConfig', 'attributeMapping', 'role']}
tooltip={{
title: `The SAML attribute key that contains the user's role directly from the IDP. Default: "role"`,
}}
>
<Input placeholder="role" />
</Form.Item>
</>
),
},
]}
/>
<RoleMappingSection />
<Callout
type="warning"
size="small"
showIcon
description="SAML wont be enabled unless you enter all the attributes above"
description="SAML won't be enabled unless you enter all the required attributes above"
className="callout"
/>
</div>

View File

@@ -0,0 +1,118 @@
import './Providers.styles.scss';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import {
Button,
Checkbox,
Collapse,
Form,
Input,
Select,
Space,
Typography,
} from 'antd';
const ROLE_OPTIONS = [
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Editor', value: 'EDITOR' },
{ label: 'Admin', value: 'ADMIN' },
];
function RoleMappingSection(): JSX.Element {
return (
<Collapse
ghost
items={[
{
key: 'roleMapping',
label: <Typography.Text strong>Role Mapping (Advanced)</Typography.Text>,
children: (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
Configure how user roles are determined from your Identity Provider. You
can either use a direct role attribute or map IDP groups to SigNoz
roles.
</Typography.Paragraph>
<Form.Item
label="Default Role"
name={['roleMapping', 'defaultRole']}
tooltip={{
title: `The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"`,
}}
>
<Select placeholder="VIEWER" options={ROLE_OPTIONS} allowClear />
</Form.Item>
<Form.Item
label="Use Role Attribute Directly"
name={['roleMapping', 'useRoleAttribute']}
valuePropName="checked"
className="field"
tooltip={{
title: `If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).`,
}}
>
<Checkbox />
</Form.Item>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
Group to Role Mappings
</Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
Map IDP group names to SigNoz roles. If a user belongs to multiple
groups, the highest privilege role will be assigned.
</Typography.Paragraph>
<Form.List name={['roleMapping', 'groupMappingsList']}>
{(fields, { add, remove }): JSX.Element => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
style={{ display: 'flex', marginBottom: 8 }}
align="baseline"
>
<Form.Item
{...restField}
name={[name, 'group']}
rules={[{ required: true, message: 'Group name required' }]}
>
<Input placeholder="IDP Group Name" style={{ width: 200 }} />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'role']}
rules={[{ required: true, message: 'Role required' }]}
>
<Select
placeholder="SigNoz Role"
options={ROLE_OPTIONS}
style={{ width: 120 }}
/>
</Form.Item>
<MinusCircleOutlined onClick={(): void => remove(name)} />
</Space>
))}
<Form.Item>
<Button
type="dashed"
onClick={(): void => add()}
block
icon={<PlusOutlined />}
>
Add Group Mapping
</Button>
</Form.Item>
</>
)}
</Form.List>
</>
),
},
]}
/>
);
}
export default RoleMappingSection;

View File

@@ -15,6 +15,7 @@ export interface GettableAuthDomain {
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
roleMapping?: RoleMapping;
}
export interface SAMLConfig {
@@ -22,12 +23,19 @@ export interface SAMLConfig {
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
attributeMapping?: AttributeMapping;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
fetchGroups: boolean;
serviceAccountJson?: string;
domainToAdminEmail?: Record<string, string>;
fetchTransitiveGroupMembership?: boolean;
allowedGroups?: string[];
insecureSkipEmailVerified: boolean;
}
export interface OIDCConfig {
@@ -35,13 +43,22 @@ export interface OIDCConfig {
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
claimMapping?: AttributeMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
export interface AttributeMapping {
email: string;
name: string;
groups: string;
role: string;
}
export interface RoleMapping {
defaultRole: string;
groupMappings: Record<string, string>;
useRoleAttribute: boolean;
}
export interface AuthNProviderInfo {

View File

@@ -9,6 +9,7 @@ export interface Config {
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
roleMapping?: RoleMapping;
}
export interface SAMLConfig {
@@ -16,12 +17,19 @@ export interface SAMLConfig {
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
attributeMapping?: AttributeMapping;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
fetchGroups: boolean;
serviceAccountJson?: string;
domainToAdminEmail?: Record<string, string>;
fetchTransitiveGroupMembership?: boolean;
allowedGroups?: string[];
insecureSkipEmailVerified: boolean;
}
export interface OIDCConfig {
@@ -29,11 +37,20 @@ export interface OIDCConfig {
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
claimMapping?: AttributeMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
export interface AttributeMapping {
email: string;
name: string;
groups: string;
role: string;
}
export interface RoleMapping {
defaultRole: string;
groupMappings: Record<string, string>;
useRoleAttribute: boolean;
}

View File

@@ -5,6 +5,7 @@ export interface UpdatableAuthDomain {
samlConfig?: SAMLConfig;
googleAuthConfig?: GoogleAuthConfig;
oidcConfig?: OIDCConfig;
roleMapping?: RoleMapping;
};
id: string;
}
@@ -14,12 +15,19 @@ export interface SAMLConfig {
samlIdp: string;
samlCert: string;
insecureSkipAuthNRequestsSigned: boolean;
attributeMapping?: AttributeMapping;
}
export interface GoogleAuthConfig {
clientId: string;
clientSecret: string;
redirectURI: string;
fetchGroups: boolean;
serviceAccountJson?: string;
domainToAdminEmail?: Record<string, string>;
fetchTransitiveGroupMembership?: boolean;
allowedGroups?: string[];
insecureSkipEmailVerified: boolean;
}
export interface OIDCConfig {
@@ -27,11 +35,20 @@ export interface OIDCConfig {
issuerAlias: string;
clientId: string;
clientSecret: string;
claimMapping: ClaimMapping;
claimMapping?: AttributeMapping;
insecureSkipEmailVerified: boolean;
getUserInfo: boolean;
}
export interface ClaimMapping {
export interface AttributeMapping {
email: string;
name: string;
groups: string;
role: string;
}
export interface RoleMapping {
defaultRole: string;
groupMappings: Record<string, string>;
useRoleAttribute: boolean;
}