8000 fix: apply SCIM patches atomically by eddiethedean · Pull Request #80 · dmux/minilake · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions src/minilake/services/scim.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import logging
import re
import uuid
from copy import deepcopy
from typing import Any, Dict, List, Optional, Tuple

from fastapi import APIRouter
Expand Down Expand Up @@ -229,13 +230,14 @@ def _list(


def _apply_patch(stored: Dict[str, Any], req: PatchRequest, resource: str) -> Dict[str, Any]:
"""Apply a SCIM PATCH body.
"""Build a validated PATCH result without mutating the stored resource.

Supports the operations clients actually send: `replace` on a named attribute or
on a whole object, and `add`/`remove` on a multi-valued attribute (group members,
entitlements, roles) — including the `members[value eq "id"]` path form used to
remove a single member.
"""
candidate = deepcopy(stored)
operations = req.operations or []
if not operations:
raise DatabricksError(
Expand All @@ -261,11 +263,11 @@ def _apply_patch(stored: Dict[str, Any], req: PatchRequest, resource: str) -> Di
if path_filter:
attr = path_filter.group("attr")
key, wanted = path_filter.group("key"), path_filter.group("value")
current = list(stored.get(attr) or [])
current = list(candidate.get(attr) or [])
if op == "remove":
stored[attr] = [e for e in current if str(e.get(key)) != wanted]
candidate[attr] = [e for e in current if str(e.get(key)) != wanted]
else:
stored[attr] = current + _as_list(value)
candidate[attr] = current + _as_list(value)
continue

if path is None:
Expand All @@ -278,20 +280,27 @@ def _apply_patch(stored: Dict[str, Any], req: PatchRequest, resource: str) -> Di
)
if op == "remove":
for key in value:
stored.pop(key, None)
candidate.pop(key, None)
else:
stored.update(value)
candidate.update(value)
continue

if op == "remove":
stored.pop(path, None)
elif op == "add" and isinstance(stored.get(path), list):
stored[path] = list(stored[path]) + _as_list(value)
candidate.pop(path, None)
elif op == "add" and isinstance(candidate.get(path), list):
candidate[path] = list(candidate[path]) + _as_list(value)
else:
stored[path] = value
candidate[path] = value

_unique_check(resource, stored.get(_config(resource)[2]), exclude_id=stored.get("id"))
return _prune_empty(stored)
_unique_check(resource, candidate.get(_config(resource)[2]), exclude_id=stored.get("id"))
return _prune_empty(candidate)


def _patch_resource(resource: str, resource_id: str, req: PatchRequest) -> Dict[str, Any]:
"""Commit a PATCH result only after all operations and uniqueness checks pass."""
candidate = _apply_patch(_get_or_404(resource, resource_id), req, resource)
_state[resource][resource_id] = candidate
return candidate


def _as_list(value: Any) -> List[Any]:
Expand Down Expand Up @@ -341,7 +350,7 @@ async def update_user(user_id: str, user: User) -> Dict[str, Any]:
@router.patch("/Users/{user_id}", response_model=None)
async def patch_user(user_id: str, req: PatchRequest) -> Dict[str, Any]:
"""Patch a user."""
return _apply_patch(_get_or_404("Users", user_id), req, "Users")
return _patch_resource("Users", user_id, req)


@router.delete("/Users/{user_id}")
Expand Down Expand Up @@ -391,7 +400,7 @@ async def update_group(group_id: str, group: Group) -> Dict[str, Any]:
@router.patch("/Groups/{group_id}", response_model=None)
async def patch_group(group_id: str, req: PatchRequest) -> Dict[str, Any]:
"""Patch a group — this is how members are added and removed."""
return _apply_patch(_get_or_404("Groups", group_id), req, "Groups")
return _patch_resource("Groups", group_id, req)


@router.delete("/Groups/{group_id}")
Expand Down Expand Up @@ -450,7 +459,7 @@ async def update_service_principal(sp_id: str, sp: ServicePrincipal) -> Dict[str
@router.patch("/ServicePrincipals/{sp_id}", response_model=None)
async def patch_service_principal(sp_id: str, req: PatchRequest) -> Dict[str, Any]:
"""Patch a service principal."""
return _apply_patch(_get_or_404("ServicePrincipals", sp_id), req, "ServicePrincipals")
return _patch_resource("ServicePrincipals", sp_id, req)


@router.delete("/ServicePrincipals/{sp_id}")
Expand Down
91 changes: 91 additions & 0 deletions tests/test_scim.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@
from databricks.sdk.errors import DatabricksError
from databricks.sdk.service.iam import ComplexValue, Patch, PatchOp

_PATCH_ATOMICITY_CASES = [
(
"users",
"user_name",
"userName",
"display_name",
"displayName",
"alice@example.com",
"bob@example.com",
),
("groups", "display_name", "displayName", "external_id", "externalId", "engineering", "product"),
(
"service_principals",
"application_id",
"applicationId",
"display_name",
"displayName",
"11111111-2222-3333-4444-555555555555",
"66666666-7777-8888-9999-000000000000",
),
]


def _create_patch_test_resource(client, unique_attr, unique_value, stable_attr):
return client.create(**{unique_attr: unique_value, stable_attr: "Original metadata"})


# --------------------------------------------------------------------------- users


Expand Down Expand Up @@ -90,6 +117,70 @@ def test_duplicate_user_name_rejected(workspace_client: WorkspaceClient):
assert "already exists" in str(exc.value).lower()


@pytest.mark.error
@pytest.mark.parametrize(
"resource_name,unique_attr,unique_path,stable_attr,stable_path,first_value,target_value",
_PATCH_ATOMICITY_CASES,
)
def test_duplicate_scim_patch_preserves_resource(
workspace_client: WorkspaceClient,
resource_name: str,
unique_attr: str,
unique_path: str,
stable_attr: str,
stable_path: str,
first_value: str,
target_value: str,
):
resource = getattr(workspace_client, resource_name)
resource.create(**{unique_attr: first_value, stable_attr: "Existing resource"})
target = _create_patch_test_resource(resource, unique_attr, target_value, stable_attr)

with pytest.raises(DatabricksError):
resource.patch(
id=target.id,
operations=[Patch(op=PatchOp.REPLACE, path=unique_path, value=first_value)],
)

fetched = resource.get(id=target.id)
assert getattr(fetched, unique_attr) == target_value
assert getattr(fetched, stable_attr) == "Original metadata"
assert len(list(resource.list(filter=f'{unique_path} eq "{first_value}"'))) == 1


@pytest.mark.error
@pytest.mark.parametrize(
"resource_name,unique_attr,unique_path,stable_attr,stable_path,first_value,target_value",
_PATCH_ATOMICITY_CASES,
)
def test_rejected_multi_operation_scim_patch_does_not_commit_prior_operations(
workspace_client: WorkspaceClient,
resource_name: str,
unique_attr: str,
unique_path: str,
stable_attr: str,
stable_path: str,
first_value: str,
target_value: str,
):
resource = getattr(workspace_client, resource_name)
resource.create(**{unique_attr: first_value, stable_attr: "Existing resource"})
target = _create_patch_test_resource(resource, unique_attr, target_value, stable_attr)

with pytest.raises(DatabricksError):
resource.patch(
id=target.id,
operations=[
Patch(op=PatchOp.REPLACE, path=stable_path, value="Must not be saved"),
Patch(op=PatchOp.REPLACE, path=unique_path, value=first_value),
],
)

fetched = resource.get(id=target.id)
assert getattr(fetched, unique_attr) == target_value
assert getattr(fetched, stable_attr) == "Original metadata"


@pytest.mark.error
def test_unsupported_filter_is_rejected(workspace_client: WorkspaceClient):
"""An unsupported filter fails loudly rather than quietly returning everything."""
Expand Down
Loading
0