From e422876ce17bef0a397f5651ce0da0cab01dc41f Mon Sep 17 00:00:00 2001 From: Marius Hein Date: Tue, 19 May 2026 13:01:34 +0200 Subject: [PATCH 1/2] fix(scim): make POST /Users and /Groups idempotent on external_id When Authentik's SCIM provider is recreated, it loses its remote-id mappings and re-POSTs every user/group on the next sync. Without idempotency, every POST hits the `external_id` UNIQUE constraint and fails with 409, blocking re-provisioning. POST now looks up by `external_id` first; if a resource already exists, update it from the payload, return 200 + Location instead of 201. This lets Authentik adopt the existing struudel resource and store the current struudel id for future PUT/PATCH calls. Also log constraint name and Postgres detail (e.g. which key collided) on any remaining SCIM 409, so future unique-constraint violations are diagnosable from the app log without psql access. --- src/struudel/blueprints/scim/routes.py | 91 +++++++++++++++++++++----- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/src/struudel/blueprints/scim/routes.py b/src/struudel/blueprints/scim/routes.py index 2c5a12b..55d2255 100644 --- a/src/struudel/blueprints/scim/routes.py +++ b/src/struudel/blueprints/scim/routes.py @@ -31,7 +31,18 @@ def _handle_scim_error(e: ScimError) -> Response: @bp.errorhandler(IntegrityError) -def _handle_integrity_error(_e: IntegrityError) -> Response: +def _handle_integrity_error(e: IntegrityError) -> Response: + diag = getattr(getattr(e, "orig", None), "diag", None) + constraint = getattr(diag, "constraint_name", None) + detail = getattr(diag, "message_detail", None) + log.warning( + "SCIM 409 %s %s constraint=%r detail=%r body=%r", + request.method, + request.path, + constraint, + detail, + request.get_data(as_text=True)[:2000], + ) return scim_error(409, "Conflict on unique attribute", "uniqueness") @@ -106,19 +117,41 @@ def users_create() -> Response: external_id = fields["external_id"] or fields["preferred_username"] with SessionLocal() as db: - user = user_service.create_user( - db, - external_id=external_id, - preferred_username=fields["preferred_username"], - name=fields["name"], - given_name=fields["given_name"], - family_name=fields["family_name"], - email=fields["email"], - active=fields["active"], - ) + existing = user_service.get_user_by_external_id(db, external_id=external_id) + if existing is not None: + user = user_service.update_user( + db, + user_id=existing.id, + preferred_username=fields["preferred_username"], + name=fields["name"], + given_name=fields["given_name"], + family_name=fields["family_name"], + email=fields["email"], + active=fields["active"], + external_id=external_id, + ) + assert user is not None + log.info( + "SCIM POST /Users adopted existing user id=%s external_id=%s", + user.id, + external_id, + ) + status = 200 + else: + user = user_service.create_user( + db, + external_id=external_id, + preferred_username=fields["preferred_username"], + name=fields["name"], + given_name=fields["given_name"], + family_name=fields["family_name"], + email=fields["email"], + active=fields["active"], + ) + status = 201 body = scim_service.user_to_scim(user) - response = scim_response(body, 201) + response = scim_response(body, status) response.headers["Location"] = body["meta"]["location"] return response @@ -226,16 +259,38 @@ def groups_create() -> Response: fields = scim_service.parse_group_payload(payload) with SessionLocal() as db: - group = group_service.create_group( - db, - display_name=fields["display_name"], - external_id=fields["external_id"], - member_user_ids=fields["member_user_ids"], + existing = ( + group_service.get_group_by_external_id(db, external_id=fields["external_id"]) + if fields["external_id"] + else None ) + if existing is not None: + group = group_service.update_group( + db, + group_id=existing.id, + display_name=fields["display_name"], + external_id=fields["external_id"], + member_user_ids=fields["member_user_ids"], + ) + assert group is not None + log.info( + "SCIM POST /Groups adopted existing group id=%s external_id=%s", + group.id, + fields["external_id"], + ) + status = 200 + else: + group = group_service.create_group( + db, + display_name=fields["display_name"], + external_id=fields["external_id"], + member_user_ids=fields["member_user_ids"], + ) + status = 201 group_service.sync_superusers_from_group(db) body = scim_service.group_to_scim(group) - response = scim_response(body, 201) + response = scim_response(body, status) response.headers["Location"] = body["meta"]["location"] return response From 3cae8b348d7bcba069518a6bc72d988b9dac76cc Mon Sep 17 00:00:00 2001 From: Marius Hein Date: Tue, 19 May 2026 13:10:14 +0200 Subject: [PATCH 2/2] fix(scim): accept Authentik PATCH /Groups without PatchOp schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentik's outgoing SCIM PATCH on a group sometimes omits the top-level `schemas: [PatchOp]` array (RFC 7644 ยง3.5.2 requires it) and puts the Group schema inside the operation's `value` instead. Additionally, the `value` dict carries SCIM-meta keys like `id`, `schemas` and `meta` that have no direct mapping to a column. Drop the top-level PatchOp schema requirement (Operations being a list is enough to identify the request), and silently skip the meta keys in group replace operations. Matches the existing Authentik-lenience pattern from fbcbe02 (members remove without value-eq filter). --- src/struudel/services/scim.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/struudel/services/scim.py b/src/struudel/services/scim.py index 9150317..74b19a4 100644 --- a/src/struudel/services/scim.py +++ b/src/struudel/services/scim.py @@ -184,9 +184,6 @@ def parse_group_payload(payload: dict[str, Any]) -> dict[str, Any]: def parse_patch_ops(payload: dict[str, Any]) -> list[dict[str, Any]]: if not isinstance(payload, dict): raise ScimError(400, "Request body must be a JSON object", "invalidSyntax") - schemas = payload.get("schemas") or [] - if PATCH_OP_SCHEMA not in schemas: - raise ScimError(400, "Missing PatchOp schema", "invalidSyntax") ops = payload.get("Operations") if not isinstance(ops, list): @@ -285,6 +282,8 @@ def _apply_group_replace(actions: dict[str, Any], path: str, value: Any) -> None actions["external_id"] = value elif key == "members": actions["replace_members"] = _parse_member_ids(value or []) + elif key in {"id", "schemas", "meta"}: + return else: raise ScimError(400, f"Unsupported path: {path!r}", "invalidPath")