diff --git a/authentik/providers/scim/clients/groups.py b/authentik/providers/scim/clients/groups.py index 8cd62c49fc..6616ce15dc 100644 --- a/authentik/providers/scim/clients/groups.py +++ b/authentik/providers/scim/clients/groups.py @@ -54,6 +54,13 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): ["group", "provider", "connection"], ) + def _create_group_member(self, id: str) -> GroupMember: + member = GroupMember(value=id) + # https://developer.webex.com/admin/docs/api/v1/scim-2-groups/create-a-group + if self.provider.compatibility_mode == SCIMCompatibilityMode.WEBEX: + member.type = "user" + return member + def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema: """Convert authentik user into SCIM""" raw_scim_group = super().to_schema(obj, connection) @@ -77,9 +84,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): members = [] for user in connections: members.append( - GroupMember( - value=user.scim_id, - ) + self._create_group_member(user.scim_id), ) if members: scim_group.members = members @@ -322,7 +327,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): op=PatchOp.add, path="members", value=[ - GroupMember(value=x).model_dump( + self._create_group_member(x).model_dump( mode="json", exclude_unset=True, ) @@ -335,7 +340,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): op=PatchOp.remove, path="members", value=[ - GroupMember(value=x).model_dump( + self._create_group_member(x).model_dump( mode="json", exclude_unset=True, ) @@ -363,7 +368,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): op=PatchOp.add, path="members", value=[ - GroupMember(value=x).model_dump( + self._create_group_member(x).model_dump( mode="json", exclude_unset=True, ) @@ -391,7 +396,7 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]): op=PatchOp.remove, path="members", value=[ - GroupMember(value=x).model_dump( + self._create_group_member(x).model_dump( mode="json", exclude_unset=True, ) diff --git a/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py b/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py index 5c42f36194..0b48ab4109 100644 --- a/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py +++ b/authentik/providers/scim/migrations/0019_scimprovider_group_filters_and_more.py @@ -84,4 +84,21 @@ class Migration(migrations.Migration): to="authentik_core.group", ), ), + migrations.AlterField( + model_name="scimprovider", + name="compatibility_mode", + field=models.CharField( + choices=[ + ("default", "Default"), + ("aws", "AWS"), + ("slack", "Slack"), + ("sfdc", "Salesforce"), + ("webex", "Webex"), + ], + default="default", + help_text="Alter authentik behavior for vendor-specific SCIM implementations.", + max_length=30, + verbose_name="SCIM Compatibility Mode", + ), + ), ] diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 0604e40c1f..025b5db1b8 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -82,6 +82,7 @@ class SCIMCompatibilityMode(models.TextChoices): AWS = "aws", _("AWS") SLACK = "slack", _("Slack") SALESFORCE = "sfdc", _("Salesforce") + WEBEX = "webex", _("Webex") class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): diff --git a/authentik/providers/scim/tests/test_membership.py b/authentik/providers/scim/tests/test_membership.py index 3e570bfcfd..abcee92acd 100644 --- a/authentik/providers/scim/tests/test_membership.py +++ b/authentik/providers/scim/tests/test_membership.py @@ -7,7 +7,7 @@ from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application, Group, User from authentik.lib.generators import generate_id from authentik.providers.scim.clients.schema import ServiceProviderConfiguration -from authentik.providers.scim.models import SCIMMapping, SCIMProvider +from authentik.providers.scim.models import SCIMCompatibilityMode, SCIMMapping, SCIMProvider from authentik.providers.scim.tasks import scim_sync from authentik.tenants.models import Tenant @@ -26,12 +26,13 @@ class SCIMMembershipTests(TestCase): Tenant.objects.update(avatars="none") @apply_blueprint("system/providers-scim.yaml") - def configure(self) -> None: + def configure(self, **kwargs) -> None: """Configure provider""" self.provider: SCIMProvider = SCIMProvider.objects.create( name=generate_id(), url="https://localhost", token=generate_id(), + **kwargs, ) self.app: Application = Application.objects.create( name=generate_id(), @@ -353,3 +354,113 @@ class SCIMMembershipTests(TestCase): ] }, ) + + def test_member_add_save_compat_webex(self): + """Test member add + save""" + config = ServiceProviderConfiguration.default() + + config.patch.supported = True + user_scim_id = generate_id() + group_scim_id = generate_id() + uid = generate_id() + group = Group.objects.create( + name=uid, + ) + + user = User.objects.create(username=generate_id()) + + # Test initial sync of group creation + with Mocker() as mocker: + mocker.get( + "https://localhost/ServiceProviderConfig", + json=config.model_dump(), + ) + mocker.post( + "https://localhost/Users", + json={ + "id": user_scim_id, + }, + ) + mocker.post( + "https://localhost/Groups", + json={ + "id": group_scim_id, + }, + ) + + self.configure(compatibility_mode=SCIMCompatibilityMode.WEBEX) + scim_sync.send(self.provider.pk) + + self.assertEqual(mocker.call_count, 3) + self.assertEqual(mocker.request_history[0].method, "GET") + self.assertEqual(mocker.request_history[1].method, "POST") + self.assertEqual(mocker.request_history[2].method, "POST") + self.assertJSONEqual( + mocker.request_history[1].body, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "emails": [], + "active": True, + "externalId": user.uid, + "name": {"familyName": " ", "formatted": " ", "givenName": ""}, + "displayName": "", + "userName": user.username, + }, + ) + self.assertJSONEqual( + mocker.request_history[2].body, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId": str(group.pk), + "displayName": group.name, + }, + ) + + with Mocker() as mocker: + mocker.get( + "https://localhost/ServiceProviderConfig", + json=config.model_dump(), + ) + mocker.get( + f"https://localhost/Groups/{group_scim_id}", + json={}, + ) + mocker.patch( + f"https://localhost/Groups/{group_scim_id}", + json={}, + ) + group.users.add(user) + group.save() + self.assertEqual(mocker.call_count, 3) + self.assertEqual(mocker.request_history[0].method, "PATCH") + self.assertEqual(mocker.request_history[1].method, "PATCH") + self.assertEqual(mocker.request_history[2].method, "GET") + self.assertJSONEqual( + mocker.request_history[0].body, + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "add", + "path": "members", + "value": [{"value": user_scim_id, "type": "user"}], + } + ], + }, + ) + self.assertJSONEqual( + mocker.request_history[1].body, + { + "Operations": [ + { + "op": "replace", + "value": { + "id": group_scim_id, + "displayName": group.name, + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId": str(group.pk), + }, + } + ] + }, + ) diff --git a/blueprints/schema.json b/blueprints/schema.json index 731544146c..05de806e01 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -11057,7 +11057,8 @@ "default", "aws", "slack", - "sfdc" + "sfdc", + "webex" ], "title": "SCIM Compatibility Mode", "description": "Alter authentik behavior for vendor-specific SCIM implementations." diff --git a/packages/client-go/model_compatibility_mode_enum.go b/packages/client-go/model_compatibility_mode_enum.go index 2ed0f22976..cb10a0d21f 100644 --- a/packages/client-go/model_compatibility_mode_enum.go +++ b/packages/client-go/model_compatibility_mode_enum.go @@ -25,6 +25,7 @@ const ( COMPATIBILITYMODEENUM_AWS CompatibilityModeEnum = "aws" COMPATIBILITYMODEENUM_SLACK CompatibilityModeEnum = "slack" COMPATIBILITYMODEENUM_SFDC CompatibilityModeEnum = "sfdc" + COMPATIBILITYMODEENUM_WEBEX CompatibilityModeEnum = "webex" ) // All allowed values of CompatibilityModeEnum enum @@ -33,6 +34,7 @@ var AllowedCompatibilityModeEnumEnumValues = []CompatibilityModeEnum{ "aws", "slack", "sfdc", + "webex", } func (v *CompatibilityModeEnum) UnmarshalJSON(src []byte) error { diff --git a/packages/client-rust/src/models/compatibility_mode_enum.rs b/packages/client-rust/src/models/compatibility_mode_enum.rs index d3e55b008a..0849dcab3a 100644 --- a/packages/client-rust/src/models/compatibility_mode_enum.rs +++ b/packages/client-rust/src/models/compatibility_mode_enum.rs @@ -21,6 +21,8 @@ pub enum CompatibilityModeEnum { Slack, #[serde(rename = "sfdc")] Sfdc, + #[serde(rename = "webex")] + Webex, } impl std::fmt::Display for CompatibilityModeEnum { @@ -30,6 +32,7 @@ impl std::fmt::Display for CompatibilityModeEnum { Self::Aws => write!(f, "aws"), Self::Slack => write!(f, "slack"), Self::Sfdc => write!(f, "sfdc"), + Self::Webex => write!(f, "webex"), } } } diff --git a/packages/client-ts/src/models/CompatibilityModeEnum.ts b/packages/client-ts/src/models/CompatibilityModeEnum.ts index 8f8c16d103..bf36cd5ee9 100644 --- a/packages/client-ts/src/models/CompatibilityModeEnum.ts +++ b/packages/client-ts/src/models/CompatibilityModeEnum.ts @@ -22,6 +22,7 @@ export const CompatibilityModeEnum = { Aws: 'aws', Slack: 'slack', Sfdc: 'sfdc', + Webex: 'webex', UnknownDefaultOpenApi: '11184809' } as const; export type CompatibilityModeEnum = typeof CompatibilityModeEnum[keyof typeof CompatibilityModeEnum]; diff --git a/schema.yml b/schema.yml index 8a91345372..2b80827cae 100644 --- a/schema.yml +++ b/schema.yml @@ -35773,6 +35773,7 @@ components: - aws - slack - sfdc + - webex type: string Config: type: object diff --git a/web/src/admin/providers/scim/SCIMProviderFormForm.ts b/web/src/admin/providers/scim/SCIMProviderFormForm.ts index c230f716bd..1b3faebb67 100644 --- a/web/src/admin/providers/scim/SCIMProviderFormForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderFormForm.ts @@ -198,6 +198,11 @@ export function renderForm({ provider = {}, errors = {}, update }: SCIMProviderF value: CompatibilityModeEnum.Sfdc, description: html`${msg("Altered behavior for usage with Salesforce.")}`, }, + { + label: msg("Webex"), + value: CompatibilityModeEnum.Webex, + description: html`${msg("Altered behavior for usage with Cisco Webex.")}`, + }, ]} help=${msg( "Alter authentik's behavior for vendor-specific SCIM implementations.",