mirror of
https://github.com/stalwartlabs/stalwart.git
synced 2026-09-06 00:01:14 +00:00
458 lines
16 KiB
Python
458 lines
16 KiB
Python
#!/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()
|