fix(scim): treat NULL blocked column as unblocked when deprovisioning

The Prisma schema declares LiteLLM_VerificationToken.blocked as a
nullable Boolean with no default, so virtual keys created via the
key management endpoint persist with blocked=NULL. SQL equality
(`blocked = false`) never matches NULL rows, so the previous
`where={'blocked': not blocked}` filter silently skipped virtually
all real keys when SCIM tried to block them. This made SCIM
deprovisioning a no-op — and especially dangerous in DELETE flows
where the user row is removed afterwards, leaving orphaned but
fully-functional keys.

Match both `False` and `None` when blocking, and only `True`
when unblocking, so the state flip (and cache invalidation) actually
fires for the keys it should.
This commit is contained in:
Cursor Agent 2026-04-30 05:45:09 +00:00
parent 236e896189
commit 9aef4ee695
No known key found for this signature in database
36 changed files with 25 additions and 6 deletions

View file

@ -351,15 +351,25 @@ async def _set_user_keys_blocked(user_id: str, blocked: bool) -> int:
prisma_client = await _get_prisma_client_or_raise_exception()
# Only flip keys whose current state differs — avoids touching keys that
# were already (un)blocked manually by an admin.
# were already (un)blocked manually by an admin. `blocked` is a nullable
# column with no default, so existing keys typically have `blocked=NULL`;
# we must treat NULL as "not blocked" so SQL equality on NULL doesn't
# silently skip them.
if blocked:
state_filter: Dict[str, Any] = {"OR": [{"blocked": False}, {"blocked": None}]}
else:
state_filter = {"blocked": True}
where_clause: Dict[str, Any] = {"user_id": user_id, **state_filter}
affected_keys = await prisma_client.db.litellm_verificationtoken.find_many(
where={"user_id": user_id, "blocked": not blocked},
where=where_clause,
)
if not affected_keys:
return 0
await prisma_client.db.litellm_verificationtoken.update_many(
where={"user_id": user_id, "blocked": not blocked},
where=where_clause,
data={"blocked": blocked},
)

View file

@ -75,7 +75,10 @@ async def test_set_user_keys_blocked_flips_state_and_invalidates_cache():
assert flipped == 2
mock_db.litellm_verificationtoken.update_many.assert_awaited_once_with(
where={"user_id": "user-x", "blocked": False},
where={
"user_id": "user-x",
"OR": [{"blocked": False}, {"blocked": None}],
},
data={"blocked": True},
)
assert sorted(cache_deletions) == ["hash-1", "hash-2"]
@ -129,7 +132,10 @@ async def test_scim_delete_user_blocks_keys_before_deleting_user():
assert response.status_code == 204
mock_db.litellm_verificationtoken.update_many.assert_awaited_once_with(
where={"user_id": user_id, "blocked": False},
where={
"user_id": user_id,
"OR": [{"blocked": False}, {"blocked": None}],
},
data={"blocked": True},
)
mock_db.litellm_usertable.delete.assert_awaited_once_with(
@ -187,7 +193,10 @@ async def test_scim_patch_user_active_false_blocks_keys():
await patch_user(user_id=user_id, patch_ops=patch_ops)
mock_db.litellm_verificationtoken.update_many.assert_awaited_once_with(
where={"user_id": user_id, "blocked": False},
where={
"user_id": user_id,
"OR": [{"blocked": False}, {"blocked": None}],
},
data={"blocked": True},
)