diff --git a/authentik/sources/scim/api/sources.py b/authentik/sources/scim/api/sources.py index d51395a0656e..1cd3d4c68209 100644 --- a/authentik/sources/scim/api/sources.py +++ b/authentik/sources/scim/api/sources.py @@ -42,6 +42,7 @@ class Meta: "meta_model_name", "managed", "user_path_template", + "managed_objects_only", "root_url", "token_obj", ] diff --git a/authentik/sources/scim/managed.py b/authentik/sources/scim/managed.py new file mode 100644 index 000000000000..eb606873aa5c --- /dev/null +++ b/authentik/sources/scim/managed.py @@ -0,0 +1,56 @@ +"""Helpers for SCIM source managed-objects-only mode.""" + +from django.db.models import QuerySet + +from authentik.core.models import Group, User +from authentik.sources.scim.models import SCIMSource, SCIMSourceGroup, SCIMSourceUser +from authentik.sources.scim.views.v2.exceptions import ( + SCIMConflictError, + SCIMError, + SCIMErrorTypes, + SCIMValidationError, +) + + +def resolve_user( + source: SCIMSource, connection: SCIMSourceUser | None, username: str +) -> User: + """Resolve the user to create or update for a SCIM request.""" + if connection: + return connection.user + if source.managed_objects_only: + if User.objects.filter(username=username).exists(): + raise SCIMConflictError("User with this userName already exists.") + return User() + if existing := User.objects.filter(username=username).first(): + return existing + return User() + + +def resolve_group(source: SCIMSource, connection: SCIMSourceGroup | None, name: str) -> Group: + """Resolve the group to create or update for a SCIM request.""" + if connection: + return connection.group + if source.managed_objects_only: + if Group.objects.filter(name=name).exists(): + raise SCIMConflictError("Group with this displayName already exists.") + return Group() + if existing := Group.objects.filter(name=name).first(): + return existing + return Group() + + +def filter_group_members(source: SCIMSource, users: QuerySet[User]) -> QuerySet[User]: + """Filter group members to users managed by this source when required.""" + if not source.managed_objects_only: + return users + managed = users.filter(scimsourceuser__source=source).distinct() + if users.count() != managed.count(): + raise SCIMValidationError( + SCIMError( + detail="One or more group members are not managed by this SCIM source.", + scimType=SCIMErrorTypes.invalid_value, + status=400, + ) + ) + return managed diff --git a/authentik/sources/scim/migrations/0004_scimsource_managed_objects_only.py b/authentik/sources/scim/migrations/0004_scimsource_managed_objects_only.py new file mode 100644 index 000000000000..c0bfbd0b9fe6 --- /dev/null +++ b/authentik/sources/scim/migrations/0004_scimsource_managed_objects_only.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_sources_scim", "0003_alter_scimsourcegroup_unique_together_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="scimsource", + name="managed_objects_only", + field=models.BooleanField( + default=False, + help_text=( + "Only manage users and groups created by this SCIM source. " + "Disables tenant-wide correlation, restricts group membership, " + "and unlinks objects on DELETE instead of deleting them." + ), + ), + ), + ] diff --git a/authentik/sources/scim/models.py b/authentik/sources/scim/models.py index db5718544348..382d9278aece 100644 --- a/authentik/sources/scim/models.py +++ b/authentik/sources/scim/models.py @@ -17,6 +17,14 @@ class SCIMSource(Source): cross-system user provisioning""" token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None) + managed_objects_only = models.BooleanField( + default=False, + help_text=_( + "Only manage users and groups created by this SCIM source. " + "Disables tenant-wide correlation, restricts group membership, " + "and unlinks objects on DELETE instead of deleting them." + ), + ) @property def service_account_identifier(self) -> str: diff --git a/authentik/sources/scim/tests/test_managed_objects_only.py b/authentik/sources/scim/tests/test_managed_objects_only.py new file mode 100644 index 000000000000..c86d87f3d14e --- /dev/null +++ b/authentik/sources/scim/tests/test_managed_objects_only.py @@ -0,0 +1,173 @@ +"""Tests for SCIM source managed-objects-only mode.""" + +from json import dumps +from uuid import uuid4 + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group, User +from authentik.core.tests.utils import create_test_user +from authentik.lib.generators import generate_id +from authentik.sources.scim.models import SCIMSource, SCIMSourceGroup, SCIMSourceUser +from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE + + +class TestSCIMManagedObjectsOnly(APITestCase): + """Test SCIM source managed_objects_only behavior.""" + + def setUp(self) -> None: + self.source = SCIMSource.objects.create( + name=generate_id(), + slug=generate_id(), + managed_objects_only=True, + ) + + def test_user_create_conflict_existing_username(self): + """Cannot correlate to an existing user when managed_objects_only is enabled.""" + existing = create_test_user() + response = self.client.post( + reverse( + "authentik_sources_scim:v2-users", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps({"userName": existing.username, "name": {"formatted": "Test"}}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 409) + + def test_group_create_conflict_existing_name(self): + """Cannot correlate to an existing group when managed_objects_only is enabled.""" + admin_group = Group.objects.create(name="authentik Admins", is_superuser=True) + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps({"displayName": admin_group.name, "externalId": generate_id()}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 409) + self.assertFalse(SCIMSourceGroup.objects.filter(source=self.source, group=admin_group).exists()) + + def test_group_members_require_managed_users(self): + """Group membership is limited to users managed by this SCIM source.""" + unmanaged = create_test_user() + scim_user = create_test_user() + SCIMSourceUser.objects.create( + source=self.source, + user=scim_user, + external_id=str(uuid4()), + ) + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps( + { + "displayName": generate_id(), + "externalId": generate_id(), + "members": [{"value": str(unmanaged.uuid)}], + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 400) + + def test_escalation_to_admin_group_blocked(self): + """Full escalation path is blocked when managed_objects_only is enabled.""" + admin_group = Group.objects.create(name="authentik Admins", is_superuser=True) + attacker_username = generate_id() + create_response = self.client.post( + reverse( + "authentik_sources_scim:v2-users", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps( + { + "userName": attacker_username, + "name": {"formatted": "Attacker"}, + "active": True, + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(create_response.status_code, 201) + attacker_uuid = create_response.json()["id"] + + group_response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps( + { + "displayName": admin_group.name, + "externalId": generate_id(), + "members": [{"value": attacker_uuid}], + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(group_response.status_code, 409) + attacker = User.objects.get(uuid=attacker_uuid) + self.assertFalse(attacker.is_superuser) + self.assertEqual(admin_group.users.count(), 0) + + def test_delete_user_unlinks_only(self): + """DELETE removes the SCIM link but keeps the user when managed_objects_only is enabled.""" + username = generate_id() + create_response = self.client.post( + reverse( + "authentik_sources_scim:v2-users", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps({"userName": username, "name": {"formatted": "Test"}}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(create_response.status_code, 201) + user_id = create_response.json()["id"] + delete_response = self.client.delete( + reverse( + "authentik_sources_scim:v2-users", + kwargs={"source_slug": self.source.slug, "user_id": user_id}, + ), + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(delete_response.status_code, 204) + self.assertTrue(User.objects.filter(username=username).exists()) + self.assertFalse(SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).exists()) + + def test_delete_group_unlinks_only(self): + """DELETE removes the SCIM link but keeps the group when managed_objects_only is enabled.""" + name = generate_id() + create_response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug}, + ), + data=dumps({"displayName": name, "externalId": generate_id()}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(create_response.status_code, 201) + group_id = create_response.json()["id"] + delete_response = self.client.delete( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug, "group_id": group_id}, + ), + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(delete_response.status_code, 204) + self.assertTrue(Group.objects.filter(name=name).exists()) + self.assertFalse( + SCIMSourceGroup.objects.filter(source=self.source, group__group_uuid=group_id).exists() + ) diff --git a/authentik/sources/scim/views/v2/groups.py b/authentik/sources/scim/views/v2/groups.py index 401b11ec9be6..df55c31a8e74 100644 --- a/authentik/sources/scim/views/v2/groups.py +++ b/authentik/sources/scim/views/v2/groups.py @@ -16,6 +16,7 @@ from authentik.core.models import Group, User from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation from authentik.providers.scim.clients.schema import Group as SCIMGroupModel +from authentik.sources.scim.managed import filter_group_members, resolve_group from authentik.sources.scim.models import SCIMSourceGroup from authentik.sources.scim.patch.processor import SCIMPatchProcessor from authentik.sources.scim.views.v2.base import SCIMObjectView @@ -94,9 +95,7 @@ def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, appl if not properties.get("name"): raise ValidationError("Invalid group") - group = connection.group if connection else Group() - if _group := Group.objects.filter(name=properties.get("name")).first(): - group = _group + group = resolve_group(self.source, connection, properties.get("name")) group.update_attributes(properties) @@ -110,7 +109,7 @@ def update_group(self, connection: SCIMSourceGroup | None, data: QueryDict, appl continue query |= Q(uuid=member.value) if query: - group.users.set(User.objects.filter(query)) + group.users.set(filter_group_members(self.source, User.objects.filter(query))) data["members"] = self._convert_members(group) if not connection: connection, _ = SCIMSourceGroup.objects.update_or_create( @@ -178,7 +177,9 @@ def patch(self, request: Request, group_id: str, **kwargs) -> Response: for member in operation.value: query |= Q(uuid=member["value"]) if query: - connection.group.users.add(*User.objects.filter(query)) + connection.group.users.add( + *filter_group_members(self.source, User.objects.filter(query)) + ) elif operation.op == PatchOp.remove: if not isinstance(operation.value, list): operation.value = [operation.value] @@ -186,7 +187,9 @@ def patch(self, request: Request, group_id: str, **kwargs) -> Response: for member in operation.value: query |= Q(uuid=member["value"]) if query: - connection.group.users.remove(*User.objects.filter(query)) + connection.group.users.remove( + *filter_group_members(self.source, User.objects.filter(query)) + ) patcher = SCIMPatchProcessor() patched_data = patcher.apply_patches( connection.attributes, request.data.get("Operations", []) @@ -204,6 +207,9 @@ def delete(self, request: Request, group_id: str, **kwargs) -> Response: ).first() if not connection: raise SCIMNotFoundError("Group not found.") + if self.source.managed_objects_only: + connection.delete() + return Response(status=204) connection.group.delete() connection.delete() return Response(status=204) diff --git a/authentik/sources/scim/views/v2/users.py b/authentik/sources/scim/views/v2/users.py index c0858202c9ac..e151c54a005c 100644 --- a/authentik/sources/scim/views/v2/users.py +++ b/authentik/sources/scim/views/v2/users.py @@ -14,6 +14,7 @@ from authentik.core.models import User from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA, Email from authentik.providers.scim.clients.schema import User as SCIMUserModel +from authentik.sources.scim.managed import filter_group_members, resolve_group, resolve_user from authentik.sources.scim.models import SCIMSourceUser from authentik.sources.scim.patch.processor import SCIMPatchProcessor from authentik.sources.scim.views.v2.base import SCIMObjectView @@ -97,9 +98,7 @@ def update_user(self, connection: SCIMSourceUser | None, data: QueryDict): if not properties.get("username"): raise ValidationError("Invalid user") - user = connection.user if connection else User() - if _user := User.objects.filter(username=properties.get("username")).first(): - user = _user + user = resolve_user(self.source, connection, properties.get("username")) user.update_attributes(properties) if not connection: @@ -158,6 +157,9 @@ def delete(self, request: Request, user_id: str, **kwargs) -> Response: connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() if not connection: raise SCIMNotFoundError("User not found.") + if self.source.managed_objects_only: + connection.delete() + return Response(status=204) connection.user.delete() connection.delete() return Response(status=204) diff --git a/packages/client-ts/src/models/PatchedSCIMSourceRequest.ts b/packages/client-ts/src/models/PatchedSCIMSourceRequest.ts index a514d179134d..90cdab1dd6a4 100644 --- a/packages/client-ts/src/models/PatchedSCIMSourceRequest.ts +++ b/packages/client-ts/src/models/PatchedSCIMSourceRequest.ts @@ -54,6 +54,12 @@ export interface PatchedSCIMSourceRequest { * @memberof PatchedSCIMSourceRequest */ userPathTemplate?: string; + /** + * Only manage users and groups created by this SCIM source. + * @type {boolean} + * @memberof PatchedSCIMSourceRequest + */ + managedObjectsOnly?: boolean; } /** @@ -86,6 +92,8 @@ export function PatchedSCIMSourceRequestFromJSONTyped( json["group_property_mappings"] == null ? undefined : json["group_property_mappings"], userPathTemplate: json["user_path_template"] == null ? undefined : json["user_path_template"], + managedObjectsOnly: + json["managed_objects_only"] == null ? undefined : json["managed_objects_only"], }; } @@ -108,5 +116,6 @@ export function PatchedSCIMSourceRequestToJSONTyped( user_property_mappings: value["userPropertyMappings"], group_property_mappings: value["groupPropertyMappings"], user_path_template: value["userPathTemplate"], + managed_objects_only: value["managedObjectsOnly"], }; } diff --git a/packages/client-ts/src/models/SCIMSource.ts b/packages/client-ts/src/models/SCIMSource.ts index 610559dcfe95..7a442e96668f 100644 --- a/packages/client-ts/src/models/SCIMSource.ts +++ b/packages/client-ts/src/models/SCIMSource.ts @@ -93,6 +93,12 @@ export interface SCIMSource { * @memberof SCIMSource */ userPathTemplate?: string; + /** + * Only manage users and groups created by this SCIM source. + * @type {boolean} + * @memberof SCIMSource + */ + managedObjectsOnly?: boolean; /** * Get Root URL * @type {string} @@ -148,6 +154,8 @@ export function SCIMSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean) managed: json["managed"], userPathTemplate: json["user_path_template"] == null ? undefined : json["user_path_template"], + managedObjectsOnly: + json["managed_objects_only"] == null ? undefined : json["managed_objects_only"], rootUrl: json["root_url"], tokenObj: TokenFromJSON(json["token_obj"]), }; @@ -182,5 +190,6 @@ export function SCIMSourceToJSONTyped( user_property_mappings: value["userPropertyMappings"], group_property_mappings: value["groupPropertyMappings"], user_path_template: value["userPathTemplate"], + managed_objects_only: value["managedObjectsOnly"], }; } diff --git a/packages/client-ts/src/models/SCIMSourceRequest.ts b/packages/client-ts/src/models/SCIMSourceRequest.ts index 66e0aa7c8c45..107e9eafe3f5 100644 --- a/packages/client-ts/src/models/SCIMSourceRequest.ts +++ b/packages/client-ts/src/models/SCIMSourceRequest.ts @@ -54,6 +54,12 @@ export interface SCIMSourceRequest { * @memberof SCIMSourceRequest */ userPathTemplate?: string; + /** + * Only manage users and groups created by this SCIM source. + * @type {boolean} + * @memberof SCIMSourceRequest + */ + managedObjectsOnly?: boolean; } /** @@ -86,6 +92,8 @@ export function SCIMSourceRequestFromJSONTyped( json["group_property_mappings"] == null ? undefined : json["group_property_mappings"], userPathTemplate: json["user_path_template"] == null ? undefined : json["user_path_template"], + managedObjectsOnly: + json["managed_objects_only"] == null ? undefined : json["managed_objects_only"], }; } @@ -108,5 +116,6 @@ export function SCIMSourceRequestToJSONTyped( user_property_mappings: value["userPropertyMappings"], group_property_mappings: value["groupPropertyMappings"], user_path_template: value["userPathTemplate"], + managed_objects_only: value["managedObjectsOnly"], }; } diff --git a/schema.yml b/schema.yml index eacfc47c5895..d144e16c643f 100644 --- a/schema.yml +++ b/schema.yml @@ -51074,6 +51074,9 @@ components: user_path_template: type: string minLength: 1 + managed_objects_only: + type: boolean + description: Only manage users and groups created by this SCIM source. PatchedSCIMSourceUserRequest: type: object description: SCIMSourceUser Serializer @@ -55329,6 +55332,9 @@ components: readOnly: true user_path_template: type: string + managed_objects_only: + type: boolean + description: Only manage users and groups created by this SCIM source. root_url: type: string description: Get Root URL @@ -55492,6 +55498,9 @@ components: user_path_template: type: string minLength: 1 + managed_objects_only: + type: boolean + description: Only manage users and groups created by this SCIM source. required: - name - slug diff --git a/web/src/admin/sources/scim/SCIMSourceForm.ts b/web/src/admin/sources/scim/SCIMSourceForm.ts index 67de2b0b2a7f..8e27f4f31fb4 100644 --- a/web/src/admin/sources/scim/SCIMSourceForm.ts +++ b/web/src/admin/sources/scim/SCIMSourceForm.ts @@ -64,6 +64,16 @@ export class SCIMSourceForm extends BaseSourceForm { label=${msg("Enabled")} ?checked=${this.instance?.enabled ?? true} > + +

+ ${msg( + "When enabled, this SCIM source only manages users and groups it created. Tenant-wide correlation, unrestricted group membership, and destructive DELETE are disabled.", + )} +

diff --git a/website/docs/users-sources/sources/protocols/scim/index.md b/website/docs/users-sources/sources/protocols/scim/index.md index d2cb08c2d276..62ad611da147 100644 --- a/website/docs/users-sources/sources/protocols/scim/index.md +++ b/website/docs/users-sources/sources/protocols/scim/index.md @@ -24,6 +24,50 @@ Endpoint to list, create, update and delete groups. There are also `/v2/ServiceProviderConfig` and `/v2/ResourceTypes`, which are used by SCIM-enabled applications to find out which features authentik supports. +## Trust model and security + +The SCIM source Bearer token is a **high-trust provisioning credential**. Anyone who can use the token can manage users and groups through the SCIM endpoints for that source. Treat it like a secret with roughly the same sensitivity as an administrative provisioning integration, not like a narrowly scoped API key. + +By default, authentik applies the following behavior for inbound SCIM requests: + +### User and group correlation + +On create and update, authentik may **correlate** SCIM resources to **existing** directory objects in the same tenant: + +- **Users** are matched by `userName` across the entire tenant, not only users already linked to this SCIM source. +- **Groups** are matched by `displayName` (mapped to the group `name`) across the entire tenant, not only groups already linked to this SCIM source. + +If a matching object exists, the SCIM source adopts and updates that object. + +### Group membership + +Group `members` operations accept **any user UUID in the tenant**. authentik does not require that member users were originally created by, or are exclusively managed by, the same SCIM source. + +### Deprovisioning (DELETE) + +SCIM **DELETE** on a user or group removes the **underlying authentik `User` or `Group` object**, not only the SCIM source link. This applies to correlated objects as well as objects created through SCIM. + +### Default bootstrap layout + +Fresh installs create a superuser group named **`authentik Admins`** (`is_superuser=true`) and an initial admin user. Because group correlation is tenant-wide, a SCIM token holder can interact with that group if they use the same `displayName`, including changing membership or deleting the group after correlating to it. + +### Recommendations + +- Restrict network access to SCIM endpoints where possible. +- Rotate and revoke SCIM tokens when an IdP integration is decommissioned. +- Do not share SCIM tokens across untrusted systems. +- If you do not require tenant-wide correlation, enable **Managed objects only** on the SCIM source (see below). + +## Managed objects only + +SCIM sources support an optional **Managed objects only** setting (`managed_objects_only`). When enabled: + +- Users and groups are **not** correlated to existing tenant objects by name during create. +- Group membership changes are limited to users **managed by the same SCIM source**. +- SCIM **DELETE** removes only the SCIM source link; the underlying user or group is retained. + +This mode is recommended for integrations where the IdP should only manage objects it created through SCIM, or where tenant-wide correlation is not desired. + ## SCIM source property mappings See the [overview](../../property-mappings/index.md) for information on how property mappings work.