diff --git a/ee/api/Pipfile b/ee/api/Pipfile index cf41528a8..93bd8134a 100644 --- a/ee/api/Pipfile +++ b/ee/api/Pipfile @@ -26,6 +26,8 @@ xmlsec = "==1.3.14" python-multipart = "==0.0.20" redis = "==6.1.0" azure-storage-blob = "==12.25.1" +scim2-server = "*" +scim2-models = "*" [dev-packages] diff --git a/ee/api/app.py b/ee/api/app.py index 672285032..4d9be2fec 100644 --- a/ee/api/app.py +++ b/ee/api/app.py @@ -9,6 +9,7 @@ from decouple import config from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware +from fastapi.middleware.wsgi import WSGIMiddleware from psycopg import AsyncConnection from psycopg.rows import dict_row from starlette import status @@ -21,7 +22,15 @@ from chalicelib.utils import pg_client, ch_client from crons import core_crons, ee_crons, core_dynamic_crons from routers import core, core_dynamic from routers import ee -from routers.subs import insights, metrics, v1_api, health, usability_tests, spot, product_analytics +from routers.subs import ( + insights, + metrics, + v1_api, + health, + usability_tests, + spot, + product_analytics, +) from routers.subs import v1_api_ee if config("ENABLE_SSO", cast=bool, default=True): @@ -34,7 +43,6 @@ logging.basicConfig(level=loglevel) class ORPYAsyncConnection(AsyncConnection): - def __init__(self, *args, **kwargs): super().__init__(*args, row_factory=dict_row, **kwargs) @@ -43,7 +51,7 @@ class ORPYAsyncConnection(AsyncConnection): async def lifespan(app: FastAPI): # Startup logging.info(">>>>> starting up <<<<<") - ap_logger = logging.getLogger('apscheduler') + ap_logger = logging.getLogger("apscheduler") ap_logger.setLevel(loglevel) app.schedule = AsyncIOScheduler() @@ -53,12 +61,23 @@ async def lifespan(app: FastAPI): await events_queue.init() app.schedule.start() - for job in core_crons.cron_jobs + core_dynamic_crons.cron_jobs + traces.cron_jobs + ee_crons.ee_cron_jobs: + for job in ( + core_crons.cron_jobs + + core_dynamic_crons.cron_jobs + + traces.cron_jobs + + ee_crons.ee_cron_jobs + ): app.schedule.add_job(id=job["func"].__name__, **job) ap_logger.info(">Scheduled jobs:") for job in app.schedule.get_jobs(): - ap_logger.info({"Name": str(job.id), "Run Frequency": str(job.trigger), "Next Run": str(job.next_run_time)}) + ap_logger.info( + { + "Name": str(job.id), + "Run Frequency": str(job.trigger), + "Next Run": str(job.next_run_time), + } + ) database = { "host": config("pg_host", default="localhost"), @@ -69,9 +88,12 @@ async def lifespan(app: FastAPI): "application_name": "AIO" + config("APP_NAME", default="PY"), } - database = psycopg_pool.AsyncConnectionPool(kwargs=database, connection_class=ORPYAsyncConnection, - min_size=config("PG_AIO_MINCONN", cast=int, default=1), - max_size=config("PG_AIO_MAXCONN", cast=int, default=5), ) + database = psycopg_pool.AsyncConnectionPool( + kwargs=database, + connection_class=ORPYAsyncConnection, + min_size=config("PG_AIO_MINCONN", cast=int, default=1), + max_size=config("PG_AIO_MAXCONN", cast=int, default=5), + ) app.state.postgresql = database # App listening @@ -86,16 +108,24 @@ async def lifespan(app: FastAPI): await pg_client.terminate() -app = FastAPI(root_path=config("root_path", default="/api"), docs_url=config("docs_url", default=""), - redoc_url=config("redoc_url", default=""), lifespan=lifespan) +app = FastAPI( + root_path=config("root_path", default="/api"), + docs_url=config("docs_url", default=""), + redoc_url=config("redoc_url", default=""), + lifespan=lifespan, +) app.add_middleware(GZipMiddleware, minimum_size=1000) -@app.middleware('http') +@app.middleware("http") async def or_middleware(request: Request, call_next): from chalicelib.core import unlock + if not unlock.is_valid(): - return JSONResponse(content={"errors": ["expired license"]}, status_code=status.HTTP_403_FORBIDDEN) + return JSONResponse( + content={"errors": ["expired license"]}, + status_code=status.HTTP_403_FORBIDDEN, + ) if helper.TRACK_TIME: now = time.time() @@ -110,8 +140,10 @@ async def or_middleware(request: Request, call_next): now = time.time() - now if now > 2: now = round(now, 2) - logging.warning(f"Execution time: {now} s for {request.method}: {request.url.path}") - response.headers["x-robots-tag"] = 'noindex, nofollow' + logging.warning( + f"Execution time: {now} s for {request.method}: {request.url.path}" + ) + response.headers["x-robots-tag"] = "noindex, nofollow" return response @@ -162,3 +194,4 @@ if config("ENABLE_SSO", cast=bool, default=True): app.include_router(scim.public_app) app.include_router(scim.app) app.include_router(scim.app_apikey) + app.mount("/sso/scim/v2", WSGIMiddleware(scim.scim_app)) diff --git a/ee/api/chalicelib/utils/SAML2_helper.py b/ee/api/chalicelib/utils/SAML2_helper.py index 6e1972c1e..5c484e5c3 100644 --- a/ee/api/chalicelib/utils/SAML2_helper.py +++ b/ee/api/chalicelib/utils/SAML2_helper.py @@ -23,20 +23,18 @@ SAML2 = { "entityId": config("SITE_URL") + API_PREFIX + "/sso/saml2/metadata/", "assertionConsumerService": { "url": config("SITE_URL") + API_PREFIX + "/sso/saml2/acs/", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", }, "singleLogoutService": { "url": config("SITE_URL") + API_PREFIX + "/sso/saml2/sls/", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", }, "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "x509cert": config("sp_crt", default=""), "privateKey": config("sp_key", default=""), }, - "security": { - "requestedAuthnContext": False - }, - "idp": None + "security": {"requestedAuthnContext": False}, + "idp": None, } # in case tenantKey is included in the URL @@ -50,25 +48,29 @@ if config("SAML2_MD_URL", default=None) is not None and len(config("SAML2_MD_URL print("SAML2_MD_URL provided, getting IdP metadata config") from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser - idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(config("SAML2_MD_URL", default=None)) + idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote( + config("SAML2_MD_URL", default=None) + ) idp = idp_data.get("idp") if SAML2["idp"] is None: - if len(config("idp_entityId", default="")) > 0 \ - and len(config("idp_sso_url", default="")) > 0 \ - and len(config("idp_x509cert", default="")) > 0: + if ( + len(config("idp_entityId", default="")) > 0 + and len(config("idp_sso_url", default="")) > 0 + and len(config("idp_x509cert", default="")) > 0 + ): idp = { "entityId": config("idp_entityId"), "singleSignOnService": { "url": config("idp_sso_url"), - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", }, - "x509cert": config("idp_x509cert") + "x509cert": config("idp_x509cert"), } if len(config("idp_sls_url", default="")) > 0: idp["singleLogoutService"] = { "url": config("idp_sls_url"), - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", } if idp is None: @@ -106,8 +108,8 @@ async def prepare_request(request: Request): session = {} # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields headers = request.headers - proto = headers.get('x-forwarded-proto', 'http') - url_data = urlparse('%s://%s' % (proto, headers['host'])) + proto = headers.get("x-forwarded-proto", "http") + url_data = urlparse("%s://%s" % (proto, headers["host"])) path = request.url.path site_url = urlparse(config("SITE_URL")) # to support custom port without changing IDP config @@ -117,21 +119,21 @@ async def prepare_request(request: Request): # add / to /acs if not path.endswith("/"): - path = path + '/' + path = path + "/" if len(API_PREFIX) > 0 and not path.startswith(API_PREFIX): path = API_PREFIX + path return { - 'https': 'on' if proto == 'https' else 'off', - 'http_host': request.headers['host'] + host_suffix, - 'server_port': url_data.port, - 'script_name': path, - 'get_data': request.args.copy(), + "https": "on" if proto == "https" else "off", + "http_host": request.headers["host"] + host_suffix, + "server_port": url_data.port, + "script_name": path, + "get_data": request.args.copy(), # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 # 'lowercase_urlencoding': True, - 'post_data': request.form.copy(), - 'cookie': {"session": session}, - 'request': request + "post_data": request.form.copy(), + "cookie": {"session": session}, + "request": request, } @@ -140,8 +142,11 @@ def is_saml2_available(): def get_saml2_provider(): - return config("idp_name", default="saml2") if is_saml2_available() and len( - config("idp_name", default="saml2")) > 0 else None + return ( + config("idp_name", default="saml2") + if is_saml2_available() and len(config("idp_name", default="saml2")) > 0 + else None + ) def get_landing_URL(query_params: dict = None, redirect_to_link2=False): @@ -152,7 +157,9 @@ def get_landing_URL(query_params: dict = None, redirect_to_link2=False): if redirect_to_link2: if len(config("sso_landing_override", default="")) == 0: - logging.warning("SSO trying to redirect to custom URL, but sso_landing_override env var is empty") + logging.warning( + "SSO trying to redirect to custom URL, but sso_landing_override env var is empty" + ) else: return config("sso_landing_override") + query_params diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index 6ce194a4e..9a58e4d37 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -1,33 +1,58 @@ -import logging -from copy import deepcopy -from enum import Enum +from scim2_server import utils + + +from routers.base import get_routers +from routers.scim.providers import MultiTenantProvider +from routers.scim.backends import PostgresBackend +from routers.scim.postgres_resource import PostgresResource +from routers.scim import users, groups, helpers from urllib.parse import urlencode from chalicelib.utils import pg_client -from fastapi import Depends, HTTPException, Query, Response, Request -from fastapi.responses import JSONResponse, RedirectResponse -from psycopg2 import errors - -from chalicelib.core import roles +from fastapi import HTTPException, Request +from fastapi.responses import RedirectResponse from chalicelib.utils.scim_auth import ( - auth_optional, - auth_required, create_tokens, verify_refresh_token, ) -from routers.base import get_routers -from routers.scim.constants import ( - SERVICE_PROVIDER_CONFIG, - RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS, - SCHEMA_IDS_TO_SCHEMA_DETAILS, + + +b = PostgresBackend() +b.register_postgres_resource( + "User", + PostgresResource( + query_resources=users.query_resources, + get_resource=users.get_resource, + create_resource=users.create_resource, + search_existing=users.search_existing, + restore_resource=users.restore_resource, + delete_resource=users.delete_resource, + update_resource=users.update_resource, + ), +) +b.register_postgres_resource( + "Group", + PostgresResource( + query_resources=groups.query_resources, + get_resource=groups.get_resource, + create_resource=groups.create_resource, + search_existing=groups.search_existing, + restore_resource=None, + delete_resource=groups.delete_resource, + update_resource=groups.update_resource, + ), ) -from routers.scim import helpers, groups, users -from routers.scim.resource_config import ResourceConfig -from routers.scim import resource_config as api_helper +scim_app = MultiTenantProvider(b) + +for schema in utils.load_default_schemas().values(): + scim_app.register_schema(schema) +for schema in helpers.load_custom_schemas().values(): + scim_app.register_schema(schema) +for resource_type in helpers.load_custom_resource_types().values(): + scim_app.register_resource_type(resource_type) -logger = logging.getLogger(__name__) public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") @@ -137,404 +162,3 @@ async def get_authorize( params["state"] = state url = f"{redirect_uri}?{urlencode(params)}" return RedirectResponse(url) - - -def _not_found_error_response(resource_id: int): - return JSONResponse( - status_code=404, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": f"Resource {resource_id} not found", - "status": "404", - }, - ) - - -def _uniqueness_error_response(): - return JSONResponse( - status_code=409, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "One or more of the attribute values are already in use or are reserved.", - "status": "409", - "scimType": "uniqueness", - }, - ) - - -def _mutability_error_response(): - return JSONResponse( - status_code=400, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "The attempted modification is not compatible with the target attribute's mutability or current state.", - "status": "400", - "scimType": "mutability", - }, - ) - - -def _operation_not_permitted_error_response(): - return JSONResponse( - status_code=403, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "Operation is not permitted based on the supplied authorization", - "status": "403", - }, - ) - - -def _invalid_value_error_response(): - return JSONResponse( - status_code=400, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "A required value was missing, or the value specified was not compatible with the operation or attribtue type, or resource schema.", - "status": "400", - "scimType": "invalidValue", - }, - ) - - -def _internal_server_error_response(detail: str): - return JSONResponse( - status_code=500, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": detail, - "status": "500", - }, - ) - - -# note(jon): it was recommended to make this endpoint partially open -# so that clients can view the `authenticationSchemes` prior to being authenticated. -@public_app.get("/ServiceProviderConfig") -async def get_service_provider_config( - r: Request, tenant_id: str | None = Depends(auth_optional) -): - is_authenticated = tenant_id is not None - if not is_authenticated: - return JSONResponse( - status_code=200, - content={ - "schemas": SERVICE_PROVIDER_CONFIG["schemas"], - "authenticationSchemes": SERVICE_PROVIDER_CONFIG[ - "authenticationSchemes" - ], - "meta": SERVICE_PROVIDER_CONFIG["meta"], - }, - ) - return JSONResponse(status_code=200, content=SERVICE_PROVIDER_CONFIG) - - -@public_app.get("/ResourceTypes", dependencies=[Depends(auth_required)]) -async def get_resource_types(filter_param: str | None = Query(None, alias="filter")): - if filter_param is not None: - return _operation_not_permitted_error_response() - return JSONResponse( - status_code=200, - content={ - "totalResults": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS), - "itemsPerPage": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS), - "startIndex": 1, - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "Resources": list(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values()), - }, - ) - - -@public_app.get("/ResourceTypes/{resource_id}", dependencies=[Depends(auth_required)]) -async def get_resource_type(resource_id: str): - if resource_id not in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS: - return _not_found_error_response(resource_id) - return JSONResponse( - status_code=200, - content=RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_id], - ) - - -@public_app.get("/Schemas", dependencies=[Depends(auth_required)]) -async def get_schemas(filter_param: str | None = Query(None, alias="filter")): - if filter_param is not None: - return _operation_not_permitted_error_response() - return JSONResponse( - status_code=200, - content={ - "totalResults": len(SCHEMA_IDS_TO_SCHEMA_DETAILS), - "itemsPerPage": len(SCHEMA_IDS_TO_SCHEMA_DETAILS), - "startIndex": 1, - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "Resources": [ - value for _, value in sorted(SCHEMA_IDS_TO_SCHEMA_DETAILS.items()) - ], - }, - ) - - -@public_app.get("/Schemas/{schema_id}") -async def get_schema(schema_id: str, tenant_id=Depends(auth_required)): - if schema_id not in SCHEMA_IDS_TO_SCHEMA_DETAILS: - return _not_found_error_response(schema_id) - schema = deepcopy(SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id]) - if schema_id == "urn:ietf:params:scim:schemas:core:2.0:User": - db_roles = roles.get_roles(tenant_id) - role_names = [role["name"] for role in db_roles] - user_type_attribute = next( - filter(lambda x: x["name"] == "userType", schema["attributes"]) - ) - user_type_attribute["canonicalValues"] = role_names - return JSONResponse( - status_code=200, - content=schema, - ) - - -user_config = ResourceConfig( - resource_type_id="User", - max_chunk_size=10, - get_active_resource_count=users.get_active_resource_count, - convert_provider_resource_to_client_resource=users.convert_provider_resource_to_client_resource, - get_provider_resource_chunk=users.get_provider_resource_chunk, - get_provider_resource=users.get_provider_resource, - convert_client_resource_creation_input_to_provider_resource_creation_input=users.convert_client_resource_creation_input_to_provider_resource_creation_input, - get_provider_resource_from_unique_fields=users.get_provider_resource_from_unique_fields, - restore_provider_resource=users.restore_provider_resource, - create_provider_resource=users.create_provider_resource, - delete_provider_resource=users.delete_provider_resource, - convert_client_resource_rewrite_input_to_provider_resource_rewrite_input=users.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input, - rewrite_provider_resource=users.rewrite_provider_resource, - convert_client_resource_update_input_to_provider_resource_update_input=users.convert_client_resource_update_input_to_provider_resource_update_input, - update_provider_resource=users.update_provider_resource, - filter_attribute_mapping=users.filter_attribute_mapping, -) -group_config = ResourceConfig( - resource_type_id="Group", - max_chunk_size=10, - get_active_resource_count=groups.get_active_resource_count, - convert_provider_resource_to_client_resource=groups.convert_provider_resource_to_client_resource, - get_provider_resource_chunk=groups.get_provider_resource_chunk, - get_provider_resource=groups.get_provider_resource, - convert_client_resource_creation_input_to_provider_resource_creation_input=groups.convert_client_resource_creation_input_to_provider_resource_creation_input, - get_provider_resource_from_unique_fields=lambda **kwargs: None, - restore_provider_resource=None, - create_provider_resource=groups.create_provider_resource, - delete_provider_resource=groups.delete_provider_resource, - convert_client_resource_rewrite_input_to_provider_resource_rewrite_input=groups.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input, - rewrite_provider_resource=groups.rewrite_provider_resource, - convert_client_resource_update_input_to_provider_resource_update_input=groups.convert_client_resource_update_input_to_provider_resource_update_input, - update_provider_resource=groups.update_provider_resource, - filter_attribute_mapping=groups.filter_attribute_mapping, -) - -RESOURCE_TYPE_TO_RESOURCE_CONFIG: dict[str, ResourceConfig] = { - "Users": user_config, - "Groups": group_config, -} - - -class SCIMResource(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.get("/{resource_type}") -async def get_resources( - resource_type: SCIMResource, - tenant_id=Depends(auth_required), - requested_start_index_one_indexed: int = Query(1, alias="startIndex"), - requested_items_per_page: int | None = Query(None, alias="count"), - attributes: str | None = Query(None), - excluded_attributes: str | None = Query(None, alias="excludedAttributes"), - filter: str | None = Query(None), -): - config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - filter_clause = helpers.scim_to_sql_where(filter, config.filter_attribute_mapping()) - total_resources = config.get_active_resource_count(tenant_id, filter_clause) - start_index_one_indexed = max(1, requested_start_index_one_indexed) - offset = start_index_one_indexed - 1 - limit = min( - max(0, requested_items_per_page or config.max_chunk_size), config.max_chunk_size - ) - provider_resources = config.get_provider_resource_chunk( - offset, tenant_id, limit, filter_clause - ) - client_resources = [ - api_helper.convert_provider_resource_to_client_resource( - config, provider_resource, attributes, excluded_attributes - ) - for provider_resource in provider_resources - ] - return JSONResponse( - status_code=200, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": total_resources, - "startIndex": start_index_one_indexed, - "itemsPerPage": len(client_resources), - "Resources": client_resources, - }, - ) - - -@public_app.get("/{resource_type}/{resource_id}") -async def get_resource( - resource_type: SCIMResource, - resource_id: int | str, - tenant_id=Depends(auth_required), - attributes: list[str] | None = Query(None), - excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), -): - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - resource = api_helper.get_resource( - resource_config, - resource_id, - tenant_id, - attributes, - excluded_attributes, - ) - if not resource: - return _not_found_error_response(resource_id) - return JSONResponse(status_code=200, content=resource) - - -@public_app.post("/{resource_type}") -async def create_resource( - resource_type: SCIMResource, - r: Request, - tenant_id=Depends(auth_required), - attributes: list[str] | None = Query(None), - excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), -): - config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - payload = await r.json() - try: - provider_resource_input = config.convert_client_resource_creation_input_to_provider_resource_creation_input( - tenant_id, - payload, - ) - except KeyError: - return _invalid_value_error_response() - existing_provider_resource = config.get_provider_resource_from_unique_fields( - **provider_resource_input - ) - if ( - existing_provider_resource - and existing_provider_resource.get("deleted_at") is None - ): - return _uniqueness_error_response() - if ( - existing_provider_resource - and existing_provider_resource.get("deleted_at") is not None - ): - provider_resource = config.restore_provider_resource( - tenant_id=tenant_id, **provider_resource_input - ) - else: - provider_resource = config.create_provider_resource( - tenant_id=tenant_id, **provider_resource_input - ) - client_resource = api_helper.convert_provider_resource_to_client_resource( - config, provider_resource, attributes, excluded_attributes - ) - response = JSONResponse(status_code=201, content=client_resource) - response.headers["Location"] = client_resource["meta"]["location"] - return response - - -@public_app.delete("/{resource_type}/{resource_id}") -async def delete_resource( - resource_type: SCIMResource, - resource_id: str, - tenant_id=Depends(auth_required), -): - # note(jon): this can be a soft or a hard delete - config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - resource = api_helper.get_resource(config, resource_id, tenant_id) - if not resource: - return _not_found_error_response(resource_id) - config.delete_provider_resource(resource_id, tenant_id) - return Response(status_code=204, content="") - - -@public_app.put("/{resource_type}/{resource_id}") -async def put_resource( - resource_type: SCIMResource, - resource_id: int | str, - r: Request, - tenant_id=Depends(auth_required), - attributes: list[str] | None = Query(None), - excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), -): - config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - client_resource = api_helper.get_resource(config, resource_id, tenant_id) - if not client_resource: - return _not_found_error_response(resource_id) - schema = api_helper.get_schema(config) - payload = await r.json() - try: - client_resource_input = helpers.filter_mutable_attributes( - schema, payload, client_resource - ) - except ValueError: - return _mutability_error_response() - provider_resource_input = ( - config.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( - tenant_id, client_resource_input - ) - ) - try: - provider_resource = config.rewrite_provider_resource( - resource_id, - tenant_id, - **provider_resource_input, - ) - except errors.UniqueViolation: - return _uniqueness_error_response() - except Exception as e: - return _internal_server_error_response(str(e)) - client_resource = api_helper.convert_provider_resource_to_client_resource( - config, provider_resource, attributes, excluded_attributes - ) - return JSONResponse(status_code=200, content=client_resource) - - -@public_app.patch("/{resource_type}/{resource_id}") -async def patch_resource( - resource_type: SCIMResource, - resource_id: int | str, - r: Request, - tenant_id=Depends(auth_required), - attributes: list[str] | None = Query(None), - excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), -): - config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - client_resource = api_helper.get_resource(config, resource_id, tenant_id) - if not client_resource: - return _not_found_error_response(resource_id) - schema = api_helper.get_schema(config) - payload = await r.json() - _, changes = helpers.apply_scim_patch( - payload["Operations"], client_resource, schema - ) - client_resource_input = { - k: new_value for k, (old_value, new_value) in changes.items() - } - provider_resource_input = ( - config.convert_client_resource_update_input_to_provider_resource_update_input( - tenant_id, client_resource_input - ) - ) - try: - provider_resource = config.update_provider_resource( - resource_id, tenant_id, **provider_resource_input - ) - except errors.UniqueViolation: - return _uniqueness_error_response() - except Exception as e: - return _internal_server_error_response(str(e)) - client_resource = api_helper.convert_provider_resource_to_client_resource( - config, provider_resource, attributes, excluded_attributes - ) - return JSONResponse(status_code=200, content=client_resource) diff --git a/ee/api/routers/scim/backends.py b/ee/api/routers/scim/backends.py new file mode 100644 index 000000000..85daf6d7c --- /dev/null +++ b/ee/api/routers/scim/backends.py @@ -0,0 +1,203 @@ +from scim2_server import backend +from scim2_server.filter import evaluate_filter +from scim2_server.utils import SCIMException + +from scim2_models import ( + SearchRequest, + Resource, + Context, + Error, +) +from scim2_filter_parser import lexer +from scim2_filter_parser.parser import SCIMParser +from routers.scim.postgres_resource import PostgresResource +from scim2_server.operators import ResolveSortOperator +import operator + + +class PostgresBackend(backend.Backend): + def __init__(self): + super().__init__() + self._postgres_resources = {} + + def register_postgres_resource( + self, resource_type_id: str, postgres_resource: PostgresResource + ): + self._postgres_resources[resource_type_id] = postgres_resource + + def query_resources( + self, + search_request: SearchRequest, + tenant_id: int, + resource_type_id: str | None = None, + ) -> tuple[int, list[Resource]]: + """Query the backend for a set of resources. + + :param search_request: SearchRequest instance describing the + query. + :param resource_type_id: ID of the resource type to query. If + None, all resource types are queried. + :return: A tuple of "total results" and a List of found + Resources. The List must contain a copy of resources. + Mutating elements in the List must not modify the data + stored in the backend. + :raises SCIMException: If the backend only supports querying for + one resource type at a time, setting resource_type_id to + None the backend may raise a + SCIMException(Error.make_too_many_error()). + """ + start_index = (search_request.start_index or 1) - 1 + + tree = None + if search_request.filter is not None: + token_stream = lexer.SCIMLexer().tokenize(search_request.filter) + tree = SCIMParser().parse(token_stream) + + # todo(jon): handle the case when resource_type_id is None. + # we're assuming it's never None for now. + # but, this is fine to leave as it doesn't seem to used or reached in + # any of my tests yet. + if not resource_type_id: + raise NotImplementedError + + resources = self._postgres_resources[resource_type_id].query_resources( + tenant_id + ) + model = self.get_model(resource_type_id) + resources = [ + model.model_validate(r, scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + for r in resources + ] + resources = [r for r in resources if (tree is None or evaluate_filter(r, tree))] + + if search_request.sort_by is not None: + descending = search_request.sort_order == SearchRequest.SortOrder.descending + sort_operator = ResolveSortOperator(search_request.sort_by) + + # To ensure that unset attributes are sorted last (when ascending, as defined in the RFC), + # we have to divide the result set into a set and unset subset. + unset_values = [] + set_values = [] + for resource in resources: + result = sort_operator(resource) + if result is None: + unset_values.append(resource) + else: + set_values.append((resource, result)) + + set_values.sort(key=operator.itemgetter(1), reverse=descending) + set_values = [value[0] for value in set_values] + if descending: + resources = unset_values + set_values + else: + resources = set_values + unset_values + + found_resources = resources[start_index:] + if search_request.count is not None: + found_resources = resources[: search_request.count] + + return len(resources), found_resources + + def get_resource( + self, tenant_id: int, resource_type_id: str, object_id: str + ) -> Resource | None: + """Query the backend for a resources by its ID. + + :param resource_type_id: ID of the resource type to get the + object from. + :param object_id: ID of the object to get. + :return: The resource object if it exists, None otherwise. The + resource must be a copy, modifying it must not change the + data stored in the backend. + """ + resource = self._postgres_resources[resource_type_id].get_resource( + object_id, tenant_id + ) + if resource: + model = self.get_model(resource_type_id) + resource = model.model_validate(resource) + return resource + + def delete_resource( + self, tenant_id: int, resource_type_id: str, object_id: str + ) -> bool: + """Delete a resource. + + :param resource_type_id: ID of the resource type to delete the + object from. + :param object_id: ID of the object to delete. + :return: True if the resource was deleted, False otherwise. + """ + resource = self.get_resource(tenant_id, resource_type_id, object_id) + if resource: + self._postgres_resources[resource_type_id].delete_resource( + object_id, tenant_id + ) + return True + return False + + def create_resource( + self, tenant_id: int, resource_type_id: str, resource: Resource + ) -> Resource | None: + """Create a resource. + + :param resource_type_id: ID of the resource type to create. + :param resource: Resource to create. + :return: The created resource. Creation should set system- + defined attributes (ID, Metadata). May be the same object + that is passed in. + """ + model = self.get_model(resource_type_id) + existing = self._postgres_resources[resource_type_id].search_existing( + tenant_id, resource + ) + if existing: + existing = model.model_validate(existing) + if existing.active: + raise SCIMException(Error.make_uniqueness_error()) + resource = self._postgres_resources[resource_type_id].restore_resource( + tenant_id, resource + ) + else: + resource = self._postgres_resources[resource_type_id].create_resource( + tenant_id, resource + ) + resource = model.model_validate(resource) + return resource + + def update_resource( + self, tenant_id: int, resource_type_id: str, resource: Resource + ) -> Resource | None: + """Update a resource. The resource is identified by its ID. + + :param resource_type_id: ID of the resource type to update. + :param resource: Resource to update. + :return: The updated resource. Updating should update the + "meta.lastModified" data. May be the same object that is + passed in. + """ + model = self.get_model(resource_type_id) + existing = self._postgres_resources[resource_type_id].search_existing( + tenant_id, resource + ) + if existing: + existing = model.model_validate(existing) + if existing.active: + if existing.id != resource.id: + raise SCIMException(Error.make_uniqueness_error()) + resource = self._postgres_resources[resource_type_id].update_resource( + tenant_id, resource + ) + else: + self._postgres_resources[resource_type_id].delete_resource( + existing.id, tenant_id + ) + resource = self._postgres_resources[resource_type_id].update_resource( + resource.id, tenant_id, resource + ) + else: + resource = self._postgres_resources[resource_type_id].update_resource( + tenant_id, resource + ) + resource = model.model_validate(resource) + return resource diff --git a/ee/api/routers/scim/constants.py b/ee/api/routers/scim/constants.py deleted file mode 100644 index 9642c471f..000000000 --- a/ee/api/routers/scim/constants.py +++ /dev/null @@ -1,36 +0,0 @@ -# note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants -import json - -SCHEMAS = sorted( - [ - json.load( - open("routers/scim/fixtures/service_provider_config_schema.json", "r") - ), - json.load(open("routers/scim/fixtures/resource_type_schema.json", "r")), - json.load(open("routers/scim/fixtures/schema_schema.json", "r")), - json.load(open("routers/scim/fixtures/user_schema.json", "r")), - json.load(open("routers/scim/fixtures/group_schema.json", "r")), - json.load( - open("routers/scim/fixtures/open_replay_user_extension_schema.json", "r") - ), - ], - key=lambda x: x["id"], -) - -SCHEMA_IDS_TO_SCHEMA_DETAILS = { - schema_detail["id"]: schema_detail for schema_detail in SCHEMAS -} - -SERVICE_PROVIDER_CONFIG = json.load( - open("routers/scim/fixtures/service_provider_config.json", "r") -) - -RESOURCE_TYPES = sorted( - json.load(open("routers/scim/fixtures/resource_type.json", "r")), - key=lambda x: x["id"], -) - -RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { - resource_type_detail["id"]: resource_type_detail - for resource_type_detail in RESOURCE_TYPES -} diff --git a/ee/api/routers/scim/fixtures/custom_resource_types.json b/ee/api/routers/scim/fixtures/custom_resource_types.json new file mode 100644 index 000000000..0c6e718d3 --- /dev/null +++ b/ee/api/routers/scim/fixtures/custom_resource_types.json @@ -0,0 +1,36 @@ +[{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "User", + "name": "User", + "endpoint": "/Users", + "description": "User Account", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "schemaExtensions": [ + { + "schema": + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + "required": true + }, + { + "schema": + "urn:ietf:params:scim:schemas:extension:openreplay:2.0:User", + "required": true + } + ], + "meta": { + "location": "/v2/ResourceTypes/User", + "resourceType": "ResourceType" + } + }, + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "description": "Group", + "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", + "meta": { + "location": "/v2/ResourceTypes/Group", + "resourceType": "ResourceType" + } + }] diff --git a/ee/api/routers/scim/fixtures/custom_schemas.json b/ee/api/routers/scim/fixtures/custom_schemas.json new file mode 100644 index 000000000..eb8e8da37 --- /dev/null +++ b/ee/api/routers/scim/fixtures/custom_schemas.json @@ -0,0 +1,32 @@ +[ + { + "id": "urn:ietf:params:scim:schemas:extension:openreplay:2.0:User", + "name": "OpenreplayUser", + "description": "Openreplay User Account Extension", + "attributes": [ + { + "name": "permissions", + "type": "string", + "multiValued": true, + "description": "A list of permissions for the User that represent a thing the User is capable of doing.", + "required": false, + "canonicalValues": ["SESSION_REPLAY", "DEV_TOOLS", "METRICS", "ASSIST_LIVE", "ASSIST_CALL", "SPOT", "SPOT_PUBLIC"], + "mutability": "readWrite", + "returned": "default" + }, + { + "name": "projectKeys", + "type": "string", + "multiValued": true, + "description": "A list of project keys for the User that represent a project the User is allowed to work on.", + "required": false, + "mutability": "readWrite", + "returned": "default" + } + ], + "meta": { + "resourceType": "Schema", + "location": "/v2/Schemas/urn:ietf:params:scim:schemas:extension:openreplay:2.0:User" + } + } +] diff --git a/ee/api/routers/scim/fixtures/group_schema.json b/ee/api/routers/scim/fixtures/group_schema.json deleted file mode 100644 index ddb030b92..000000000 --- a/ee/api/routers/scim/fixtures/group_schema.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:core:2.0:Group", - "name": "Group", - "description": "Group", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "schemas", - "type": "string", - "multiValued": true, - "description": "An array of Strings containing URI that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON structure.", - "required": true, - "caseExact": false, - "mutability": "immutable", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "id", - "type": "string", - "multiValued": false, - "description": "Unique identifier for the resource, assigned by the service provider. MUST be non-empty, unique, stable, and non-reassignable. Clients MUST NOT specify this value.", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "server" - }, - { - "name": "externalId", - "type": "string", - "multiValued": false, - "description": "Identifier for the resource as defined by the provisioning client. OPTIONAL; clients MAY include a non-empty value.", - "required": false, - "caseExact": true, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "meta", - "type": "complex", - "multiValued": false, - "description": "Resource metadata. MUST be ignored when provided by clients.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "resourceType", - "type": "string", - "multiValued": false, - "description": "The resource type name.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "created", - "type": "dateTime", - "multiValued": false, - "description": "The date and time the resource was added.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "lastModified", - "type": "dateTime", - "multiValued": false, - "description": "The most recent date and time the resource was modified.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "location", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "The URI of the resource being returned.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "version", - "type": "string", - "multiValued": false, - "description": "The version (ETag) of the resource being returned.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "displayName", - "type": "string", - "multiValued": false, - "description": "Human readable name for the Group. REQUIRED.", - "required": true, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "members", - "type": "complex", - "multiValued": true, - "description": "A list of members of the Group.", - "required": false, - "subAttributes": [ - { - "name": "value", - "type": "string", - "multiValued": false, - "description": "Identifier of the member of this Group.", - "required": true, - "caseExact": true, - "mutability": "immutable", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "$ref", - "type": "reference", - "referenceTypes": ["User"], - "multiValued": false, - "description": "The URI of the corresponding member resource of this Group.", - "required": false, - "caseExact": false, - "mutability": "immutable", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "type", - "type": "string", - "multiValued": false, - "description": "A label indicating the type of resource; e.g., 'User'.", - "required": false, - "caseExact": false, - "canonicalValues": ["User"], - "mutability": "immutable", - "returned": "default", - "uniqueness": "none" - } - ], - "mutability": "readWrite", - "returned": "default" - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json b/ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json deleted file mode 100644 index b38eeefa7..000000000 --- a/ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", - "name": "User", - "description": "User Account Extension", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "permissions", - "type": "string", - "multiValued": true, - "description": "Permissions granted to the users of the group.", - "required": false, - "caseExact": true, - "canonicalValues": ["Session Replay", "Developer Tools", "Dashboard", "Assist (Live)", "Assist (Call)", "Spots", "Change Spot Visibility"], - "mutability": "readWrite", - "returned": "default" - }, - { - "name": "projectKeys", - "type": "string", - "multiValued": true, - "description": "A list of project keys associated with the group.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default" - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/fixtures/resource_type.json b/ee/api/routers/scim/fixtures/resource_type.json deleted file mode 100644 index 3879665b1..000000000 --- a/ee/api/routers/scim/fixtures/resource_type.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:ResourceType" - ], - "id": "User", - "name": "User", - "endpoint": "/Users", - "description": "User account", - "schema": "urn:ietf:params:scim:schemas:core:2.0:User", - "meta": { - "resourceType": "ResourceType", - "created": "2025-04-16T08:37:00Z", - "lastModified": "2025-04-16T08:37:00Z", - "location": "ResourceType/User" - }, - "schemaExtensions": [ - { - "schema": "urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", - "required": true - } - ] - }, - { - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:ResourceType" - ], - "id": "Group", - "name": "Group", - "endpoint": "/Groups", - "description": "A collection of users", - "schema": "urn:ietf:params:scim:schemas:core:2.0:Group", - "meta": { - "resourceType": "ResourceType", - "created": "2025-04-16T08:37:00Z", - "lastModified": "2025-04-16T08:37:00Z", - "location": "ResourceType/Group" - }, - "schemaExtensions": [] - } -] diff --git a/ee/api/routers/scim/fixtures/resource_type_schema.json b/ee/api/routers/scim/fixtures/resource_type_schema.json deleted file mode 100644 index ac53aefea..000000000 --- a/ee/api/routers/scim/fixtures/resource_type_schema.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "name": "ResourceType", - "description": "Specifies the schema that describes a SCIM Resource Type", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "schemas", - "type": "string", - "multiValued": true, - "description": "An array of Strings containing URI that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON structure.", - "required": true, - "caseExact": false, - "mutability": "immutable", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "id", - "type": "string", - "multiValued": false, - "description": "The resource type's server unique id. May be the same as the 'name' attribute.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "externalId", - "type": "string", - "multiValued": false, - "description": "Identifier for the resource as defined by the provisioning client. OPTIONAL; clients MAY include a non-empty value.", - "required": false, - "caseExact": true, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "meta", - "type": "complex", - "multiValued": false, - "description": "Resource metadata. MUST be ignored when provided by clients.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "resourceType", - "type": "string", - "multiValued": false, - "description": "The resource type name.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "created", - "type": "dateTime", - "multiValued": false, - "description": "The date and time the resource was added.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "lastModified", - "type": "dateTime", - "multiValued": false, - "description": "The most recent date and time the resource was modified.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "location", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "The URI of the resource being returned.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "version", - "type": "string", - "multiValued": false, - "description": "The version (ETag) of the resource being returned.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "name", - "type": "string", - "multiValued": false, - "description": "The resource type name.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": false, - "description": "The resource type's human readable description.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "endpoint", - "type": "reference", - "referenceTypes": ["uri"], - "multiValued": false, - "description": "The resource type's HTTP addressable endpoint relative to the Base URL; e.g., /Users.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "schema", - "type": "reference", - "referenceTypes": ["uri"], - "multiValued": false, - "description": "The resource types primary/base schema URI.", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "schemaExtensions", - "type": "complex", - "multiValued": true, - "description": "A list of URIs of the resource type's schema extensions", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "schema", - "type": "reference", - "referenceTypes": ["uri"], - "multiValued": false, - "description": "The URI of a schema extension.", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": false, - "description": "Specifies whether the schema extension is required for the resource type.", - "required": true, - "mutability": "readOnly", - "returned": "default" - } - ] - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/fixtures/schema_schema.json b/ee/api/routers/scim/fixtures/schema_schema.json deleted file mode 100644 index 231cbde54..000000000 --- a/ee/api/routers/scim/fixtures/schema_schema.json +++ /dev/null @@ -1,389 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", - "name": "Schema", - "description": "Specifies the schema that describes a SCIM Schema", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "schemas", - "type": "string", - "multiValued": true, - "description": "An array of Strings containing URI that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON structure.", - "required": true, - "caseExact": false, - "mutability": "immutable", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "id", - "type": "string", - "multiValued": false, - "description": "The unique URI of the schema.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "externalId", - "type": "string", - "multiValued": false, - "description": "Identifier for the resource as defined by the provisioning client. OPTIONAL; clients MAY include a non-empty value.", - "required": false, - "caseExact": true, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "meta", - "type": "complex", - "multiValued": false, - "description": "Resource metadata. MUST be ignored when provided by clients.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "resourceType", - "type": "string", - "multiValued": false, - "description": "The resource type name.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "created", - "type": "dateTime", - "multiValued": false, - "description": "The date and time the resource was added.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "lastModified", - "type": "dateTime", - "multiValued": false, - "description": "The most recent date and time the resource was modified.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "location", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "The URI of the resource being returned.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "version", - "type": "string", - "multiValued": false, - "description": "The version (ETag) of the resource being returned.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "name", - "type": "string", - "multiValued": false, - "description": "The schema's human readable name.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": false, - "description": "The schema's human readable description.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "attributes", - "type": "complex", - "multiValued": true, - "description": "A complex attribute that includes the attributes of a schema", - "required": true, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "name", - "type": "string", - "multiValued": false, - "description": "The attribute's name", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "type", - "type": "string", - "multiValued": false, - "description": "The attribute's data type.", - "required": true, - "canonicalValues": ["string","complex","boolean","decimal","integer","dateTime","reference"], - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "multiValued", - "type": "boolean", - "multiValued": false, - "description": "Boolean indicating an attribute's plurality.", - "required": true, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "description", - "type": "string", - "multiValued": false, - "description": "A human readable description of the attribute.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": false, - "description": "A boolean indicating if the attribute is required.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "canonicalValues", - "type": "string", - "multiValued": true, - "description": "A collection of canonical values.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "caseExact", - "type": "boolean", - "multiValued": false, - "description": "Indicates if a string attribute is case-sensitive.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "mutability", - "type": "string", - "multiValued": false, - "description": "Indicates if an attribute is modifiable.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "canonicalValues": ["readOnly","readWrite","immutable","writeOnly"] - }, - { - "name": "returned", - "type": "string", - "multiValued": false, - "description": "Indicates when an attribute is returned in a response.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "canonicalValues": ["always","never","default","request"] - }, - { - "name": "uniqueness", - "type": "string", - "multiValued": false, - "description": "Indicates how unique a value must be.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "canonicalValues": ["none","server","global"] - }, - { - "name": "referenceTypes", - "type": "string", - "multiValued": true, - "description": "Specifies a resourceType that a reference attribute may refer to.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "subAttributes", - "type": "complex", - "multiValued": true, - "description": "Used to define the sub-attributes of a complex attribute", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttribtes": [ - { - "name": "name", - "type": "string", - "multiValued": false, - "description": "The sub-attribute's name", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "type", - "type": "string", - "multiValued": false, - "description": "The sub-attribute's data type.", - "required": true, - "canonicalValues": ["string","complex","boolean","decimal","integer","dateTime","reference"], - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "multiValued", - "type": "boolean", - "multiValued": false, - "description": "Boolean indicating sub-attribute plurality.", - "required": true, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "description", - "type": "string", - "multiValued": false, - "description": "Human readable description of the sub-attribute.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": false, - "description": "Whether the sub-attribute is required.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "canonicalValues", - "type": "string", - "multiValued": true, - "description": "A collection of canonical values for the sub-attribute.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "caseExact", - "type": "boolean", - "multiValued": false, - "description": "Case sensitivity of the sub-attribute.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "mutability", - "type": "string", - "multiValued": false, - "description": "Modifiability of the sub-attribute.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "canonicalValues": ["readOnly","readWrite","immutable","writeOnly"] - }, - { - "name": "returned", - "type": "string", - "multiValued": false, - "description": "When the sub-attribute is returned.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "canonicalValues": ["always","never","default","request"] - }, - { - "name": "uniqueness", - "type": "string", - "multiValued": false, - "description": "Uniqueness constraint of the sub-attribute.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "canonicalValues": ["none","server","global"] - }, - { - "name": "referenceTypes", - "type": "string", - "multiValued": true, - "description": "ResourceTypes that the sub-attribute may reference.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - } - ] - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Schema", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/fixtures/service_provider_config.json b/ee/api/routers/scim/fixtures/service_provider_config.json deleted file mode 100644 index 38a5079ae..000000000 --- a/ee/api/routers/scim/fixtures/service_provider_config.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "schemas": [ - "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" - ], - "patch": { - "supported": true - }, - "bulk": { - "supported": false, - "maxOperations": 0, - "maxPayloadSize": 0 - }, - "filter": { - "supported": true, - "maxResults": 10 - }, - "changePassword": { - "supported": false - }, - "sort": { - "supported": false - }, - "etag": { - "supported": false - }, - "authenticationSchemes": [ - { - "type": "oauthbearertoken", - "name": "OAuth Bearer Token", - "description": "Authentication scheme using the OAuth Bearer Token Standard. The access token should be sent in the 'Authorization' header using the Bearer schema.", - "specUri": "https://tools.ietf.org/html/rfc6750", - "primary": true - } - ], - "meta": { - "resourceType": "ServiceProviderConfig", - "created": "2025-04-15T15:45:00Z", - "lastModified": "2025-04-15T15:45:00Z", - "location": "/ServiceProviderConfig" - } -} diff --git a/ee/api/routers/scim/fixtures/service_provider_config_schema.json b/ee/api/routers/scim/fixtures/service_provider_config_schema.json deleted file mode 100644 index 2a90e8de4..000000000 --- a/ee/api/routers/scim/fixtures/service_provider_config_schema.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "name": "Service Provider Configuration", - "description": "Schema for representing the service provider's configuration", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "documentationUri", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "An HTTP addressable URL pointing to the service provider's human consumable help documentation.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "patch", - "type": "complex", - "multiValued": false, - "description": "A complex type that specifies PATCH configuration options.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "Boolean value specifying whether the operation is supported.", - "required": true, - "mutability": "readOnly", - "returned": "default" - } - ] - }, - { - "name": "bulk", - "type": "complex", - "multiValued": false, - "description": "A complex type that specifies BULK configuration options.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "Boolean value specifying whether the operation is supported.", - "required": true, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "maxOperations", - "type": "integer", - "multiValued": false, - "description": "An integer value specifying the maximum number of operations.", - "required": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "maxPayloadSize", - "type": "integer", - "multiValued": false, - "description": "An integer value specifying the maximum payload size in bytes.", - "required": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "filter", - "type": "complex", - "multiValued": false, - "description": "A complex type that specifies FILTER options.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "Boolean value specifying whether the operation is supported.", - "required": true, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "maxResults", - "type": "integer", - "multiValued": false, - "description": "Integer value specifying the maximum number of resources returned in a response.", - "required": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "changePassword", - "type": "complex", - "multiValued": false, - "description": "A complex type that specifies change password options.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "Boolean value specifying whether the operation is supported.", - "required": true, - "mutability": "readOnly", - "returned": "default" - } - ] - }, - { - "name": "sort", - "type": "complex", - "multiValued": false, - "description": "A complex type that specifies sort result options.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": false, - "description": "Boolean value specifying whether the operation is supported.", - "required": true, - "mutability": "readOnly", - "returned": "default" - } - ] - }, - { - "name": "authenticationSchemes", - "type": "complex", - "multiValued": true, - "description": "A complex type that specifies supported Authentication Scheme properties.", - "required": true, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "name", - "type": "string", - "multiValued": false, - "description": "The common authentication scheme name; e.g., HTTP Basic.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": false, - "description": "A description of the authentication scheme.", - "required": true, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "specUri", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "An HTTP addressable URL pointing to the Authentication Scheme's specification.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "documentationUri", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "An HTTP addressable URL pointing to the Authentication Scheme's usage documentation.", - "required": false, - "caseExact": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json deleted file mode 100644 index c80a084c5..000000000 --- a/ee/api/routers/scim/fixtures/user_schema.json +++ /dev/null @@ -1,387 +0,0 @@ -{ - "id": "urn:ietf:params:scim:schemas:core:2.0:User", - "name": "User", - "description": "User Account", - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "attributes": [ - { - "name": "schemas", - "type": "string", - "multiValued": true, - "description": "An array of Strings containing URI that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON structure.", - "required": true, - "caseExact": false, - "mutability": "immutable", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "id", - "type": "string", - "multiValued": false, - "description": "Unique identifier for the resource, assigned by the service provider. MUST be non-empty, unique, stable, and non-reassignable. Clients MUST NOT specify this value.", - "required": true, - "caseExact": true, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "server" - }, - { - "name": "externalId", - "type": "string", - "multiValued": false, - "description": "Identifier for the resource as defined by the provisioning client. OPTIONAL; clients MAY include a non-empty value.", - "required": false, - "caseExact": true, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "meta", - "type": "complex", - "multiValued": false, - "description": "Resource metadata. MUST be ignored when provided by clients.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { - "name": "resourceType", - "type": "string", - "multiValued": false, - "description": "The resource type name.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "created", - "type": "dateTime", - "multiValued": false, - "description": "The date and time the resource was added.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "lastModified", - "type": "dateTime", - "multiValued": false, - "description": "The most recent date and time the resource was modified.", - "required": false, - "mutability": "readOnly", - "returned": "default" - }, - { - "name": "location", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "The URI of the resource being returned.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "version", - "type": "string", - "multiValued": false, - "description": "The version (ETag) of the resource being returned.", - "required": false, - "caseExact": true, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "userName", - "type": "string", - "multiValued": false, - "description": "Unique identifier for the User, used to authenticate. REQUIRED.", - "required": true, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "server" - }, - { - "name": "name", - "type": "complex", - "multiValued": false, - "description": "Components of the user's real name.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { "name": "formatted", "type": "string", "multiValued": false, "description": "Complete name, formatted for display.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "familyName", "type": "string", "multiValued": false, "description": "Family name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "givenName", "type": "string", "multiValued": false, "description": "Given name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "middleName", "type": "string", "multiValued": false, "description": "Middle name(s).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "honorificPrefix","type": "string", "multiValued": false, "description": "Honorific prefix.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "honorificSuffix","type": "string", "multiValued": false, "description": "Honorific suffix.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" } - ] - }, - { - "name": "displayName", - "type": "string", - "multiValued": false, - "description": "Full name, suitable for display.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "nickName", - "type": "string", - "multiValued": false, - "description": "Casual name.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "profileUrl", - "type": "reference", - "referenceTypes": ["external"], - "multiValued": false, - "description": "URL of the user's profile.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "title", - "type": "string", - "multiValued": false, - "description": "User's title (e.g., 'Vice President').", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "userType", - "type": "string", - "multiValued": false, - "description": "Relationship between organization and user.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "preferredLanguage", - "type": "string", - "multiValued": false, - "description": "Preferred language, e.g., 'en_US'.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "locale", - "type": "string", - "multiValued": false, - "description": "Locale for formatting, e.g., 'en-US'.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "timezone", - "type": "string", - "multiValued": false, - "description": "Time zone in Olson format, e.g., 'America/Los_Angeles'.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "active", - "type": "boolean", - "multiValued": false, - "description": "Administrative status.", - "required": false, - "mutability": "readWrite", - "returned": "default" - }, - { - "name": "password", - "type": "string", - "multiValued": false, - "description": "Cleartext password for create/reset operations.", - "required": false, - "caseExact": false, - "mutability": "writeOnly", - "returned": "never", - "uniqueness": "none" - }, - { - "name": "emails", - "type": "complex", - "multiValued": true, - "description": "Email addresses for the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "Email address.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type: 'work','home','other'.", "required": false, "caseExact": false, "canonicalValues": ["work","home","other"], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "phoneNumbers", - "type": "complex", - "multiValued": true, - "description": "Phone numbers for the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "Phone number (tel URI).", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type: 'work','home','mobile','fax','pager','other'.", "required": false, "caseExact": false, "canonicalValues": ["work","home","mobile","fax","pager","other"], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "ims", - "type": "complex", - "multiValued": true, - "description": "Instant messaging addresses.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "IM address.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type: 'aim','gtalk','icq','xmpp','msn','skype','qq','yahoo'.", "required": false, "caseExact": false, "canonicalValues": ["aim","gtalk","icq","xmpp","msn","skype","qq","yahoo"], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "photos", - "type": "complex", - "multiValued": true, - "description": "URLs of photos of the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "reference", "referenceTypes": ["external"], "multiValued": false, "description": "Photo URL.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type: 'photo','thumbnail'.", "required": false, "caseExact": false, "canonicalValues": ["photo","thumbnail"], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "addresses", - "type": "complex", - "multiValued": true, - "description": "Physical mailing addresses.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { "name": "formatted", "type": "string", "multiValued": false, "description": "Full address, may contain newlines.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "streetAddress", "type": "string", "multiValued": false, "description": "Street address.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "locality", "type": "string", "multiValued": false, "description": "City or locality.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "region", "type": "string", "multiValued": false, "description": "State or region.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "postalCode", "type": "string", "multiValued": false, "description": "Zip or postal code.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "country", "type": "string", "multiValued": false, "description": "Country name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type: 'work','home','other'.", "required": false, "caseExact": false, "canonicalValues": ["work","home","other"], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean","multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "groups", - "type": "complex", - "multiValued": true, - "description": "Groups to which the user belongs.", - "required": false, - "mutability": "readOnly", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "Group identifier.", "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" }, - { "name": "$ref", "type": "reference", "referenceTypes": ["User","Group"], "multiValued": false, "description": "URI of the Group resource.", "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none" } - ] - }, - { - "name": "entitlements", - "type": "complex", - "multiValued": true, - "description": "Entitlements granted to the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "Entitlement value.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type label.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "roles", - "type": "complex", - "multiValued": true, - "description": "Roles granted to the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "string", "multiValued": false, "description": "Role value.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type label.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - }, - { - "name": "x509Certificates", - "type": "complex", - "multiValued": true, - "description": "X.509 certificates issued to the user.", - "required": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { "name": "value", "type": "binary", "multiValued": false, "description": "Certificate value.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "display", "type": "string", "multiValued": false, "description": "Display name.", "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "type", "type": "string", "multiValued": false, "description": "Type label.", "required": false, "caseExact": false, "canonicalValues": [], "mutability": "readWrite", "returned": "default", "uniqueness": "none" }, - { "name": "primary", "type": "boolean", "multiValued": false, "description": "Primary flag; one per list.", "required": false, "mutability": "readWrite", "returned": "default" } - ] - } - ], - "meta": { - "resourceType": "Schema", - "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", - "created": "2025-04-17T15:48:00Z", - "lastModified": "2025-04-17T15:48:00Z" - } -} diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index 8eb9447db..dda2517a3 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -4,30 +4,14 @@ from psycopg2.extensions import AsIs from chalicelib.utils import pg_client from routers.scim import helpers -from routers.scim.resource_config import ( - ProviderResource, - ClientResource, - ResourceId, - ClientInput, - ProviderInput, -) - -def convert_client_resource_update_input_to_provider_resource_update_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - result = {} - if "displayName" in client_input: - result["name"] = client_input["displayName"] - if "members" in client_input: - members = client_input["members"] or [] - result["user_ids"] = [int(member["value"]) for member in members] - return result +from scim2_models import Error, Resource +from scim2_server.utils import SCIMException def convert_provider_resource_to_client_resource( - provider_resource: ProviderResource, -) -> ClientResource: + provider_resource: dict, +) -> dict: members = provider_resource["users"] or [] return { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], @@ -38,7 +22,6 @@ def convert_provider_resource_to_client_resource( "lastModified": provider_resource["updated_at"].strftime( "%Y-%m-%dT%H:%M:%SZ" ), - "location": f"Groups/{provider_resource['role_id']}", }, "displayName": provider_resource["name"], "members": [ @@ -52,36 +35,97 @@ def convert_provider_resource_to_client_resource( } -def get_active_resource_count(tenant_id: int, filter_clause: str | None = None) -> int: - where_and_clauses = [ - f"roles.tenant_id = {tenant_id}", - "roles.deleted_at IS NULL", - ] - if filter_clause is not None: - where_and_clauses.append(filter_clause) - where_clause = " AND ".join(where_and_clauses) +def query_resources(tenant_id: int) -> list[dict]: + query = _main_select_query(tenant_id) with pg_client.PostgresClient() as cur: + cur.execute(query) + items = cur.fetchall() + return [convert_provider_resource_to_client_resource(item) for item in items] + + +def get_resource(resource_id: str, tenant_id: int) -> dict | None: + query = _main_select_query(tenant_id, resource_id) + with pg_client.PostgresClient() as cur: + cur.execute(query) + item = cur.fetchone() + if item: + return convert_provider_resource_to_client_resource(item) + return None + + +def delete_resource(resource_id: str, tenant_id: int) -> None: + _update_resource_sql( + resource_id=resource_id, + tenant_id=tenant_id, + deleted_at=datetime.now(), + ) + + +def search_existing(tenant_id: int, resource: Resource) -> dict | None: + return None + + +def create_resource(tenant_id: int, resource: Resource) -> dict: + with pg_client.PostgresClient() as cur: + user_ids = ( + [int(x.value) for x in resource.members] if resource.members else None + ) + user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur) + try: + cur.execute( + cur.mogrify( + """ + INSERT INTO public.roles ( + name, + tenant_id + ) + VALUES ( + %(name)s, + %(tenant_id)s + ) + RETURNING role_id + """, + { + "name": resource.display_name, + "tenant_id": tenant_id, + }, + ) + ) + except Exception: + raise SCIMException(Error.make_invalid_value_error()) + role_id = cur.fetchone()["role_id"] cur.execute( f""" - SELECT COUNT(*) - FROM public.roles - WHERE {where_clause} + UPDATE public.users + SET + updated_at = now(), + role_id = {role_id} + WHERE users.user_id = ANY({user_id_clause}) """ ) - return cur.fetchone()["count"] + cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1") + item = cur.fetchone() + return convert_provider_resource_to_client_resource(item) -def _main_select_query( - tenant_id: int, resource_id: int | None = None, filter_clause: str | None = None -) -> str: +def update_resource(tenant_id: int, resource: Resource) -> dict | None: + item = _update_resource_sql( + resource_id=resource.id, + tenant_id=tenant_id, + name=resource.display_name, + user_ids=[int(x.value) for x in resource.members], + deleted_at=None, + ) + return convert_provider_resource_to_client_resource(item) + + +def _main_select_query(tenant_id: int, resource_id: str | None = None) -> str: where_and_clauses = [ f"roles.tenant_id = {tenant_id}", "roles.deleted_at IS NULL", ] if resource_id is not None: where_and_clauses.append(f"roles.role_id = {resource_id}") - if filter_clause is not None: - where_and_clauses.append(filter_clause) where_clause = " AND ".join(where_and_clauses) return f""" SELECT @@ -108,88 +152,6 @@ def _main_select_query( """ -def get_provider_resource_chunk( - offset: int, tenant_id: int, limit: int, filter_clause: str | None = None -) -> list[ProviderResource]: - query = _main_select_query(tenant_id, filter_clause=filter_clause) - with pg_client.PostgresClient() as cur: - cur.execute(f"{query} LIMIT {limit} OFFSET {offset}") - return cur.fetchall() - - -def filter_attribute_mapping() -> dict[str, str]: - return {"displayName": "roles.name"} - - -def get_provider_resource( - resource_id: ResourceId, tenant_id: int -) -> ProviderResource | None: - with pg_client.PostgresClient() as cur: - cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1") - return cur.fetchone() - - -def convert_client_resource_creation_input_to_provider_resource_creation_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - return { - "name": client_input["displayName"], - "user_ids": [ - int(member["value"]) for member in client_input.get("members", []) - ], - } - - -def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - return { - "name": client_input["displayName"], - "user_ids": [ - int(member["value"]) for member in client_input.get("members", []) - ], - } - - -def create_provider_resource( - name: str, - tenant_id: int, - user_ids: list[str] | None = None, - **kwargs: dict[str, Any], -) -> ProviderResource: - with pg_client.PostgresClient() as cur: - kwargs["name"] = name - kwargs["tenant_id"] = tenant_id - column_fragments = [ - cur.mogrify("%s", (AsIs(k),)).decode("utf-8") for k in kwargs.keys() - ] - column_clause = ", ".join(column_fragments) - value_fragments = [ - cur.mogrify("%s", (v,)).decode("utf-8") for v in kwargs.values() - ] - value_clause = ", ".join(value_fragments) - user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur) - cur.execute( - f""" - INSERT INTO public.roles ({column_clause}) - VALUES ({value_clause}) - RETURNING role_id - """ - ) - role_id = cur.fetchone()["role_id"] - cur.execute( - f""" - UPDATE public.users - SET - updated_at = now(), - role_id = {role_id} - WHERE users.user_id = ANY({user_id_clause}) - """ - ) - cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1") - return cur.fetchone() - - def _update_resource_sql( resource_id: int, tenant_id: int, @@ -235,42 +197,7 @@ def _update_resource_sql( WHERE roles.role_id = {resource_id} AND roles.tenant_id = {tenant_id} - AND roles.deleted_at IS NULL """ ) cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1") return cur.fetchone() - - -def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: - _update_resource_sql( - resource_id=resource_id, - tenant_id=tenant_id, - deleted_at=datetime.now(), - ) - - -def rewrite_provider_resource( - resource_id: int, - tenant_id: int, - name: str, - **kwargs: dict[str, Any], -) -> dict[str, Any]: - return _update_resource_sql( - resource_id=resource_id, - tenant_id=tenant_id, - name=name, - **kwargs, - ) - - -def update_provider_resource( - resource_id: int, - tenant_id: int, - **kwargs: dict[str, Any], -): - return _update_resource_sql( - resource_id=resource_id, - tenant_id=tenant_id, - **kwargs, - ) diff --git a/ee/api/routers/scim/helpers.py b/ee/api/routers/scim/helpers.py index bb6c56fec..85c9c49f2 100644 --- a/ee/api/routers/scim/helpers.py +++ b/ee/api/routers/scim/helpers.py @@ -1,7 +1,8 @@ from typing import Any, Literal -from copy import deepcopy -import re from chalicelib.utils import pg_client +from scim2_models import Schema, Resource, ResourceType +import os +import json def safe_mogrify_array( @@ -15,481 +16,29 @@ def safe_mogrify_array( return result -def convert_query_str_to_list(query_str: str | None) -> list[str]: - if query_str is None: - return None - return query_str.split(",") +def load_json_resource(json_name: str) -> dict: + with open(json_name) as f: + return json.load(f) -def get_all_attribute_names(schema: dict[str, Any]) -> list[str]: - result = [] - - def _walk(attrs, prefix=None): - for attr in attrs: - name = attr["name"] - path = f"{prefix}.{name}" if prefix else name - result.append(path) - if attr["type"] == "complex": - sub = attr.get("subAttributes") or attr.get("attributes") or [] - _walk(sub, path) - - _walk(schema["attributes"]) - return result +def load_scim_resource( + json_name: str, type_: type[Resource] +) -> dict[str, type[Resource]]: + ret = {} + definitions = load_json_resource(json_name) + for d in definitions: + model = type_.model_validate(d) + ret[model.id] = model + return ret -def get_all_attribute_names_where_returned_is_always( - schema: dict[str, Any], -) -> list[str]: - result = [] - - def _walk(attrs, prefix=None): - for attr in attrs: - name = attr["name"] - path = f"{prefix}.{name}" if prefix else name - if attr["returned"] == "always": - result.append(path) - if attr["type"] == "complex": - sub = attr.get("subAttributes") or attr.get("attributes") or [] - _walk(sub, path) - - _walk(schema["attributes"]) - return result +def load_custom_schemas() -> dict[str, Schema]: + json_name = os.path.join("routers", "scim", "fixtures", "custom_schemas.json") + return load_scim_resource(json_name, Schema) -def filter_attributes( - obj: dict[str, Any], - attributes_query_str: str | None, - excluded_attributes_query_str: str | None, - schema: dict[str, Any], -) -> dict[str, Any]: - all_attributes = get_all_attribute_names(schema) - always_returned_attributes = get_all_attribute_names_where_returned_is_always( - schema +def load_custom_resource_types() -> dict[str, ResourceType]: + json_name = os.path.join( + "routers", "scim", "fixtures", "custom_resource_types.json" ) - included_attributes = convert_query_str_to_list(attributes_query_str) - included_attributes = included_attributes or all_attributes - included_attributes_set = set(included_attributes).union( - set(always_returned_attributes) - ) - excluded_attributes = convert_query_str_to_list(excluded_attributes_query_str) - excluded_attributes = excluded_attributes or [] - excluded_attributes_set = set(excluded_attributes).difference( - set(always_returned_attributes) - ) - include_paths = included_attributes_set.difference(excluded_attributes_set) - - include_tree = {} - for path in include_paths: - parts = path.split(".") - node = include_tree - for part in parts: - node = node.setdefault(part, {}) - - def _recurse(o, tree, parent_key=None): - if isinstance(o, dict): - out = {} - for key, subtree in tree.items(): - if key in o: - out[key] = _recurse(o[key], subtree, key) - return out - if isinstance(o, list): - out = [_recurse(item, tree, parent_key) for item in o] - return out - return o - - result = _recurse(obj, include_tree) - return result - - -def filter_mutable_attributes( - schema: dict[str, Any], - requested_changes: dict[str, Any], - current_values: dict[str, Any], -) -> dict[str, Any]: - attributes = {attr.get("name"): attr for attr in schema.get("attributes", [])} - - valid_changes = {} - - for attr_name, new_value in requested_changes.items(): - attr_def = attributes.get(attr_name) - if not attr_def: - # Unknown attribute: ignore per RFC 7644 - continue - - mutability = attr_def.get("mutability", "readWrite") - - if mutability == "readWrite" or mutability == "writeOnly": - valid_changes[attr_name] = new_value - - elif mutability == "readOnly": - # Cannot modify read-only attributes: ignore - continue - - elif mutability == "immutable": - # Only valid if the new value matches the current value exactly - current_value = current_values.get(attr_name) - if new_value != current_value: - raise ValueError( - f"Attribute '{attr_name}' is immutable (cannot change). " - f"Current value: {current_value!r}, attempted change: {new_value!r}" - ) - # If it matches, no change is needed (already set) - - return valid_changes - - -def apply_scim_patch( - operations: list[dict[str, Any]], resource: dict[str, Any], schema: dict[str, Any] -) -> dict[str, Any]: - """ - Apply SCIM patch operations to a resource based on schema. - Returns (updated_resource, changes) where `updated_resource` is the new SCIM - resource dict and `changes` maps attribute or path to (old_value, new_value). - Additions have old_value=None if attribute didn't exist; removals have new_value=None. - For add/remove on list-valued attributes, changes record the full list before/after. - """ - # Deep copy to avoid mutating original - updated = deepcopy(resource) - changes = {} - - # Allowed attributes from schema - allowed_attrs = {attr["name"]: attr for attr in schema.get("attributes", [])} - - for op in operations: - op_type = op.get("op", "").strip().lower() - path = op.get("path") - value = op.get("value") - - if not path: - # Top-level merge - if op_type in ("add", "replace"): - if not isinstance(value, dict): - raise ValueError( - "When path is not provided, value must be a dict of attributes to merge." - ) - for attr, val in value.items(): - if attr not in allowed_attrs: - raise ValueError( - f"Attribute '{attr}' not defined in SCIM schema" - ) - old = updated.get(attr) - updated[attr] = val if val is not None else updated.pop(attr, None) - changes[attr] = (old, val) - else: - raise ValueError(f"Unsupported operation without path: {op_type}") - continue - - tokens = parse_scim_path(path) - - # Detect simple top-level list add/remove - if ( - op_type in ("add", "remove") - and len(tokens) == 1 - and isinstance(tokens[0], str) - ): - attr = tokens[0] - if attr not in allowed_attrs: - raise ValueError(f"Attribute '{attr}' not defined in SCIM schema") - current_list = updated.get(attr, []) - if isinstance(current_list, list): - before = deepcopy(current_list) - if op_type == "add": - # Ensure list exists - updated.setdefault(attr, []) - # Append new items - items = value if isinstance(value, list) else [value] - updated[attr].extend(items) - else: # remove - # Remove items matching filter if value not provided - # For remove on list without filter, remove all values equal to value - if value is None: - updated.pop(attr, None) - else: - # filter value items out - items = value if isinstance(value, list) else [value] - updated[attr] = [ - e for e in updated.get(attr, []) if e not in items - ] - after = deepcopy(updated.get(attr, [])) - changes[attr] = (before, after) - continue - - # For other operations, get old value and apply normally - old_val = get_by_path(updated, tokens) - - if op_type == "add": - set_by_path(updated, tokens, value) - elif op_type == "replace": - if value is None: - remove_by_path(updated, tokens) - else: - set_by_path(updated, tokens, value) - elif op_type == "remove": - remove_by_path(updated, tokens) - else: - raise ValueError(f"Unsupported operation type: {op_type}") - - # Record change for non-list or nested paths - new_val = None if op_type == "remove" else get_by_path(updated, tokens) - changes[path] = (old_val, new_val) - - return updated, changes - - -def parse_scim_path(path): - """ - Parse a SCIM-style path (e.g., 'emails[type eq "work"].value') into a list - of tokens. Each token is either a string attribute name or a tuple - (attr, filter_attr, filter_value) for list-filtering. - """ - tokens = [] - # Regex matches segments like attr or attr[filter] where filter is e.g. type eq "work" - segment_re = re.compile(r"([^\.\[]+)(?:\[(.*?)\])?") - for match in segment_re.finditer(path): - attr = match.group(1) - filt = match.group(2) - if filt: - # Support simple equality filter of form: subAttr eq "value" - m = re.match(r"\s*(\w+)\s+eq\s+\"([^\"]+)\"", filt) - if not m: - raise ValueError(f"Unsupported filter expression: {filt}") - filter_attr, filter_val = m.group(1), m.group(2) - tokens.append((attr, filter_attr, filter_val)) - else: - tokens.append(attr) - return tokens - - -def get_by_path(doc, tokens): - """ - Retrieve a value from nested dicts/lists using parsed tokens. - Returns None if any step is missing. - """ - cur = doc - for token in tokens: - if cur is None: - return None - if isinstance(token, tuple): - attr, fattr, fval = token - lst = cur.get(attr) - if not isinstance(lst, list): - return None - # Find first dict element matching filter - for elem in lst: - if isinstance(elem, dict) and elem.get(fattr) == fval: - cur = elem - break - else: - return None - else: - if isinstance(cur, dict): - cur = cur.get(token) - elif isinstance(cur, list) and isinstance(token, int): - if 0 <= token < len(cur): - cur = cur[token] - else: - return None - else: - return None - return cur - - -def set_by_path(doc, tokens, value): - """ - Set a value in nested dicts/lists using parsed tokens. - Creates intermediate dicts/lists as needed. - """ - cur = doc - for i, token in enumerate(tokens): - last = i == len(tokens) - 1 - if isinstance(token, tuple): - attr, fattr, fval = token - lst = cur.setdefault(attr, []) - if not isinstance(lst, list): - raise ValueError(f"Expected list at attribute '{attr}'") - # Find existing entry - idx = next( - ( - j - for j, e in enumerate(lst) - if isinstance(e, dict) and e.get(fattr) == fval - ), - None, - ) - if idx is None: - if last: - lst.append(value) - return - else: - new = {} - lst.append(new) - cur = new - else: - if last: - lst[idx] = value - return - cur = lst[idx] - - else: - if last: - if value is None: - if isinstance(cur, dict): - cur.pop(token, None) - else: - cur[token] = value - else: - cur = cur.setdefault(token, {}) - - -def remove_by_path(doc, tokens): - """ - Remove a value in nested dicts/lists using parsed tokens. - Does nothing if path not present. - """ - cur = doc - for i, token in enumerate(tokens): - last = i == len(tokens) - 1 - if isinstance(token, tuple): - attr, fattr, fval = token - lst = cur.get(attr) - if not isinstance(lst, list): - return - for j, elem in enumerate(lst): - if isinstance(elem, dict) and elem.get(fattr) == fval: - if last: - lst.pop(j) - return - cur = elem - break - else: - return - else: - if last: - if isinstance(cur, dict): - cur.pop(token, None) - elif isinstance(cur, list) and isinstance(token, int): - if 0 <= token < len(cur): - cur.pop(token) - return - else: - if isinstance(cur, dict): - cur = cur.get(token) - elif isinstance(cur, list) and isinstance(token, int): - cur = cur[token] if 0 <= token < len(cur) else None - else: - return - - -class SCIMFilterParser: - _TOK_RE = re.compile( - r""" - (?:"[^"]*"|'[^']*')| # double- or single-quoted string - \band\b|\bor\b|\bnot\b| - \beq\b|\bne\b|\bco\b|\bsw\b|\bew\b|\bgt\b|\blt\b|\bge\b|\ble\b|\bpr\b| - [()]| # parentheses - [^\s()]+ # bare token - """, - re.IGNORECASE | re.VERBOSE, - ) - _NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$") - - def __init__(self, text: str, attr_map: dict[str, str]): - self.tokens = [tok for tok in self._TOK_RE.findall(text)] - self.pos = 0 - self.attr_map = attr_map - - def peek(self) -> str | None: - return self.tokens[self.pos].lower() if self.pos < len(self.tokens) else None - - def next(self) -> str: - tok = self.tokens[self.pos] - self.pos += 1 - return tok - - def parse(self) -> str: - expr = self._parse_or() - if self.pos != len(self.tokens): - raise ValueError(f"Unexpected token at end: {self.peek()}") - return expr - - def _parse_or(self) -> str: - left = self._parse_and() - while self.peek() == "or": - self.next() - right = self._parse_and() - left = f"({left} OR {right})" - return left - - def _parse_and(self) -> str: - left = self._parse_not() - while self.peek() == "and": - self.next() - right = self._parse_not() - left = f"({left} AND {right})" - return left - - def _parse_not(self) -> str: - if self.peek() == "not": - self.next() - inner = self._parse_simple() - return f"(NOT {inner})" - return self._parse_simple() - - def _parse_simple(self) -> str: - if self.peek() == "(": - self.next() - expr = self._parse_or() - if self.next() != ")": - raise ValueError("Missing closing parenthesis") - return f"({expr})" - return self._parse_comparison() - - def _parse_comparison(self) -> str: - raw_attr = self.next() - col = self.attr_map.get(raw_attr, raw_attr) - op = self.next().lower() - - if op == "pr": - return f"{col} IS NOT NULL" - - val = self.next() - - # strip quotes if present (single or double) - if (val.startswith('"') and val.endswith('"')) or ( - val.startswith("'") and val.endswith("'") - ): - inner = val[1:-1].replace("'", "''") - sql_val = f"'{inner}'" - elif self._NUMERIC_RE.match(val): - sql_val = val - else: - inner = val.replace("'", "''") - sql_val = f"'{inner}'" - - if op == "eq": - return f"{col} = {sql_val}" - if op == "ne": - return f"{col} <> {sql_val}" - if op == "co": - return f"{col} LIKE '%' || {sql_val} || '%'" - if op == "sw": - return f"{col} LIKE {sql_val} || '%'" - if op == "ew": - return f"{col} LIKE '%' || {sql_val}" - if op in ("gt", "lt", "ge", "le"): - sql_ops = {"gt": ">", "lt": "<", "ge": ">=", "le": "<="} - return f"{col} {sql_ops[op]} {sql_val}" - - raise ValueError(f"Unknown operator: {op}") - - -def scim_to_sql_where(filter_str: str | None, attr_map: dict[str, str]) -> str | None: - """ - Convert a SCIM filter into an SQL WHERE fragment, - mapping SCIM attributes per attr_map and correctly quoting - both single- and double-quoted strings. - """ - if filter_str is None: - return None - parser = SCIMFilterParser(filter_str, attr_map) - return parser.parse() + return load_scim_resource(json_name, ResourceType) diff --git a/ee/api/routers/scim/postgres_resource.py b/ee/api/routers/scim/postgres_resource.py new file mode 100644 index 000000000..c06bc17f6 --- /dev/null +++ b/ee/api/routers/scim/postgres_resource.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import Callable +from scim2_models import Resource + + +@dataclass +class PostgresResource: + query_resources: Callable[[int], list[dict]] + get_resource: Callable[[str, int], dict | None] + create_resource: Callable[[int, Resource], dict] + search_existing: Callable[[int, Resource], dict | None] + restore_resource: Callable[[int, Resource], dict] | None + delete_resource: Callable[[str, int], None] + update_resource: Callable[[int, Resource], dict] diff --git a/ee/api/routers/scim/providers.py b/ee/api/routers/scim/providers.py new file mode 100644 index 000000000..be24acc6e --- /dev/null +++ b/ee/api/routers/scim/providers.py @@ -0,0 +1,280 @@ +import traceback +from typing import Union + +from scim2_server import provider + +from scim2_models import ( + AuthenticationScheme, + ServiceProviderConfig, + Patch, + Bulk, + Filter, + Sort, + ETag, + Meta, + ChangePassword, + Error, + ResourceType, + Context, + ListResponse, + PatchOp, +) + +from werkzeug import Request, Response +from werkzeug.exceptions import HTTPException, NotFound, PreconditionFailed +from pydantic import ValidationError +from werkzeug.routing.exceptions import RequestRedirect +from scim2_server.utils import SCIMException, merge_resources + +from chalicelib.utils.scim_auth import verify_access_token + + +class MultiTenantProvider(provider.SCIMProvider): + def check_auth(self, request: Request): + auth = request.headers.get("Authorization") + if not auth or not auth.startswith("Bearer "): + return None + token = auth[len("Bearer ") :] + if not token: + return Response( + "Missing or invalid Authorization header", + status=401, + headers={"WWW-Authenticate": 'Bearer realm="login required"'}, + ) + payload = verify_access_token(token) + tenant_id = payload["tenant_id"] + return tenant_id + + def get_service_provider_config(self): + auth_schemes = [ + AuthenticationScheme( + type="oauthbearertoken", + name="OAuth Bearer Token", + description="Authentication scheme using the OAuth Bearer Token Standard. The access token should be sent in the 'Authorization' header using the Bearer schema.", + spec_uri="https://datatracker.ietf.org/doc/html/rfc6750", + ) + ] + return ServiceProviderConfig( + # todo(jon): write correct documentation uri + documentation_uri="https://www.example.com/", + patch=Patch(supported=True), + bulk=Bulk(supported=False), + filter=Filter(supported=True, max_results=1000), + change_password=ChangePassword(supported=False), + sort=Sort(supported=True), + etag=ETag(supported=False), + authentication_schemes=auth_schemes, + meta=Meta(resource_type="ServiceProviderConfig"), + ) + + def query_resource( + self, request: Request, tenant_id: int, resource: ResourceType | None + ): + search_request = self.build_search_request(request) + + kwargs = {} + if resource is not None: + kwargs["resource_type_id"] = resource.id + total_results, results = self.backend.query_resources( + search_request=search_request, tenant_id=tenant_id, **kwargs + ) + for r in results: + self.adjust_location(request, r) + + resources = [ + s.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + attributes=search_request.attributes, + excluded_attributes=search_request.excluded_attributes, + ) + for s in results + ] + + return ListResponse[Union[tuple(self.backend.get_models())]]( # noqa: UP007 + total_results=total_results, + items_per_page=search_request.count, + start_index=search_request.start_index, + resources=resources, + ) + + def call_resource( + self, request: Request, resource_endpoint: str, **kwargs + ) -> Response: + resource_type = self.backend.get_resource_type_by_endpoint( + "/" + resource_endpoint + ) + if not resource_type: + raise NotFound + + if "tenant_id" not in kwargs: + raise Exception + tenant_id = kwargs["tenant_id"] + + match request.method: + case "GET": + return self.make_response( + self.query_resource(request, tenant_id, resource_type).model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + ) + case _: # "POST" + payload = request.json + resource = self.backend.get_model(resource_type.id).model_validate( + payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + created_resource = self.backend.create_resource( + tenant_id, + resource_type.id, + resource, + ) + self.adjust_location(request, created_resource) + return self.make_response( + created_resource.model_dump( + scim_ctx=Context.RESOURCE_CREATION_RESPONSE + ), + status=201, + headers={"Location": created_resource.meta.location}, + ) + + def call_single_resource( + self, request: Request, resource_endpoint: str, resource_id: str, **kwargs + ) -> Response: + find_endpoint = "/" + resource_endpoint + resource_type = self.backend.get_resource_type_by_endpoint(find_endpoint) + if not resource_type: + raise NotFound + + if "tenant_id" not in kwargs: + raise Exception + tenant_id = kwargs["tenant_id"] + + match request.method: + case "GET": + if resource := self.backend.get_resource( + tenant_id, resource_type.id, resource_id + ): + if self.continue_etag(request, resource): + response_args = self.get_attrs_from_request(request) + self.adjust_location(request, resource) + return self.make_response( + resource.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + **response_args, + ) + ) + else: + return self.make_response(None, status=304) + raise NotFound + case "DELETE": + if self.backend.delete_resource( + tenant_id, resource_type.id, resource_id + ): + return self.make_response(None, 204) + else: + raise NotFound + case "PUT": + response_args = self.get_attrs_from_request(request) + resource = self.backend.get_resource( + tenant_id, resource_type.id, resource_id + ) + if resource is None: + raise NotFound + if not self.continue_etag(request, resource): + raise PreconditionFailed + + updated_attributes = self.backend.get_model( + resource_type.id + ).model_validate(request.json) + merge_resources(resource, updated_attributes) + updated = self.backend.update_resource( + tenant_id, resource_type.id, resource + ) + self.adjust_location(request, updated) + return self.make_response( + updated.model_dump( + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + **response_args, + ) + ) + case _: # "PATCH" + payload = request.json + # MS Entra sometimes passes a "id" attribute + if "id" in payload: + del payload["id"] + operations = payload.get("Operations", []) + for operation in operations: + if "name" in operation: + # MS Entra sometimes passes a "name" attribute + del operation["name"] + + patch_operation = PatchOp.model_validate(payload) + response_args = self.get_attrs_from_request(request) + resource = self.backend.get_resource( + tenant_id, resource_type.id, resource_id + ) + if resource is None: + raise NotFound + if not self.continue_etag(request, resource): + raise PreconditionFailed + + self.apply_patch_operation(resource, patch_operation) + updated = self.backend.update_resource( + tenant_id, resource_type.id, resource + ) + + if response_args: + self.adjust_location(request, updated) + return self.make_response( + updated.model_dump( + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + **response_args, + ) + ) + else: + # RFC 7644, section 3.5.2: + # A PATCH operation MAY return a 204 (no content) + # if no attributes were requested + return self.make_response( + None, 204, headers={"ETag": updated.meta.version} + ) + + def wsgi_app(self, request: Request, environ): + try: + if environ.get("PATH_INFO", "").endswith(".scim"): + # RFC 7644, Section 3.8 + # Just strip .scim suffix, the provider always returns application/scim+json + environ["PATH_INFO"], _, _ = environ["PATH_INFO"].rpartition(".scim") + urls = self.url_map.bind_to_environ(environ) + endpoint, args = urls.match() + + tenant_id = None + if endpoint != "service_provider_config": + # RFC7643, Section 5: skip authentication for ServiceProviderConfig + tenant_id = self.check_auth(request) + + # Wrap the entire call in a transaction. Should probably be optimized (use transaction only when necessary). + with self.backend: + if endpoint == "service_provider_config" or endpoint == "schema": + response = getattr(self, f"call_{endpoint}")(request, **args) + else: + response = getattr(self, f"call_{endpoint}")( + request, **args, tenant_id=tenant_id + ) + return response + except RequestRedirect as e: + # urls.match may cause a redirect, handle it as a special case of HTTPException + self.log.exception(e) + return e.get_response(environ) + except HTTPException as e: + self.log.exception(e) + return self.make_error(Error(status=e.code, detail=e.description)) + except SCIMException as e: + self.log.exception(e) + return self.make_error(e.scim_error) + except ValidationError as e: + self.log.exception(e) + return self.make_error(Error(status=400, detail=str(e))) + except Exception as e: + self.log.exception(e) + tb = traceback.format_exc() + return self.make_error(Error(status=500, detail=str(e) + "\n" + tb)) diff --git a/ee/api/routers/scim/resource_config.py b/ee/api/routers/scim/resource_config.py deleted file mode 100644 index 84bbf57f1..000000000 --- a/ee/api/routers/scim/resource_config.py +++ /dev/null @@ -1,92 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable - -from routers.scim.constants import ( - SCHEMA_IDS_TO_SCHEMA_DETAILS, - RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS, -) -from routers.scim import helpers - - -Schema = dict[str, Any] -ProviderResource = dict[str, Any] -ClientResource = dict[str, Any] -ResourceId = int | str -ClientInput = dict[str, Any] -ProviderInput = dict[str, Any] - - -@dataclass -class ResourceConfig: - resource_type_id: str - max_chunk_size: int - get_active_resource_count: Callable[[int], int] - convert_provider_resource_to_client_resource: Callable[ - [ProviderResource], ClientResource - ] - get_provider_resource_chunk: Callable[[int, int, int], list[ProviderResource]] - get_provider_resource: Callable[[ResourceId, int], ProviderResource | None] - convert_client_resource_creation_input_to_provider_resource_creation_input: ( - Callable[[int, ClientInput], ProviderInput] - ) - get_provider_resource_from_unique_fields: Callable[..., ProviderResource | None] - restore_provider_resource: Callable[..., ProviderResource] | None - create_provider_resource: Callable[..., ProviderResource] - delete_provider_resource: Callable[[ResourceId, int], None] - convert_client_resource_rewrite_input_to_provider_resource_rewrite_input: Callable[ - [int, ClientInput], ProviderInput - ] - rewrite_provider_resource: Callable[..., ProviderResource] - convert_client_resource_update_input_to_provider_resource_update_input: Callable[ - [int, ClientInput], ProviderInput - ] - update_provider_resource: Callable[..., ProviderResource] - filter_attribute_mapping: Callable[None, dict[str, str]] - - -def get_schema(config: ResourceConfig) -> Schema: - resource_type_id = config.resource_type_id - resource_type = RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_type_id] - main_schema_id = resource_type["schema"] - schema_extension_ids = [ - item["schema"] for item in resource_type["schemaExtensions"] - ] - result = SCHEMA_IDS_TO_SCHEMA_DETAILS[main_schema_id] - for schema_id in schema_extension_ids: - result["attributes"].extend( - SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id]["attributes"] - ) - result["schemas"] = [main_schema_id, *schema_extension_ids] - return result - - -def convert_provider_resource_to_client_resource( - config: ResourceConfig, - provider_resource: ProviderResource, - attributes_query_str: str | None, - excluded_attributes_query_str: str | None, -) -> ClientResource: - client_resource = config.convert_provider_resource_to_client_resource( - provider_resource - ) - schema = get_schema(config) - client_resource = helpers.filter_attributes( - client_resource, attributes_query_str, excluded_attributes_query_str, schema - ) - return client_resource - - -def get_resource( - config: ResourceConfig, - resource_id: ResourceId, - tenant_id: int, - attributes: str | None = None, - excluded_attributes: str | None = None, -) -> ClientResource | None: - provider_resource = config.get_provider_resource(resource_id, tenant_id) - if provider_resource is None: - return None - client_resource = convert_provider_resource_to_client_resource( - config, provider_resource, attributes, excluded_attributes - ) - return client_resource diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 41072f5b5..7e42c4ebd 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -1,168 +1,12 @@ -from typing import Any -from datetime import datetime -from psycopg2.extensions import AsIs from routers.scim import helpers from chalicelib.utils import pg_client -from routers.scim.resource_config import ( - ProviderResource, - ClientResource, - ResourceId, - ClientInput, - ProviderInput, -) -from schemas.schemas_ee import Permissions - - -def _is_valid_permission_for_identity_provider(permission: str) -> bool: - permission_display_to_value_mapping = { - "Session Replay": Permissions.SESSION_REPLAY, - "Developer Tools": Permissions.DEV_TOOLS, - "Dashboard": Permissions.METRICS, - "Assist (Live)": Permissions.ASSIST_LIVE, - "Assist (Call)": Permissions.ASSIST_CALL, - "Spots": Permissions.SPOT, - "Change Spot Visibility": Permissions.SPOT_PUBLIC, - } - value = permission_display_to_value_mapping.get(permission) - return Permissions.has_value(value) - - -def convert_client_resource_update_input_to_provider_resource_update_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - result = {} - if "name" in client_input: - # note(jon): we're currently not handling the case where the client - # send patches of individual name components (e.g. name.middleName) - name = client_input.get("name", {}).get("formatted") - if name: - result["name"] = name - if "userName" in client_input: - result["email"] = client_input["userName"] - if "externalId" in client_input: - result["internal_id"] = client_input["externalId"] - if "active" in client_input: - result["deleted_at"] = None if client_input["active"] else datetime.now() - if "projectKeys" in client_input: - result["project_keys"] = [item["value"] for item in client_input["projectKeys"]] - if "entitlements" in client_input: - result["permissions"] = [ - item - for item in client_input["entitlements"] - if _is_valid_permission_for_identity_provider(item) - ] - return result - - -def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - name = " ".join( - [ - x - for x in [ - client_input.get("name", {}).get("honorificPrefix"), - client_input.get("name", {}).get("givenName"), - client_input.get("name", {}).get("middleName"), - client_input.get("name", {}).get("familyName"), - client_input.get("name", {}).get("honorificSuffix"), - ] - if x - ] - ) - if not name: - name = client_input.get("displayName") - result = { - "email": client_input["userName"], - "internal_id": client_input.get("externalId"), - "name": name, - "project_keys": [item for item in client_input.get("projectKeys", [])], - "permissions": [ - item - for item in client_input.get("entitlements", []) - if _is_valid_permission_for_identity_provider(item) - ], - } - result = {k: v for k, v in result.items() if v is not None} - return result - - -def convert_client_resource_creation_input_to_provider_resource_creation_input( - tenant_id: int, client_input: ClientInput -) -> ProviderInput: - name = " ".join( - [ - x - for x in [ - client_input.get("name", {}).get("honorificPrefix"), - client_input.get("name", {}).get("givenName"), - client_input.get("name", {}).get("middleName"), - client_input.get("name", {}).get("familyName"), - client_input.get("name", {}).get("honorificSuffix"), - ] - if x - ] - ) - if not name: - name = client_input.get("displayName") - result = { - "email": client_input["userName"], - "internal_id": client_input.get("externalId"), - "name": name, - "project_keys": [item["value"] for item in client_input.get("projectKeys", [])], - "permissions": [ - item - for item in client_input.get("entitlements", []) - if _is_valid_permission_for_identity_provider(item) - ], - } - result = {k: v for k, v in result.items() if v is not None} - return result - - -def filter_attribute_mapping() -> dict[str, str]: - return {"userName": "users.email"} - - -def get_provider_resource_from_unique_fields( - email: str, **kwargs: dict[str, Any] -) -> ProviderResource | None: - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT * - FROM public.users - WHERE users.email = %(email)s - """, - {"email": email}, - ) - ) - return cur.fetchone() - - -def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - UPDATE public.users - SET - deleted_at = NULL, - updated_at = now() - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - """, - {"user_id": resource_id, "tenant_id": tenant_id}, - ) - ) +from scim2_models import Resource def convert_provider_resource_to_client_resource( - provider_resource: ProviderResource, -) -> ClientResource: + provider_resource: dict, +) -> dict: groups = [] if provider_resource["role_id"]: groups.append( @@ -175,7 +19,8 @@ def convert_provider_resource_to_client_resource( "id": str(provider_resource["user_id"]), "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User", - "urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + "urn:ietf:params:scim:schemas:extension:openreplay:2.0:User", ], "meta": { "resourceType": "User", @@ -183,7 +28,6 @@ def convert_provider_resource_to_client_resource( "lastModified": provider_resource["updated_at"].strftime( "%Y-%m-%dT%H:%M:%SZ" ), - "location": f"Users/{provider_resource['user_id']}", }, "userName": provider_resource["email"], "externalId": provider_resource["internal_id"], @@ -193,129 +37,180 @@ def convert_provider_resource_to_client_resource( "displayName": provider_resource["name"] or provider_resource["email"], "active": provider_resource["deleted_at"] is None, "groups": groups, + "urn:ietf:params:scim:schemas:extension:openreplay:2.0:User": { + "permissions": provider_resource.get("permissions") or [], + "projectKeys": provider_resource.get("project_keys") or [], + }, } -def get_active_resource_count(tenant_id: int, filter_clause: str | None = None) -> int: - where_and_statements = [ - f"users.tenant_id = {tenant_id}", - "users.deleted_at IS NULL", - ] - if filter_clause is not None: - where_and_statements.append(filter_clause) - where_clause = " AND ".join(where_and_statements) +def query_resources(tenant_id: int) -> list[dict]: with pg_client.PostgresClient() as cur: cur.execute( f""" - SELECT COUNT(*) + SELECT + users.*, + roles.permissions AS permissions, + COALESCE( + ( + SELECT json_agg(projects.project_key) + FROM public.projects + LEFT JOIN public.roles_projects USING (project_id) + WHERE roles_projects.role_id = roles.role_id + ), + '[]' + ) AS project_keys FROM public.users - WHERE {where_clause} + LEFT JOIN public.roles ON roles.role_id = users.role_id + WHERE users.tenant_id = {tenant_id} AND users.deleted_at IS NULL """ ) - return cur.fetchone()["count"] + items = cur.fetchall() + return [convert_provider_resource_to_client_resource(item) for item in items] -def get_provider_resource_chunk( - offset: int, tenant_id: int, limit: int, filter_clause: str | None = None -) -> list[ProviderResource]: - where_and_statements = [ - f"users.tenant_id = {tenant_id}", - "users.deleted_at IS NULL", - ] - if filter_clause is not None: - where_and_statements.append(filter_clause) - where_clause = " AND ".join(where_and_statements) +def get_resource(resource_id: str, tenant_id: int) -> dict | None: with pg_client.PostgresClient() as cur: cur.execute( f""" - SELECT * + SELECT + users.*, + roles.permissions AS permissions, + COALESCE( + ( + SELECT json_agg(projects.project_key) + FROM public.projects + LEFT JOIN public.roles_projects USING (project_id) + WHERE roles_projects.role_id = roles.role_id + ), + '[]' + ) AS project_keys FROM public.users - WHERE {where_clause} - LIMIT {limit} - OFFSET {offset}; + LEFT JOIN public.roles ON roles.role_id = users.role_id + WHERE users.tenant_id = {tenant_id} AND users.deleted_at IS NULL AND users.user_id = {resource_id} """ ) - return cur.fetchall() + item = cur.fetchone() + if item: + return convert_provider_resource_to_client_resource(item) + return None -def get_provider_resource( - resource_id: ResourceId, tenant_id: int -) -> ProviderResource | None: +def delete_resource(resource_id: str, tenatn_id: int) -> None: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + UPDATE public.users + SET + deleted_at = NULL, + updated_at = now() + WHERE users.user_id = %(user_id)s + """, + {"user_id": resource_id}, + ) + ) + + +def search_existing(tenant_id: int, resource: Resource) -> dict | None: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( """ SELECT * FROM public.users - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - LIMIT 1; + WHERE email = %(email)s + """, + {"email": resource.user_name}, + ) + ) + item = cur.fetchone() + if item: + return convert_provider_resource_to_client_resource(item) + return None + + +def restore_resource(tenant_id: int, resource: Resource) -> dict | None: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT role_id + FROM public.users + WHERE user_id = %(user_id)s + """, + {"user_id": resource.id}, + ) + ) + item = cur.fetchone() + if item and item["role_id"] is not None: + _update_role_projects_and_permissions( + item["role_id"], + resource.OpenreplayUser.project_keys, + resource.OpenreplayUser.permissions, + cur, + ) + cur.execute( + cur.mogrify( + """ + WITH u AS ( + UPDATE public.users + SET + tenant_id = %(tenant_id)s, + email = %(email)s, + name = %(name)s, + internal_id = %(internal_id)s, + deleted_at = NULL, + created_at = now(), + updated_at = now(), + api_key = default, + jwt_iat = NULL, + weekly_report = default + WHERE users.email = %(email)s + RETURNING * + ) + SELECT + u.*, + roles.permissions AS permissions, + COALESCE( + ( + SELECT json_agg(projects.project_key) + FROM public.projects + LEFT JOIN public.roles_projects USING (project_id) + WHERE roles_projects.role_id = roles.role_id + ), + '[]' + ) AS project_keys + FROM u + LEFT JOIN public.roles ON roles.role_id = u.role_id """, { - "user_id": resource_id, "tenant_id": tenant_id, + "email": resource.user_name, + "name": " ".join( + [ + x + for x in [ + resource.name.honorific_prefix, + resource.name.given_name, + resource.name.middle_name, + resource.name.family_name, + resource.name.honorific_suffix, + ] + if x + ] + ) + if resource.name + else "", + "internal_id": resource.external_id, }, ) ) - return cur.fetchone() + item = cur.fetchone() + return convert_provider_resource_to_client_resource(item) -def _update_role_projects_and_permissions( - role_id: int | None, - project_keys: list[str] | None, - permissions: list[str] | None, - cur: pg_client.PostgresClient, -) -> None: - if role_id is None: - return - all_projects = "true" if not project_keys else "false" - project_key_clause = helpers.safe_mogrify_array(project_keys, "varchar", cur) - permission_clause = helpers.safe_mogrify_array(permissions, "varchar", cur) - cur.execute( - f""" - UPDATE public.roles - SET - updated_at = now(), - all_projects = {all_projects}, - permissions = {permission_clause} - WHERE role_id = {role_id} - RETURNING * - """ - ) - cur.execute( - f""" - DELETE FROM public.roles_projects - USING public.projects - WHERE - projects.project_id = roles_projects.project_id - AND roles_projects.role_id = {role_id} - AND projects.project_key != ALL({project_key_clause}) - """ - ) - cur.execute( - f""" - INSERT INTO public.roles_projects (role_id, project_id) - SELECT {role_id}, projects.project_id - FROM public.projects - LEFT JOIN public.roles_projects USING (project_id) - WHERE - projects.project_key = ANY({project_key_clause}) - AND roles_projects.role_id IS NULL - RETURNING * - """ - ) - - -def create_provider_resource( - email: str, - tenant_id: int, - name: str = "", - internal_id: str | None = None, - project_keys: list[str] | None = None, - permissions: list[str] | None = None, -) -> ProviderResource: +def create_resource(tenant_id: int, resource: Resource) -> dict: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -340,29 +235,50 @@ def create_provider_resource( """, { "tenant_id": tenant_id, - "email": email, - "name": name, - "internal_id": internal_id, + "email": resource.user_name, + "name": " ".join( + [ + x + for x in [ + resource.name.honorific_prefix, + resource.name.given_name, + resource.name.middle_name, + resource.name.family_name, + resource.name.honorific_suffix, + ] + if x + ] + ) + if resource.name + else "", + "internal_id": resource.external_id, }, ) ) - user = cur.fetchone() - _update_role_projects_and_permissions( - user["role_id"], project_keys, permissions, cur - ) - return user + item = cur.fetchone() + return convert_provider_resource_to_client_resource(item) -def restore_provider_resource( - tenant_id: int, - email: str, - name: str = "", - internal_id: str | None = None, - project_keys: list[str] | None = None, - permissions: list[str] | None = None, - **kwargs: dict[str, Any], -) -> ProviderResource: +def update_resource(tenant_id: int, resource: Resource) -> dict | None: with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT role_id + FROM public.users + WHERE user_id = %(user_id)s + """, + {"user_id": resource.id}, + ) + ) + item = cur.fetchone() + if item and item["role_id"] is not None: + _update_role_projects_and_permissions( + item["role_id"], + resource.OpenreplayUser.project_keys, + resource.OpenreplayUser.permissions, + cur, + ) cur.execute( cur.mogrify( """ @@ -373,87 +289,83 @@ def restore_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - deleted_at = NULL, - created_at = now(), - updated_at = now(), - api_key = default, - jwt_iat = NULL, - weekly_report = default - WHERE users.email = %(email)s + updated_at = now() + WHERE user_id = %(user_id)s RETURNING * ) - SELECT * + SELECT + u.*, + roles.permissions AS permissions, + COALESCE( + ( + SELECT json_agg(projects.project_key) + FROM public.projects + LEFT JOIN public.roles_projects USING (project_id) + WHERE roles_projects.role_id = roles.role_id + ), + '[]' + ) AS project_keys FROM u + LEFT JOIN public.roles ON roles.role_id = u.role_id """, { + "user_id": resource.id, "tenant_id": tenant_id, - "email": email, - "name": name, - "internal_id": internal_id, + "email": resource.user_name, + "name": " ".join( + [ + x + for x in [ + resource.name.honorific_prefix, + resource.name.given_name, + resource.name.middle_name, + resource.name.family_name, + resource.name.honorific_suffix, + ] + if x + ] + ) + if resource.name + else "", + "internal_id": resource.external_id, }, ) ) - user = cur.fetchone() - _update_role_projects_and_permissions( - user["role_id"], project_keys, permissions, cur - ) - return user + item = cur.fetchone() + return convert_provider_resource_to_client_resource(item) -def _update_resource_sql( - resource_id: int, - tenant_id: int, - project_keys: list[str] | None = None, - permissions: list[str] | None = None, - **kwargs: dict[str, Any], -) -> dict[str, Any]: - with pg_client.PostgresClient() as cur: - kwargs["updated_at"] = datetime.now() - set_fragments = [ - cur.mogrify("%s = %s", (AsIs(k), v)).decode("utf-8") - for k, v in kwargs.items() - ] - set_clause = ", ".join(set_fragments) - cur.execute( - f""" - UPDATE public.users - SET {set_clause} - WHERE - users.user_id = {resource_id} - AND users.tenant_id = {tenant_id} - AND users.deleted_at IS NULL - RETURNING * - """ - ) - user = cur.fetchone() - role_id = user["role_id"] - _update_role_projects_and_permissions(role_id, project_keys, permissions, cur) - return user - - -def rewrite_provider_resource( - resource_id: int, - tenant_id: int, - email: str, - name: str = "", - internal_id: str | None = None, - project_keys: list[str] | None = None, - permissions: list[str] | None = None, -) -> dict[str, Any]: - return _update_resource_sql( - resource_id, - tenant_id, - email=email, - name=name, - internal_id=internal_id, - project_keys=project_keys, - permissions=permissions, +def _update_role_projects_and_permissions( + role_id: int, + project_keys: list[str] | None, + permissions: list[str] | None, + cur: pg_client.PostgresClient, +) -> None: + all_projects = "true" if not project_keys else "false" + project_key_clause = helpers.safe_mogrify_array(project_keys, "varchar", cur) + permission_clause = helpers.safe_mogrify_array(permissions, "varchar", cur) + cur.execute( + f""" + UPDATE public.roles + SET + updated_at = now(), + all_projects = {all_projects}, + permissions = {permission_clause} + WHERE role_id = {role_id} + RETURNING * + """ + ) + cur.execute( + f""" + DELETE FROM public.roles_projects + WHERE roles_projects.role_id = {role_id} + """ + ) + cur.execute( + f""" + INSERT INTO public.roles_projects (role_id, project_id) + SELECT {role_id}, projects.project_id + FROM public.projects + WHERE projects.project_key = ANY({project_key_clause}) + """ ) - - -def update_provider_resource( - resource_id: int, - tenant_id: int, - **kwargs, -): - return _update_resource_sql(resource_id, tenant_id, **kwargs)