Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drives the third party SCIM client libraries against a Stalwart service provider."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
from scim2_client.engines.httpx import SyncSCIMClient
|
||||
from scim2_models import (
|
||||
Context,
|
||||
Error,
|
||||
ListResponse,
|
||||
PatchOp,
|
||||
PatchOperation,
|
||||
SearchRequest,
|
||||
ServiceProviderConfig,
|
||||
)
|
||||
from scim2_tester import check_server
|
||||
|
||||
|
||||
def allow_post_rfc7643_service_provider_config_attributes():
|
||||
"""RFC 9865 adds a top level `pagination` attribute to ServiceProviderConfig and
|
||||
draft-zollner-scim-interop-profile adds `interopProfileConformant`. scim2-models
|
||||
validates against RFC 7643 alone and forbids both."""
|
||||
ServiceProviderConfig.model_config["extra"] = "ignore"
|
||||
ServiceProviderConfig.model_rebuild(force=True)
|
||||
|
||||
|
||||
def build_client(url, token):
|
||||
return SyncSCIMClient(
|
||||
httpx.Client(
|
||||
base_url=url,
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
verify=False,
|
||||
timeout=120.0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def conformance(client, _domain):
|
||||
results = check_server(client, raise_exceptions=False)
|
||||
return {
|
||||
"checks": [
|
||||
{
|
||||
"status": result.status.name,
|
||||
"title": result.title,
|
||||
"reason": result.reason,
|
||||
"tags": sorted(result.tags) if result.tags else [],
|
||||
"resource_type": result.resource_type,
|
||||
}
|
||||
for result in results
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def lifecycle(client, domain):
|
||||
steps = []
|
||||
|
||||
def record(name, detail=None):
|
||||
steps.append({"step": name, "ok": True, "detail": detail})
|
||||
|
||||
user_model = client.get_resource_model("User")
|
||||
group_model = client.get_resource_model("Group")
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
user_name = "lifecycle-{}@{}".format(tag, domain)
|
||||
renamed = "lifecycle-{}-renamed@{}".format(tag, domain)
|
||||
alias = "lifecycle-{}-alias@{}".format(tag, domain)
|
||||
display_name = "Lifecycle Person {}".format(tag)
|
||||
group_name = "Lifecycle Team {}".format(tag)
|
||||
user_id = None
|
||||
group_id = None
|
||||
|
||||
try:
|
||||
config = client.service_provider_config
|
||||
assert config.patch.supported is True
|
||||
assert config.etag.supported is True
|
||||
assert config.sort.supported is True
|
||||
assert config.change_password.supported is False
|
||||
assert config.bulk.max_operations == 1000, config.bulk
|
||||
assert config.filter.max_results == 200, config.filter
|
||||
record("discover", {
|
||||
"resource_types": sorted(
|
||||
resource_type.id for resource_type in client.resource_types
|
||||
),
|
||||
"resource_models": sorted(
|
||||
model.__name__ for model in client.resource_models
|
||||
),
|
||||
})
|
||||
|
||||
created = client.create(
|
||||
user_model(
|
||||
user_name=user_name,
|
||||
display_name=display_name,
|
||||
external_id="lifecycle-" + tag,
|
||||
emails=[{"value": alias}],
|
||||
),
|
||||
expected_status_codes=[201],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
user_id = created.id
|
||||
assert created.user_name == user_name, created.user_name
|
||||
assert created.display_name == display_name, created.display_name
|
||||
assert created.meta.location.endswith("/Users/" + user_id), created.meta.location
|
||||
assert created.meta.version, "the create response carried no version"
|
||||
assert "last_modified" not in type(created.meta).model_fields, "meta.lastModified was published"
|
||||
record("create_user", {"id": user_id, "version": created.meta.version})
|
||||
|
||||
fetched = client.query(
|
||||
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
|
||||
)
|
||||
assert fetched.id == user_id
|
||||
assert fetched.meta.version == created.meta.version
|
||||
assert [email.value for email in fetched.emails] == [user_name, alias], fetched.emails
|
||||
record("query_user_by_id")
|
||||
|
||||
response = client.client.get(
|
||||
"/Users", params={"filter": 'userName eq "{}"'.format(user_name)}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
listed = ListResponse[user_model].model_validate(
|
||||
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
||||
)
|
||||
assert listed.total_results == 1, listed.total_results
|
||||
assert listed.resources[0].id == user_id
|
||||
record("query_user_by_filter")
|
||||
|
||||
searched = client.search(
|
||||
search_request=SearchRequest(filter='userName eq "{}"'.format(user_name)),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert searched.total_results == 1, searched.total_results
|
||||
record("search_endpoint")
|
||||
|
||||
patched = client.modify(
|
||||
user_model,
|
||||
user_id,
|
||||
PatchOp[user_model](
|
||||
operations=[
|
||||
PatchOperation(op="replace", path="displayName", value="Patched " + tag),
|
||||
PatchOperation(op="replace", path="active", value=False),
|
||||
]
|
||||
),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert patched.display_name == "Patched " + tag, patched.display_name
|
||||
assert patched.active is False, patched.active
|
||||
assert patched.meta.version != created.meta.version, "the version did not change"
|
||||
record("patch_user")
|
||||
|
||||
replaced = client.replace(
|
||||
user_model(id=user_id, user_name=renamed),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert replaced.user_name == renamed, replaced.user_name
|
||||
assert replaced.active is True, "a replace must reset active to its default"
|
||||
assert replaced.display_name is None, replaced.display_name
|
||||
record("replace_user")
|
||||
|
||||
group = client.create(
|
||||
group_model(display_name=group_name, members=[{"value": user_id}]),
|
||||
expected_status_codes=[201],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
group_id = group.id
|
||||
assert group.display_name == group_name
|
||||
assert [member.value for member in group.members] == [user_id], group.members
|
||||
record("create_group_with_member", {"id": group_id})
|
||||
|
||||
member_view = client.query(
|
||||
user_model, user_id, expected_status_codes=[200], raise_scim_errors=True
|
||||
)
|
||||
assert [entry.value for entry in member_view.groups] == [group_id], member_view.groups
|
||||
record("membership_visible_on_user")
|
||||
|
||||
emptied = client.modify(
|
||||
group_model,
|
||||
group_id,
|
||||
PatchOp[group_model](
|
||||
operations=[PatchOperation(op="remove", path="members")]
|
||||
),
|
||||
expected_status_codes=[200],
|
||||
raise_scim_errors=True,
|
||||
)
|
||||
assert not emptied.members, emptied.members
|
||||
record("patch_group_members")
|
||||
|
||||
client.delete(group_model, group_id, expected_status_codes=[204], raise_scim_errors=True)
|
||||
group_id = None
|
||||
record("delete_group")
|
||||
|
||||
client.delete(user_model, user_id, expected_status_codes=[204], raise_scim_errors=True)
|
||||
gone = client.query(
|
||||
user_model,
|
||||
user_id,
|
||||
expected_status_codes=[404],
|
||||
raise_scim_errors=False,
|
||||
)
|
||||
assert isinstance(gone, Error), gone
|
||||
assert gone.status == 404, gone
|
||||
user_id = None
|
||||
record("delete_user")
|
||||
except Exception as err:
|
||||
steps.append(
|
||||
{
|
||||
"step": "aborted",
|
||||
"ok": False,
|
||||
"detail": "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
for model, resource_id in ((group_model, group_id), (user_model, user_id)):
|
||||
if resource_id:
|
||||
try:
|
||||
client.delete(model, resource_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"steps": steps}
|
||||
|
||||
|
||||
def clients(client, domain):
|
||||
"""Replays the payload shapes that Okta and the Keycloak community extensions send."""
|
||||
steps = []
|
||||
user_model = client.get_resource_model("User")
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
created = []
|
||||
|
||||
def check(name, fn):
|
||||
try:
|
||||
fn()
|
||||
steps.append({"step": name, "ok": True, "detail": None})
|
||||
except Exception as err:
|
||||
steps.append(
|
||||
{
|
||||
"step": name,
|
||||
"ok": False,
|
||||
"detail": "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def post(name, payload):
|
||||
def run():
|
||||
response = client.client.post("/Users", json=payload)
|
||||
assert response.status_code == 201, "{} {}".format(
|
||||
response.status_code, response.text
|
||||
)
|
||||
body = response.json()
|
||||
created.append(body["id"])
|
||||
assert body["userName"] == payload["userName"], body
|
||||
assert "password" not in response.text, body
|
||||
if payload.get("displayName"):
|
||||
assert body["displayName"] == payload["displayName"], body
|
||||
elif payload.get("name", {}).get("givenName"):
|
||||
expected = " ".join(
|
||||
part
|
||||
for part in (
|
||||
payload["name"].get("givenName"),
|
||||
payload["name"].get("familyName"),
|
||||
)
|
||||
if part
|
||||
)
|
||||
assert body["displayName"] == expected, body
|
||||
user_model.model_validate(body, scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
|
||||
|
||||
check(name, run)
|
||||
|
||||
okta_name = "okta-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"okta_create_user",
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": okta_name,
|
||||
"name": {"givenName": "Barbara", "familyName": "Jensen"},
|
||||
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
|
||||
"displayName": "Barbara Jensen",
|
||||
"locale": "en-US",
|
||||
"externalId": "00u" + tag,
|
||||
"groups": [],
|
||||
"password": "correct horse battery staple",
|
||||
"active": True,
|
||||
"title": "Vice President",
|
||||
"userType": "Employee",
|
||||
"phoneNumbers": [{"value": "555-0100", "type": "work"}],
|
||||
},
|
||||
)
|
||||
|
||||
keycloak_name = "keycloak-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"keycloak_create_user",
|
||||
{
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": keycloak_name,
|
||||
"name": {"givenName": "Wile", "familyName": "Coyote"},
|
||||
"emails": [{"value": keycloak_name, "primary": True}],
|
||||
"active": True,
|
||||
"externalId": "kc-" + tag,
|
||||
},
|
||||
)
|
||||
|
||||
entra_name = "entra-{}@{}".format(tag, domain)
|
||||
post(
|
||||
"entra_create_user",
|
||||
{
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
],
|
||||
"userName": entra_name,
|
||||
"name": {"givenName": "Road", "familyName": "Runner"},
|
||||
"displayName": "Road Runner",
|
||||
"emails": [{"value": entra_name, "type": "work", "primary": True}],
|
||||
"active": True,
|
||||
"externalId": "entra-" + tag,
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
|
||||
"department": "Sales",
|
||||
"employeeNumber": "42",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
okta_id = created[0]
|
||||
|
||||
def okta_put():
|
||||
response = client.client.put(
|
||||
"/Users/" + okta_id,
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"id": okta_id,
|
||||
"userName": okta_name,
|
||||
"name": {"givenName": "Babs", "familyName": "Jensen"},
|
||||
"emails": [{"primary": True, "value": okta_name, "type": "work"}],
|
||||
"displayName": "Babs Jensen",
|
||||
"active": False,
|
||||
"password": "correct horse battery staple",
|
||||
"title": "President",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["displayName"] == "Babs Jensen", body
|
||||
assert body["active"] is False, body
|
||||
|
||||
check("okta_replace_user", okta_put)
|
||||
|
||||
def keycloak_patch():
|
||||
response = client.client.patch(
|
||||
"/Users/" + created[1],
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [{"op": "replace", "value": {"active": "false"}}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["active"] is False, response.text
|
||||
|
||||
check("keycloak_deactivate_user", keycloak_patch)
|
||||
|
||||
def entra_patch():
|
||||
response = client.client.patch(
|
||||
"/Users/" + created[2],
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||
"Operations": [
|
||||
{"op": "replace", "path": "name.givenName", "value": "Wile"},
|
||||
{"op": "replace", "path": "displayName", "value": "Wile Runner"},
|
||||
{
|
||||
"op": "add",
|
||||
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
|
||||
"value": "Marketing",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["displayName"] == "Wile Runner", response.text
|
||||
|
||||
check("entra_patch_user", entra_patch)
|
||||
|
||||
def okta_lookup():
|
||||
response = client.client.get(
|
||||
"/Users", params={"filter": 'userName eq "{}"'.format(okta_name)}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
listed = ListResponse[user_model].model_validate(
|
||||
response.json(), scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
||||
)
|
||||
assert listed.total_results == 1, listed.total_results
|
||||
|
||||
check("okta_lookup_by_username", okta_lookup)
|
||||
|
||||
def rejects_typos():
|
||||
response = client.client.post(
|
||||
"/Users",
|
||||
json={
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||
"userName": "typo-{}@{}".format(tag, domain),
|
||||
"dispalyName": "Typo",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["scimType"] == "invalidSyntax", response.text
|
||||
|
||||
check("misspelled_attributes_are_still_rejected", rejects_typos)
|
||||
|
||||
for resource_id in created:
|
||||
try:
|
||||
client.client.delete("/Users/" + resource_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"steps": steps}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", required=True)
|
||||
parser.add_argument("--token", required=True)
|
||||
parser.add_argument("--domain", required=True)
|
||||
parser.add_argument(
|
||||
"--mode", required=True, choices=["conformance", "lifecycle", "clients"]
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
allow_post_rfc7643_service_provider_config_attributes()
|
||||
client = build_client(args.url, args.token)
|
||||
try:
|
||||
client.discover()
|
||||
except Exception as err:
|
||||
json.dump(
|
||||
{"error": "discovery failed: " + "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__)
|
||||
)},
|
||||
sys.stdout,
|
||||
)
|
||||
sys.stdout.write("\n")
|
||||
return
|
||||
|
||||
runner = {"conformance": conformance, "lifecycle": lifecycle, "clients": clients}[
|
||||
args.mode
|
||||
]
|
||||
json.dump(runner(client, args.domain), sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user