diff --git a/authentik/providers/scim/clients/groups.py b/authentik/providers/scim/clients/groups.py index 0a2f03caac..bd387ecdbb 100644 --- a/authentik/providers/scim/clients/groups.py +++ b/authentik/providers/scim/clients/groups.py @@ -5,7 +5,6 @@ from itertools import batched from django.db import transaction from pydantic import ValidationError from pydanticscim.group import GroupMember -from pydanticscim.responses import PatchOp from authentik.core.models import Group from authentik.lib.sync.mapper import PropertyMappingManager @@ -20,7 +19,12 @@ from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.exceptions import ( SCIMRequestException, ) -from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest +from authentik.providers.scim.clients.schema import ( + SCIM_GROUP_SCHEMA, + PatchOp, + PatchOperation, + PatchRequest, +) from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema from authentik.providers.scim.models import ( SCIMMapping, diff --git a/authentik/providers/scim/clients/schema.py b/authentik/providers/scim/clients/schema.py index 9f601cc1bc..b9131f2470 100644 --- a/authentik/providers/scim/clients/schema.py +++ b/authentik/providers/scim/clients/schema.py @@ -1,5 +1,7 @@ """Custom SCIM schemas""" +from enum import Enum + from pydantic import Field from pydanticscim.group import Group as BaseGroup from pydanticscim.responses import PatchOperation as BasePatchOperation @@ -65,6 +67,21 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration): ) +class PatchOp(str, Enum): + + replace = "replace" + remove = "remove" + add = "add" + + @classmethod + def _missing_(cls, value): + value = value.lower() + for member in cls: + if member.lower() == value: + return member + return None + + class PatchRequest(BasePatchRequest): """PatchRequest which correctly sets schemas""" @@ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest): class PatchOperation(BasePatchOperation): """PatchOperation with optional path""" + op: PatchOp path: str | None diff --git a/authentik/sources/scim/tests/test_groups.py b/authentik/sources/scim/tests/test_groups.py new file mode 100644 index 0000000000..df5683c0ca --- /dev/null +++ b/authentik/sources/scim/tests/test_groups.py @@ -0,0 +1,277 @@ +"""Test SCIM Group""" + +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 +from authentik.core.tests.utils import create_test_user +from authentik.events.models import Event, EventAction +from authentik.lib.generators import generate_id +from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema +from authentik.sources.scim.models import ( + SCIMSource, + SCIMSourceGroup, +) +from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE + + +class TestSCIMGroups(APITestCase): + """Test SCIM Group view""" + + def setUp(self) -> None: + self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id()) + + def test_group_list(self): + """Test full group list""" + response = self.client.get( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + }, + ), + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 200) + + def test_group_list_single(self): + """Test full group list (single group)""" + group = Group.objects.create(name=generate_id()) + user = create_test_user() + group.users.add(user) + SCIMSourceGroup.objects.create( + source=self.source, + group=group, + id=str(uuid4()), + ) + response = self.client.get( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + "group_id": str(group.pk), + }, + ), + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=200) + SCIMGroupSchema.model_validate_json(response.content, strict=True) + + def test_group_create(self): + """Test group create""" + ext_id = generate_id() + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + }, + ), + data=dumps({"displayName": generate_id(), "externalId": ext_id}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username + ).exists() + ) + + def test_group_create_members(self): + """Test group create""" + user = create_test_user() + ext_id = generate_id() + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + }, + ), + data=dumps( + { + "displayName": generate_id(), + "externalId": ext_id, + "members": [{"value": str(user.uuid)}], + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username + ).exists() + ) + + def test_group_create_members_empty(self): + """Test group create""" + ext_id = generate_id() + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + }, + ), + data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 201) + self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) + self.assertTrue( + Event.objects.filter( + action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username + ).exists() + ) + + def test_group_create_duplicate(self): + """Test group create (duplicate)""" + group = Group.objects.create(name=generate_id()) + existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) + ext_id = generate_id() + response = self.client.post( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + }, + ), + data=dumps( + {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)} + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 409) + self.assertJSONEqual( + response.content, + { + "detail": "Group with ID exists already.", + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "scimType": "uniqueness", + "status": 409, + }, + ) + + def test_group_update(self): + """Test group update""" + group = Group.objects.create(name=generate_id()) + existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) + ext_id = generate_id() + response = self.client.put( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug, "group_id": group.pk}, + ), + data=dumps( + {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)} + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=200) + + def test_group_update_non_existent(self): + """Test group update""" + ext_id = generate_id() + response = self.client.put( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={ + "source_slug": self.source.slug, + "group_id": str(uuid4()), + }, + ), + data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=404) + self.assertJSONEqual( + response.content, + { + "detail": "Group not found.", + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": 404, + }, + ) + + def test_group_patch_add(self): + """Test group patch""" + user = create_test_user() + + group = Group.objects.create(name=generate_id()) + SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) + response = self.client.patch( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug, "group_id": group.pk}, + ), + data=dumps( + { + "Operations": [ + { + "op": "Add", + "path": "members", + "value": {"value": str(user.uuid)}, + } + ] + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=200) + self.assertTrue(group.users.filter(pk=user.pk).exists()) + + def test_group_patch_remove(self): + """Test group patch""" + user = create_test_user() + + group = Group.objects.create(name=generate_id()) + group.users.add(user) + SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) + response = self.client.patch( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug, "group_id": group.pk}, + ), + data=dumps( + { + "Operations": [ + { + "op": "remove", + "path": "members", + "value": {"value": str(user.uuid)}, + } + ] + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=200) + self.assertFalse(group.users.filter(pk=user.pk).exists()) + + def test_group_delete(self): + """Test group delete""" + group = Group.objects.create(name=generate_id()) + SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) + response = self.client.delete( + reverse( + "authentik_sources_scim:v2-groups", + kwargs={"source_slug": self.source.slug, "group_id": group.pk}, + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, second=204) diff --git a/authentik/sources/scim/tests/test_users.py b/authentik/sources/scim/tests/test_users.py index fe025a97f0..a862035d30 100644 --- a/authentik/sources/scim/tests/test_users.py +++ b/authentik/sources/scim/tests/test_users.py @@ -177,3 +177,51 @@ class TestSCIMUsers(APITestCase): SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], "0123456789", ) + + def test_user_update(self): + """Test user update""" + user = create_test_user() + existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) + ext_id = generate_id() + response = self.client.put( + reverse( + "authentik_sources_scim:v2-users", + kwargs={ + "source_slug": self.source.slug, + "user_id": str(user.uuid), + }, + ), + data=dumps( + { + "id": str(existing.pk), + "userName": generate_id(), + "externalId": ext_id, + "emails": [ + { + "primary": True, + "value": user.email, + } + ], + } + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 200) + + def test_user_delete(self): + """Test user delete""" + user = create_test_user() + SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) + response = self.client.delete( + reverse( + "authentik_sources_scim:v2-users", + kwargs={ + "source_slug": self.source.slug, + "user_id": str(user.uuid), + }, + ), + content_type=SCIM_CONTENT_TYPE, + HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", + ) + self.assertEqual(response.status_code, 204) diff --git a/authentik/sources/scim/views/v2/auth.py b/authentik/sources/scim/views/v2/auth.py index 26394ffde4..85eae42fa8 100644 --- a/authentik/sources/scim/views/v2/auth.py +++ b/authentik/sources/scim/views/v2/auth.py @@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ from rest_framework.request import Request from rest_framework.views import APIView +from authentik.core.middleware import CTX_AUTH_VIA from authentik.core.models import Token, TokenIntents, User from authentik.sources.scim.models import SCIMSource @@ -26,6 +27,7 @@ class SCIMTokenAuth(BaseAuthentication): _username, _, password = b64decode(key.encode()).decode().partition(":") token = self.check_token(password, source_slug) if token: + CTX_AUTH_VIA.set("scim_basic") return (token.user, token) return None @@ -52,4 +54,5 @@ class SCIMTokenAuth(BaseAuthentication): token = self.check_token(key, source_slug) if not token: return None + CTX_AUTH_VIA.set("scim_token") return (token.user, token) diff --git a/authentik/sources/scim/views/v2/base.py b/authentik/sources/scim/views/v2/base.py index bcb4a8eed5..9f17f78a6f 100644 --- a/authentik/sources/scim/views/v2/base.py +++ b/authentik/sources/scim/views/v2/base.py @@ -1,13 +1,11 @@ """SCIM Utils""" from typing import Any -from urllib.parse import urlparse from django.conf import settings from django.core.paginator import Page, Paginator from django.db.models import Q, QuerySet from django.http import HttpRequest -from django.urls import resolve from rest_framework.parsers import JSONParser from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import JSONRenderer @@ -46,7 +44,7 @@ class SCIMView(APIView): logger: BoundLogger permission_classes = [IsAuthenticated] - parser_classes = [SCIMParser] + parser_classes = [SCIMParser, JSONParser] renderer_classes = [SCIMRenderer] def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: @@ -56,28 +54,6 @@ class SCIMView(APIView): def get_authenticators(self): return [SCIMTokenAuth(self)] - def patch_resolve_value(self, raw_value: dict) -> User | Group | None: - """Attempt to resolve a raw `value` attribute of a patch operation into - a database model""" - model = User - query = {} - if "$ref" in raw_value: - url = urlparse(raw_value["$ref"]) - if match := resolve(url.path): - if match.url_name == "v2-users": - model = User - query = {"pk": int(match.kwargs["user_id"])} - elif "type" in raw_value: - match raw_value["type"]: - case "User": - model = User - query = {"pk": int(raw_value["value"])} - case "Group": - model = Group - else: - return None - return model.objects.filter(**query).first() - def filter_parse(self, request: Request): """Parse the path of a Patch Operation""" path = request.query_params.get("filter") diff --git a/authentik/sources/scim/views/v2/exceptions.py b/authentik/sources/scim/views/v2/exceptions.py new file mode 100644 index 0000000000..ffaf0638d6 --- /dev/null +++ b/authentik/sources/scim/views/v2/exceptions.py @@ -0,0 +1,58 @@ +from enum import Enum + +from pydanticscim.responses import SCIMError as BaseSCIMError +from rest_framework.exceptions import ValidationError + + +class SCIMErrorTypes(Enum): + invalid_filter = "invalidFilter" + too_many = "tooMany" + uniqueness = "uniqueness" + mutability = "mutability" + invalid_syntax = "invalidSyntax" + invalid_path = "invalidPath" + no_target = "noTarget" + invalid_value = "invalidValue" + invalid_vers = "invalidVers" + sensitive = "sensitive" + + +class SCIMError(BaseSCIMError): + scimType: SCIMErrorTypes | None = None + detail: str | None = None + + +class SCIMValidationError(ValidationError): + status_code = 400 + default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400) + + def __init__(self, detail: SCIMError | None): + if detail is None: + detail = self.default_detail + detail.status = self.status_code + self.detail = detail.model_dump(mode="json", exclude_none=True) + + +class SCIMConflictError(SCIMValidationError): + status_code = 409 + + def __init__(self, detail: str): + super().__init__( + SCIMError( + detail=detail, + scimType=SCIMErrorTypes.uniqueness, + status=self.status_code, + ) + ) + + +class SCIMNotFoundError(SCIMValidationError): + status_code = 404 + + def __init__(self, detail: str): + super().__init__( + SCIMError( + detail=detail, + status=self.status_code, + ) + ) diff --git a/authentik/sources/scim/views/v2/groups.py b/authentik/sources/scim/views/v2/groups.py index dc3f4c1763..15b443286e 100644 --- a/authentik/sources/scim/views/v2/groups.py +++ b/authentik/sources/scim/views/v2/groups.py @@ -4,19 +4,25 @@ from uuid import uuid4 from django.db.models import Q from django.db.transaction import atomic -from django.http import Http404, QueryDict +from django.http import QueryDict from django.urls import reverse from pydantic import ValidationError as PydanticValidationError from pydanticscim.group import GroupMember from rest_framework.exceptions import ValidationError from rest_framework.request import Request from rest_framework.response import Response +from scim2_filter_parser.attr_paths import AttrPath from authentik.core.models import Group, User -from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA +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.models import SCIMSourceGroup from authentik.sources.scim.views.v2.base import SCIMObjectView +from authentik.sources.scim.views.v2.exceptions import ( + SCIMConflictError, + SCIMNotFoundError, + SCIMValidationError, +) class GroupsView(SCIMObjectView): @@ -27,7 +33,7 @@ class GroupsView(SCIMObjectView): def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: """Convert Group to SCIM data""" payload = SCIMGroupModel( - schemas=[SCIM_USER_SCHEMA], + schemas=[SCIM_GROUP_SCHEMA], id=str(scim_group.group.pk), externalId=scim_group.id, displayName=scim_group.group.name, @@ -58,7 +64,7 @@ class GroupsView(SCIMObjectView): if group_id: connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() if not connection: - raise Http404 + raise SCIMNotFoundError("Group not found.") return Response(self.group_to_scim(connection)) connections = ( base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) @@ -119,7 +125,7 @@ class GroupsView(SCIMObjectView): ).first() if connection: self.logger.debug("Found existing group") - return Response(status=409) + raise SCIMConflictError("Group with ID exists already.") connection = self.update_group(None, request.data) return Response(self.group_to_scim(connection), status=201) @@ -129,10 +135,44 @@ class GroupsView(SCIMObjectView): source=self.source, group__group_uuid=group_id ).first() if not connection: - raise Http404 + raise SCIMNotFoundError("Group not found.") connection = self.update_group(connection, request.data) return Response(self.group_to_scim(connection), status=200) + @atomic + def patch(self, request: Request, group_id: str, **kwargs) -> Response: + """Patch group handler""" + connection = SCIMSourceGroup.objects.filter( + source=self.source, group__group_uuid=group_id + ).first() + if not connection: + raise SCIMNotFoundError("Group not found.") + + for _op in request.data.get("Operations", []): + operation = PatchOperation.model_validate(_op) + if operation.op.lower() not in ["add", "remove", "replace"]: + raise SCIMValidationError() + attr_path = AttrPath(f'{operation.path} eq ""', {}) + if attr_path.first_path == ("members", None, None): + # FIXME: this can probably be de-duplicated + if operation.op == PatchOp.add: + if not isinstance(operation.value, list): + operation.value = [operation.value] + query = Q() + for member in operation.value: + query |= Q(uuid=member["value"]) + if query: + connection.group.users.add(*User.objects.filter(query)) + elif operation.op == PatchOp.remove: + if not isinstance(operation.value, list): + operation.value = [operation.value] + query = Q() + for member in operation.value: + query |= Q(uuid=member["value"]) + if query: + connection.group.users.remove(*User.objects.filter(query)) + return Response(self.group_to_scim(connection), status=200) + @atomic def delete(self, request: Request, group_id: str, **kwargs) -> Response: """Delete group handler""" @@ -140,7 +180,7 @@ class GroupsView(SCIMObjectView): source=self.source, group__group_uuid=group_id ).first() if not connection: - raise Http404 + raise SCIMNotFoundError("Group not found.") connection.group.delete() connection.delete() return Response(status=204) diff --git a/authentik/sources/scim/views/v2/resource_types.py b/authentik/sources/scim/views/v2/resource_types.py index cecd5d225f..4d50c45702 100644 --- a/authentik/sources/scim/views/v2/resource_types.py +++ b/authentik/sources/scim/views/v2/resource_types.py @@ -1,11 +1,11 @@ """SCIM Meta views""" -from django.http import Http404 from django.urls import reverse from rest_framework.request import Request from rest_framework.response import Response from authentik.sources.scim.views.v2.base import SCIMView +from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError class ResourceTypesView(SCIMView): @@ -138,7 +138,7 @@ class ResourceTypesView(SCIMView): resource = [x for x in resource_types if x.get("id") == resource_type] if resource: return Response(resource[0]) - raise Http404 + raise SCIMNotFoundError("Resource not found.") return Response( { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], diff --git a/authentik/sources/scim/views/v2/schemas.py b/authentik/sources/scim/views/v2/schemas.py index 14fe9fdde7..d4af6068de 100644 --- a/authentik/sources/scim/views/v2/schemas.py +++ b/authentik/sources/scim/views/v2/schemas.py @@ -3,12 +3,12 @@ from json import loads from django.conf import settings -from django.http import Http404 from django.urls import reverse from rest_framework.request import Request from rest_framework.response import Response from authentik.sources.scim.views.v2.base import SCIMView +from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError with open( settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", @@ -44,7 +44,7 @@ class SchemaView(SCIMView): schema = [x for x in schemas if x.get("id") == schema_uri] if schema: return Response(schema[0]) - raise Http404 + raise SCIMNotFoundError("Schema not found.") return Response( { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], diff --git a/authentik/sources/scim/views/v2/service_provider_config.py b/authentik/sources/scim/views/v2/service_provider_config.py index 19c329177d..d3dfd623f6 100644 --- a/authentik/sources/scim/views/v2/service_provider_config.py +++ b/authentik/sources/scim/views/v2/service_provider_config.py @@ -33,6 +33,8 @@ class ServiceProviderConfigView(SCIMView): { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], "authenticationSchemes": auth_schemas, + # We only support patch for groups currently, so don't broadly advertise it. + # Implementations that require Group patch will use it regardless of this flag. "patch": {"supported": False}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, "filter": { diff --git a/authentik/sources/scim/views/v2/users.py b/authentik/sources/scim/views/v2/users.py index 93761a3ac5..d29bc611bb 100644 --- a/authentik/sources/scim/views/v2/users.py +++ b/authentik/sources/scim/views/v2/users.py @@ -4,7 +4,7 @@ from uuid import uuid4 from django.db.models import Q from django.db.transaction import atomic -from django.http import Http404, QueryDict +from django.http import QueryDict from django.urls import reverse from pydanticscim.user import Email, EmailKind, Name from rest_framework.exceptions import ValidationError @@ -16,6 +16,7 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA from authentik.providers.scim.clients.schema import User as SCIMUserModel from authentik.sources.scim.models import SCIMSourceUser from authentik.sources.scim.views.v2.base import SCIMObjectView +from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError class UsersView(SCIMObjectView): @@ -69,7 +70,7 @@ class UsersView(SCIMObjectView): .first() ) if not connection: - raise Http404 + raise SCIMNotFoundError("User not found.") return Response(self.user_to_scim(connection)) connections = ( SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") @@ -122,7 +123,7 @@ class UsersView(SCIMObjectView): ).first() if connection: self.logger.debug("Found existing user") - return Response(status=409) + raise SCIMConflictError("Group with ID exists already.") connection = self.update_user(None, request.data) return Response(self.user_to_scim(connection), status=201) @@ -130,7 +131,7 @@ class UsersView(SCIMObjectView): """Update user handler""" connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() if not connection: - raise Http404 + raise SCIMNotFoundError("User not found.") self.update_user(connection, request.data) return Response(self.user_to_scim(connection), status=200) @@ -139,7 +140,7 @@ class UsersView(SCIMObjectView): """Delete user handler""" connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() if not connection: - raise Http404 + raise SCIMNotFoundError("User not found.") connection.user.delete() connection.delete() return Response(status=204) diff --git a/lifecycle/migrate.py b/lifecycle/migrate.py index 6b77edde90..b44de08ab6 100755 --- a/lifecycle/migrate.py +++ b/lifecycle/migrate.py @@ -119,7 +119,7 @@ def run_migrations(): check_args = ["", "check"] for label in django_db_config(CONFIG).keys(): check_args.append(f"--database={label}") - if not CONFIG.get_bool("DEBUG"): + if not CONFIG.get_bool("debug"): check_args.append("--deploy") execute_from_command_line(check_args) finally: diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index 3ac0177aaf..6658b63fe5 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -133,7 +133,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa async apiEndpoint(): Promise> { const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({ ...(await this.defaultEndpointConfig()), - pathStartswith: getURLParam("path", ""), + pathStartswith: this.activePath, isActive: this.hideDeactivated ? true : undefined, includeGroups: false, });