From 59a72061616682f03d78421d3029166f3ee02c3b Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 15 Apr 2025 10:33:39 +0200 Subject: [PATCH 01/34] add ResourceType endpoints --- ee/api/routers/scim.py | 85 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 8a0975492..ea00bdc81 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -4,7 +4,7 @@ import uuid from typing import Optional from decouple import config -from fastapi import Depends, HTTPException, Header, Query, Response +from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from pydantic import BaseModel, Field @@ -31,12 +31,11 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @public_app.post("/token") async def login(host: str = Header(..., alias="Host"), form_data: OAuth2PasswordRequestForm = Depends()): subdomain = host.split(".")[0] - + # Missing authentication part, to add if form_data.username != config("SCIM_USER") or form_data.password != config("SCIM_PASSWORD"): raise HTTPException(status_code=401, detail="Invalid credentials") - subdomain = "Openreplay EE" tenant = tenants.get_by_name(subdomain) access_token, refresh_token = create_tokens(tenant_id=tenant["tenantId"]) @@ -49,7 +48,71 @@ async def refresh_token(r: RefreshRequest): payload = verify_refresh_token(r.refresh_token) new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) - return {"access_token": new_access_token, "token_type": "Bearer"} + return {"access_token": new_access_token, "token_type": "bearer"} + + +RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { + "User": { + "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", + } +} + + +def _not_found_error_response(resource_id: str): + 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", + } + ) + + +@public_app.get("/ResourceTypes", dependencies=[Depends(auth_required)]) +async def get_resource_types(r: Request): + 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": sorted( + { + **value, + "meta": { + "location": str(r.url_for("get_resource_type", resource_id=value["id"])), + "resourceType": "ResourceType", + } + } + for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() + ), + }, + ) + + +@public_app.get("/ResourceTypes/{resource_id}", dependencies=[Depends(auth_required)]) +async def get_resource_type(r: Request, resource_id: str): + if resource_id not in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS: + return _not_found_error_response(resource_id) + content = { + **RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_id], + "meta": { + "location": str(r.url), + "resourceType": "ResourceType", + } + } + return JSONResponse( + status_code=200, + content=content, + ) + """ User endpoints @@ -104,7 +167,7 @@ async def get_users( if email: email = email.split(" ")[2].strip('"') result_users = users.get_users_paginated(start_index, count, email) - + serialized_users = [] for user in result_users: serialized_users.append( @@ -168,7 +231,7 @@ async def create_user(r: UserRequest): tenant_id = 1 existing_user = users.get_by_email_only(r.userName) deleted_user = users.get_deleted_user_by_email(r.userName) - + if existing_user: return JSONResponse( status_code = 409, @@ -205,7 +268,7 @@ async def create_user(r: UserRequest): ) return JSONResponse(status_code=201, content=res.model_dump(mode='json')) - + @public_app.put("/Users/{user_id}", dependencies=[Depends(auth_required)]) def update_user(user_id: str, r: UserRequest): @@ -239,7 +302,7 @@ def update_user(user_id: str, r: UserRequest): active = r.active, # ignore for now, since, can't insert actual timestamp groups = [], # ignore ) - + return JSONResponse(status_code=201, content=res.model_dump(mode='json')) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -266,7 +329,7 @@ def delete_user(user_uuid: str): user = users.get_by_uuid(user_uuid, tenant_id) if not user: raise HTTPException(status_code=404, detail="User not found") - + users.__hard_delete_user_uuid(user_uuid) return Response(status_code=204, content="") @@ -393,7 +456,7 @@ def update_put_group(group_id: str, r: GroupRequest): group = roles.get_role_by_group_id(tenant_id, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") - + if r.operations and r.operations[0].op == "replace" and r.operations[0].path is None: roles.update_group_name(tenant_id, group["data"]["groupId"], r.operations[0].value["displayName"]) return Response(status_code=200, content="") @@ -408,7 +471,7 @@ def update_put_group(group_id: str, r: GroupRequest): "value": user["data"]["userId"], "display": user["name"] }) - + return JSONResponse( status_code=200, content=GroupResponse( From cb0d6840bfc5f67654936da29cb559be2ed19c46 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 15 Apr 2025 14:43:04 +0200 Subject: [PATCH 02/34] add Schemas endpoint --- ee/api/routers/scim.py | 526 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 519 insertions(+), 7 deletions(-) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index ea00bdc81..4d8f9aec2 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -84,14 +84,16 @@ async def get_resource_types(r: Request): "startIndex": 1, "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "Resources": sorted( - { - **value, - "meta": { - "location": str(r.url_for("get_resource_type", resource_id=value["id"])), - "resourceType": "ResourceType", + [ + { + **value, + "meta": { + "location": str(r.url_for("get_resource_type", resource_id=value["id"])), + "resourceType": "ResourceType", + } } - } - for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() + for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() + ] ), }, ) @@ -114,6 +116,516 @@ async def get_resource_type(r: Request, resource_id: str): ) +SCHEMA_IDS_TO_SCHEMA_DETAILS = { + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": { + "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "name": "ServiceProviderConfig", + "description": "Defines the configuration options for the SCIM service provider.", + "attributes": [ + { + "name": "documentationUri", + "type": "string", + "multiValued": False, + "description": "The base or canonical URL of the service provider's documentation.", + "required": False, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "patch", + "type": "complex", + "multiValued": False, + "description": "A complex attribute indicating the service provider's support for PATCH requests.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether PATCH is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "bulk", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for bulk operations.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether bulk operations are supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxOperations", + "type": "integer", + "multiValued": False, + "description": "The maximum number of operations that can be performed in a bulk request.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxPayloadSize", + "type": "integer", + "multiValued": False, + "description": "The maximum payload size in bytes for a bulk request.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "filter", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for filtering.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether filtering is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxResults", + "type": "integer", + "multiValued": False, + "description": "The maximum number of resources returned in a search response.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "changePassword", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for change password requests.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the change password operation is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "sort", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for sorting.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether sorting is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "etag", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for ETag in responses.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether ETag is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "authenticationSchemes", + "type": "complex", + "multiValued": True, + "description": "A multi-valued complex attribute that defines the authentication schemes supported by the service provider.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "type", + "type": "string", + "multiValued": False, + "description": "The type of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The common name of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A description of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "specUri", + "type": "string", + "multiValued": False, + "description": "A URI that points to the authentication scheme's specification.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "documentationUri", + "type": "string", + "multiValued": False, + "description": "A URI that points to the documentation for this scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + } + ] + }, + "urn:ietf:params:scim:schemas:core:2.0:ResourceType": { + "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "name": "ResourceType", + "description": "Represents the configuration of a SCIM resource type.", + "attributes": [ + { + "name": "id", + "type": "string", + "multiValued": False, + "description": "The resource type's unique identifier.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "global" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The resource type's human‐readable name.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A brief description of the resource type.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "endpoint", + "type": "string", + "multiValued": False, + "description": "The HTTP endpoint where the resource type is exposed.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "schema", + "type": "string", + "multiValued": False, + "description": "The primary schema URN for the resource type.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "schemaExtensions", + "type": "complex", + "multiValued": True, + "description": "A list of schema extensions for the resource type.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "schema", + "type": "string", + "multiValued": False, + "description": "The URN of the extension schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "required", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value indicating whether the extension is required.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + } + ] + } + ] + }, + "urn:ietf:params:scim:schemas:core:2.0:Schema": { + "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", + "name": "Schema", + "description": "Defines the attributes and metadata for a SCIM resource.", + "attributes": [ + { + "name": "id", + "type": "string", + "multiValued": False, + "description": "The unique identifier of the schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "global" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The human‐readable name of the schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A description of the schema.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "attributes", + "type": "complex", + "multiValued": True, + "description": "The list of attributes defined by the schema.", + "required": True, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "Attribute name.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "type": "string", + "multiValued": False, + "description": "Data type of the attribute.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "multiValued", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the attribute is multi-valued.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "required", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the attribute is required.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "caseExact", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether string comparisons are case sensitive.", + "required": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "mutability", + "type": "string", + "multiValued": False, + "description": "Defines whether the attribute is readOnly, readWrite, immutable, or writeOnly.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "returned", + "type": "string", + "multiValued": False, + "description": "Specifies when the attribute is returned in a response.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "uniqueness", + "type": "string", + "multiValued": False, + "description": "Indicates whether the attribute must be unique.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + } + ] + } + ] + } +} + + +@public_app.get("/Schemas", dependencies=[Depends(auth_required)]) +async def get_schemas(): + 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}", dependencies=[Depends(auth_required)]) +async def get_schema(schema_id: str): + if schema_id not in SCHEMA_IDS_TO_SCHEMA_DETAILS: + return _not_found_error_response(schema_id) + return JSONResponse( + status_code=200, + content=SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id], + ) + + """ User endpoints """ From ab8069a986bbf9903226e156e7513c4b9c91ff3d Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 15 Apr 2025 14:55:01 +0200 Subject: [PATCH 03/34] make scim constant file --- ee/api/routers/scim.py | 512 +------------------------------ ee/api/routers/scim_constants.py | 499 ++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+), 498 deletions(-) create mode 100644 ee/api/routers/scim_constants.py diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 4d8f9aec2..02580c483 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -13,6 +13,7 @@ import schemas from chalicelib.core import users, roles, tenants from chalicelib.utils.scim_auth import auth_required, create_tokens, verify_refresh_token from routers.base import get_routers +from routers.scim_constants import RESOURCE_TYPES, SCHEMAS logger = logging.getLogger(__name__) @@ -52,14 +53,8 @@ async def refresh_token(r: RefreshRequest): RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { - "User": { - "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", - } + resource_type_detail["id"]: resource_type_detail + for resource_type_detail in RESOURCE_TYPES } @@ -83,18 +78,16 @@ async def get_resource_types(r: Request): "itemsPerPage": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS), "startIndex": 1, "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "Resources": sorted( - [ - { - **value, - "meta": { - "location": str(r.url_for("get_resource_type", resource_id=value["id"])), - "resourceType": "ResourceType", - } + "Resources": [ + { + **value, + "meta": { + "location": str(r.url_for("get_resource_type", resource_id=value["id"])), + "resourceType": "ResourceType", } - for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() - ] - ), + } + for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() + ], }, ) @@ -117,485 +110,8 @@ async def get_resource_type(r: Request, resource_id: str): SCHEMA_IDS_TO_SCHEMA_DETAILS = { - "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": { - "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "name": "ServiceProviderConfig", - "description": "Defines the configuration options for the SCIM service provider.", - "attributes": [ - { - "name": "documentationUri", - "type": "string", - "multiValued": False, - "description": "The base or canonical URL of the service provider's documentation.", - "required": False, - "caseExact": False, - "mutability": "readWrite", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "patch", - "type": "complex", - "multiValued": False, - "description": "A complex attribute indicating the service provider's support for PATCH requests.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether PATCH is supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "bulk", - "type": "complex", - "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for bulk operations.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether bulk operations are supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "maxOperations", - "type": "integer", - "multiValued": False, - "description": "The maximum number of operations that can be performed in a bulk request.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "maxPayloadSize", - "type": "integer", - "multiValued": False, - "description": "The maximum payload size in bytes for a bulk request.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "filter", - "type": "complex", - "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for filtering.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether filtering is supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "maxResults", - "type": "integer", - "multiValued": False, - "description": "The maximum number of resources returned in a search response.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "changePassword", - "type": "complex", - "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for change password requests.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether the change password operation is supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "sort", - "type": "complex", - "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for sorting.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether sorting is supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "etag", - "type": "complex", - "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for ETag in responses.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether ETag is supported.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - }, - { - "name": "authenticationSchemes", - "type": "complex", - "multiValued": True, - "description": "A multi-valued complex attribute that defines the authentication schemes supported by the service provider.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "type", - "type": "string", - "multiValued": False, - "description": "The type of the authentication scheme.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "The common name of the authentication scheme.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": False, - "description": "A description of the authentication scheme.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "specUri", - "type": "string", - "multiValued": False, - "description": "A URI that points to the authentication scheme's specification.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "documentationUri", - "type": "string", - "multiValued": False, - "description": "A URI that points to the documentation for this scheme.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - } - ] - } - ] - }, - "urn:ietf:params:scim:schemas:core:2.0:ResourceType": { - "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "name": "ResourceType", - "description": "Represents the configuration of a SCIM resource type.", - "attributes": [ - { - "name": "id", - "type": "string", - "multiValued": False, - "description": "The resource type's unique identifier.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "global" - }, - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "The resource type's human‐readable name.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": False, - "description": "A brief description of the resource type.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "endpoint", - "type": "string", - "multiValued": False, - "description": "The HTTP endpoint where the resource type is exposed.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "schema", - "type": "string", - "multiValued": False, - "description": "The primary schema URN for the resource type.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "schemaExtensions", - "type": "complex", - "multiValued": True, - "description": "A list of schema extensions for the resource type.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "schema", - "type": "string", - "multiValued": False, - "description": "The URN of the extension schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value indicating whether the extension is required.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - } - ] - } - ] - }, - "urn:ietf:params:scim:schemas:core:2.0:Schema": { - "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", - "name": "Schema", - "description": "Defines the attributes and metadata for a SCIM resource.", - "attributes": [ - { - "name": "id", - "type": "string", - "multiValued": False, - "description": "The unique identifier of the schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "global" - }, - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "The human‐readable name of the schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": False, - "description": "A description of the schema.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "attributes", - "type": "complex", - "multiValued": True, - "description": "The list of attributes defined by the schema.", - "required": True, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "Attribute name.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "type", - "type": "string", - "multiValued": False, - "description": "Data type of the attribute.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "multiValued", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether the attribute is multi-valued.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether the attribute is required.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "caseExact", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether string comparisons are case sensitive.", - "required": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "mutability", - "type": "string", - "multiValued": False, - "description": "Defines whether the attribute is readOnly, readWrite, immutable, or writeOnly.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "returned", - "type": "string", - "multiValued": False, - "description": "Specifies when the attribute is returned in a response.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "uniqueness", - "type": "string", - "multiValued": False, - "description": "Indicates whether the attribute must be unique.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - } - ] - } - ] - } + schema_detail["id"]: schema_detail + for schema_detail in SCHEMAS } diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py new file mode 100644 index 000000000..25c07fc58 --- /dev/null +++ b/ee/api/routers/scim_constants.py @@ -0,0 +1,499 @@ +RESOURCE_TYPES = sorted( + [ + { + "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", + } + ], + key=lambda x: x["id"], +) + +SCHEMAS = sorted( + # todo(jon): add the user schema + [ + { + "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "name": "ResourceType", + "description": "Represents the configuration of a SCIM resource type.", + "attributes": [ + { + "name": "id", + "type": "string", + "multiValued": False, + "description": "The resource type's unique identifier.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "global" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The resource type's human‐readable name.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A brief description of the resource type.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "endpoint", + "type": "string", + "multiValued": False, + "description": "The HTTP endpoint where the resource type is exposed.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "schema", + "type": "string", + "multiValued": False, + "description": "The primary schema URN for the resource type.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "schemaExtensions", + "type": "complex", + "multiValued": True, + "description": "A list of schema extensions for the resource type.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "schema", + "type": "string", + "multiValued": False, + "description": "The URN of the extension schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "required", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value indicating whether the extension is required.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + } + ] + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", + "name": "Schema", + "description": "Defines the attributes and metadata for a SCIM resource.", + "attributes": [ + { + "name": "id", + "type": "string", + "multiValued": False, + "description": "The unique identifier of the schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "global" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The human‐readable name of the schema.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A description of the schema.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "attributes", + "type": "complex", + "multiValued": True, + "description": "The list of attributes defined by the schema.", + "required": True, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "Attribute name.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "type", + "type": "string", + "multiValued": False, + "description": "Data type of the attribute.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "multiValued", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the attribute is multi-valued.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "required", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the attribute is required.", + "required": True, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "caseExact", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether string comparisons are case sensitive.", + "required": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "mutability", + "type": "string", + "multiValued": False, + "description": "Defines whether the attribute is readOnly, readWrite, immutable, or writeOnly.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "returned", + "type": "string", + "multiValued": False, + "description": "Specifies when the attribute is returned in a response.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + }, + { + "name": "uniqueness", + "type": "string", + "multiValued": False, + "description": "Indicates whether the attribute must be unique.", + "required": True, + "caseExact": False, + "mutability": "readOnly", + "returned": "always", + "uniqueness": "none" + } + ] + } + ] + }, + { + "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "name": "ServiceProviderConfig", + "description": "Defines the configuration options for the SCIM service provider.", + "attributes": [ + { + "name": "documentationUri", + "type": "string", + "multiValued": False, + "description": "The base or canonical URL of the service provider's documentation.", + "required": False, + "caseExact": False, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "patch", + "type": "complex", + "multiValued": False, + "description": "A complex attribute indicating the service provider's support for PATCH requests.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether PATCH is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "bulk", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for bulk operations.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether bulk operations are supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxOperations", + "type": "integer", + "multiValued": False, + "description": "The maximum number of operations that can be performed in a bulk request.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxPayloadSize", + "type": "integer", + "multiValued": False, + "description": "The maximum payload size in bytes for a bulk request.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "filter", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for filtering.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether filtering is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "maxResults", + "type": "integer", + "multiValued": False, + "description": "The maximum number of resources returned in a search response.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "changePassword", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for change password requests.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether the change password operation is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "sort", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for sorting.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether sorting is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "etag", + "type": "complex", + "multiValued": False, + "description": "A complex attribute that indicates the service provider's support for ETag in responses.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "supported", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value that indicates whether ETag is supported.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "authenticationSchemes", + "type": "complex", + "multiValued": True, + "description": "A multi-valued complex attribute that defines the authentication schemes supported by the service provider.", + "required": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "type", + "type": "string", + "multiValued": False, + "description": "The type of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "name", + "type": "string", + "multiValued": False, + "description": "The common name of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "description", + "type": "string", + "multiValued": False, + "description": "A description of the authentication scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "specUri", + "type": "string", + "multiValued": False, + "description": "A URI that points to the authentication scheme's specification.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "documentationUri", + "type": "string", + "multiValued": False, + "description": "A URI that points to the documentation for this scheme.", + "required": False, + "caseExact": False, + "mutability": "readOnly", + "returned": "default", + "uniqueness": "none" + } + ] + } + ] + }, + ], + key=lambda x: x["id"], +) From f0b397708d40bcc9c218c7003476c8b7d269b17a Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 15 Apr 2025 15:11:24 +0200 Subject: [PATCH 04/34] add 403 error for resourcetypes and schemas --- ee/api/routers/scim.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 02580c483..4fddeda2f 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -70,7 +70,16 @@ def _not_found_error_response(resource_id: str): @public_app.get("/ResourceTypes", dependencies=[Depends(auth_required)]) -async def get_resource_types(r: Request): +async def get_resource_types(r: Request, filter_param: str | None = Query(None, alias="filter")): + if filter_param is not None: + 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", + } + ) return JSONResponse( status_code=200, content={ @@ -116,7 +125,16 @@ SCHEMA_IDS_TO_SCHEMA_DETAILS = { @public_app.get("/Schemas", dependencies=[Depends(auth_required)]) -async def get_schemas(): +async def get_schemas(filter_param: str | None = Query(None, alias="filter")): + if filter_param is not None: + 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", + } + ) return JSONResponse( status_code=200, content={ From 7661bcc5e867bd4b206aff21a8d0f0de56648507 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 16 Apr 2025 08:23:14 +0200 Subject: [PATCH 05/34] add ServiceProviderConfig endpoint --- ee/api/chalicelib/utils/scim_auth.py | 15 +- ee/api/routers/scim.py | 24 ++- ee/api/routers/scim_constants.py | 257 +++++++++++++++++++-------- 3 files changed, 213 insertions(+), 83 deletions(-) diff --git a/ee/api/chalicelib/utils/scim_auth.py b/ee/api/chalicelib/utils/scim_auth.py index 83e779c40..fb73b9dbb 100644 --- a/ee/api/chalicelib/utils/scim_auth.py +++ b/ee/api/chalicelib/utils/scim_auth.py @@ -68,10 +68,21 @@ def verify_refresh_token(token: str): raise HTTPException(status_code=401, detail="Invalid token") -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") +required_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # Authentication Dependency -def auth_required(token: str = Depends(oauth2_scheme)): +def auth_required(token: str = Depends(required_oauth2_scheme)): """Dependency to check Authorization header.""" if config("SCIM_AUTH_TYPE") == "OAuth2": payload = verify_access_token(token) return payload["tenant_id"] + + +optional_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) +def auth_optional(token: str | None = Depends(optional_oauth2_scheme)): + if token is None: + return None + try: + tenant_id = auth_required(token) + return tenant_id + except HTTPException: + return None diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 4fddeda2f..1a34620a6 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -2,6 +2,7 @@ import logging import re import uuid from typing import Optional +import copy from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request @@ -11,9 +12,9 @@ from pydantic import BaseModel, Field import schemas from chalicelib.core import users, roles, tenants -from chalicelib.utils.scim_auth import auth_required, create_tokens, verify_refresh_token +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 RESOURCE_TYPES, SCHEMAS +from routers.scim_constants import RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG logger = logging.getLogger(__name__) @@ -160,6 +161,25 @@ async def get_schema(schema_id: str): ) +# 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)): + content = copy.deepcopy(SERVICE_PROVIDER_CONFIG) + content["meta"]["location"] = str(r.url) + is_authenticated = tenant_id is not None + if not is_authenticated: + content = { + "schemas": content["schemas"], + "authenticationSchemes": content["authenticationSchemes"], + "meta": content["meta"], + } + return JSONResponse( + status_code=200, + content=content, + ) + + """ User endpoints """ diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index 25c07fc58..f349fc5f7 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -1,3 +1,5 @@ +# note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants + RESOURCE_TYPES = sorted( [ { @@ -15,6 +17,7 @@ RESOURCE_TYPES = sorted( SCHEMAS = sorted( # todo(jon): add the user schema [ + # todo(jon): update the ResourceType schema to have the correct values { "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", "name": "ResourceType", @@ -110,6 +113,7 @@ SCHEMAS = sorted( } ] }, + # todo(jon): update the Schema schema to have the correct values { "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", "name": "Schema", @@ -249,39 +253,38 @@ SCHEMAS = sorted( }, { "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "name": "ServiceProviderConfig", - "description": "Defines the configuration options for the SCIM service provider.", + "name": "Service Provider Configuration", + "description": "Schema for representing the service provider's configuration.", "attributes": [ { "name": "documentationUri", - "type": "string", + "type": "reference", + "referenceTypes": ["external"], "multiValued": False, - "description": "The base or canonical URL of the service provider's documentation.", + "description": "An HTTP-addressable URL pointing to the service provider's human consumable help documentation.", "required": False, "caseExact": False, - "mutability": "readWrite", + "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "patch", "type": "complex", "multiValued": False, - "description": "A complex attribute indicating the service provider's support for PATCH requests.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies PATCH configuration options.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether PATCH is supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" } ] }, @@ -289,41 +292,39 @@ SCHEMAS = sorted( "name": "bulk", "type": "complex", "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for bulk operations.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies bulk configuration options.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether bulk operations are supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" }, { "name": "maxOperations", "type": "integer", "multiValued": False, - "description": "The maximum number of operations that can be performed in a bulk request.", - "required": False, + "description": "An integer value specifying the maximum number of operations.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "maxPayloadSize", "type": "integer", "multiValued": False, - "description": "The maximum payload size in bytes for a bulk request.", - "required": False, + "description": "An integer value specifying the maximum payload size in bytes.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", } ] }, @@ -331,31 +332,29 @@ SCHEMAS = sorted( "name": "filter", "type": "complex", "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for filtering.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies FILTER options.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether filtering is supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" }, { "name": "maxResults", "type": "integer", "multiValued": False, - "description": "The maximum number of resources returned in a search response.", - "required": False, + "description": "The integer value specifying the maximum number of resources returned in a response.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", } ] }, @@ -363,137 +362,237 @@ SCHEMAS = sorted( "name": "changePassword", "type": "complex", "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for change password requests.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies configuration options related to changing a password.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether the change password operation is supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" } - ] + ], }, { "name": "sort", "type": "complex", "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for sorting.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies sort result options.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether sorting is supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" } - ] + ], }, { "name": "etag", "type": "complex", "multiValued": False, - "description": "A complex attribute that indicates the service provider's support for ETag in responses.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies ETag configuration options.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "supported", "type": "boolean", "multiValued": False, - "description": "A Boolean value that indicates whether ETag is supported.", - "required": False, + "description": "A Boolean value specifying whether or not the operation is supported.", + "required": True, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" } - ] + ], }, { "name": "authenticationSchemes", "type": "complex", "multiValued": True, - "description": "A multi-valued complex attribute that defines the authentication schemes supported by the service provider.", - "required": False, - "mutability": "readOnly", + "description": "A complex type that specifies supported authentication scheme properties.", + "required": True, "returned": "default", - "uniqueness": "none", + "mutability": "readOnly", "subAttributes": [ { "name": "type", "type": "string", "multiValued": False, - "description": "The type of the authentication scheme.", - "required": False, + "description": "The authentication scheme. This specification defines the values 'oauth', 'oauth2', 'oauthbearertoken', 'httpbasic', and 'httpdigest'.", + "required": True, "caseExact": False, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "name", "type": "string", "multiValued": False, - "description": "The common name of the authentication scheme.", - "required": False, + "description": "The common authentication scheme name, e.g., HTTP Basic.", + "required": True, "caseExact": False, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "description", "type": "string", "multiValued": False, "description": "A description of the authentication scheme.", - "required": False, + "required": True, "caseExact": False, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "specUri", - "type": "string", + "type": "reference", + "referenceTypes": ["external"], "multiValued": False, - "description": "A URI that points to the authentication scheme's specification.", + "description": "An HTTP-addressable URL pointing to the authentication scheme's specification.", "required": False, "caseExact": False, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", }, { "name": "documentationUri", - "type": "string", + "type": "reference", + "referenceTypes": ["external"], "multiValued": False, - "description": "A URI that points to the documentation for this scheme.", + "description": "An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", "required": False, "caseExact": False, "mutability": "readOnly", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", + }, + { + "name": "primary", + "type": "boolean", + "multiValued": False, + "description": "A Boolean value specifying whether or not the attribute is the preferred attribute value.", + "required": True, + "mutability": "readOnly", + "returned": "default", } ] - } + }, + { + "name": "meta", + "type": "complex", + "multiValued": True, + "description": "A complex type that specifies resource metadata.", + "required": True, + "returned": "default", + "mutability": "readOnly", + "subAttributes": [ + { + "name": "resourceType", + "type": "string", + "multiValued": False, + "description": "The name of the resource type of the resource.", + "required": True, + "caseExact": True, + "mutability": "readOnly", + "returned": "default", + }, + { + "name": "created", + "type": "datetime", + "multiValued": False, + "description": " The 'DateTime' that the resource was added to the service provider.", + "required": True, + "mutability": "readOnly", + "returned": "default", + }, + { + "name": "lastModified", + "type": "datetime", + "multiValued": False, + "description": "The most recent DateTime that the details of this resource were updated at the service provider.", + "required": True, + "mutability": "readOnly", + "returned": "default", + }, + { + "name": "location", + "type": "reference", + "referenceTypes": ["ServiceProviderConfig"], + "multiValued": False, + "description": "The URI of the resource being returned.", + "required": True, + "mutability": "readOnly", + "returned": "default", + }, + ] + }, ] }, ], key=lambda x: x["id"], ) + +SERVICE_PROVIDER_CONFIG = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "patch": { + # todo(jon): this needs to be updated to True once we properly implement patching for users and groups + "supported": False, + }, + "bulk": { + "supported": False, + "maxOperations": 0, + "maxPayloadSize": 0, + }, + "filter": { + # todo(jon): this needs to be updated to True once we properly implement filtering for users and groups + "supported": False, + "maxResults": 0, + }, + "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", + # todo(jon): see if we have our own documentation for this + # "documentationUri": "", # optional + "primary": True, + }, + ], + "meta": { + "resourceType": "ServiceProviderConfig", + "created": "2025-04-15T15:45", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-15T15:45", + "location": "", # note(jon): this field will be computed in the /ServiceProviderConfig endpoint + }, +} From 13f46fe566b3ef5c543e687d341a56a3a12ef06c Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 16 Apr 2025 14:56:50 +0200 Subject: [PATCH 06/34] update schemas to match more closely with rfc docs --- ee/api/routers/scim.py | 24 +- ee/api/routers/scim_constants.py | 1242 +++++++++++++++++------------- 2 files changed, 702 insertions(+), 564 deletions(-) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 1a34620a6..2787f185b 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -71,7 +71,7 @@ def _not_found_error_response(resource_id: str): @public_app.get("/ResourceTypes", dependencies=[Depends(auth_required)]) -async def get_resource_types(r: Request, filter_param: str | None = Query(None, alias="filter")): +async def get_resource_types(filter_param: str | None = Query(None, alias="filter")): if filter_param is not None: return JSONResponse( status_code=403, @@ -88,34 +88,18 @@ async def get_resource_types(r: Request, filter_param: str | None = Query(None, "itemsPerPage": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS), "startIndex": 1, "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "Resources": [ - { - **value, - "meta": { - "location": str(r.url_for("get_resource_type", resource_id=value["id"])), - "resourceType": "ResourceType", - } - } - for value in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values() - ], + "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(r: Request, resource_id: str): +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) - content = { - **RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_id], - "meta": { - "location": str(r.url), - "resourceType": "ResourceType", - } - } return JSONResponse( status_code=200, - content=content, + content=RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_id], ) diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index f349fc5f7..ee11cfee9 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -1,551 +1,682 @@ # note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants +from typing import Any + +def _attribute_characteristics( + name: str, + description: str, + type: str="string", + sub_attributes: dict[str, Any] | None=None, + # note(jon): no default for multiValued is defined in the docs and it is marked as optional. + # from our side, we'll default it to False. + multi_valued: bool=False, + required: bool=False, + canonical_values: list[str] | None=None, + case_exact: bool=False, + mutability: str="readWrite", + returned: str="default", + uniqueness: str="none", + reference_types: list[str] | None=None, +): + characteristics = { + "name": name, + "type": type, + "subAttributes": sub_attributes, + "multiValued": multi_valued, + "description": description, + "required": required, + "canonicalValues": canonical_values, + "caseExact": case_exact, + "mutability": mutability, + "returned": returned, + "uniqueness": uniqueness, + "referenceTypes": reference_types, + } + characteristics_without_none = { + key: value + for key, value in characteristics.items() + if value is not None + } + return characteristics_without_none + + +def _multi_valued_attributes(type_canonical_values: list[str], type_required: bool=False, type_mutability="readWrite"): + return [ + _attribute_characteristics( + name="type", + description="A label indicating the attribute's function.", + canonical_values=type_canonical_values, + case_exact=True, + required=type_required, + mutability=type_mutability, + ), + _attribute_characteristics( + name="primary", + type="boolean", + description="A Boolean value indicating the 'primary' or preferred attribute value for this attribute.", + ), + _attribute_characteristics( + name="display", + description="A human-readable name.", + mutability="immutable", + ), + _attribute_characteristics( + name="value", + description="The attribute's significant value.", + ), + _attribute_characteristics( + name="$ref", + type="reference", + reference_types=["uri"], + description="The reference URI of a target resource." + ), + ] + + +# note(jon): the docs are a little confusing regarding this, but +# in section 3.1 of RFC7643, it is specified that ResourceType and +# ServiceProviderConfig are not included in the common attributes. but +# in other references, they treat them as a resource. +def _common_resource_attributes(id_required: bool=True, id_uniqueness: str="none"): + return [ + _attribute_characteristics( + name="id", + description="A unique identifier for the SCIM resource.", + case_exact=True, + mutability="readOnly", + returned="always", + required=id_required, + uniqueness=id_uniqueness, + ), + _attribute_characteristics( + name="externalId", + description="A String that is an identifier for the resource as defined by the provisioning client.", + case_exact=True, + ), + _attribute_characteristics( + name="schemas", + type="reference", + reference_types=["uri"], + 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.", + multi_valued=True, + canonical_values=[ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "urn:ietf:params:scim:schemas:core:2.0:Schema", + # todo(jon): add the user and group schem when completed + ], + case_exact=True, + mutability="readOnly", + returned="default", + required=True, + ), + _attribute_characteristics( + name="meta", + type="complex", + description="A complex attribute containing resource metadata.", + required=True, + sub_attributes=[ + _attribute_characteristics( + name="resourceType", + description="The name of the resource type of the resource.", + mutability="readOnly", + case_exact=True, + required=True, + ), + _attribute_characteristics( + name="created", + type="dateTime", + description="The 'DateTime' that the resource was added to the service provider.", + mutability="readOnly", + required=True, + ), + _attribute_characteristics( + name="lastModified", + type="dateTime", + description="The most recent DateTime that the details of this resource were updated at the service provider.", + mutability="readOnly", + required=True, + ), + _attribute_characteristics( + name="location", + type="reference", + reference_types=["uri"], + description="The URI of the resource being returned.", + mutability="readOnly", + required=True, + ), + # todo(jon): decide if we'll handle versioning. for now, we won't do it. + ], + ), + ] + + + +SERVICE_PROVIDER_CONFIG_SCHEMA = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "name": "Service Provider Configuration", + "description": "Schema for representing the service provider's configuration.", + "meta": { + "resourceType": "Schema", + "created": "2025-04-16T14:48:00Z", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-16T14:48:00Z", + "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + }, + "attributes": [ + *_common_resource_attributes(id_required=False), + _attribute_characteristics( + name="documentationUri", + type="reference", + reference_types=["external"], + description="An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.", + mutability="readOnly", + ), + _attribute_characteristics( + name="patch", + type="complex", + description="A complex type that specifies PATCH configuration options.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="bulk", + type="complex", + description="A complex type that specifies bulk configuration options.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="maxOperations", + type="integer", + description="An integer value specifying the maximum number of operations.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="maxPayloadSize", + type="integer", + description="An integer value specifying the maximum payload size in bytes.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="filter", + type="complex", + description="A complex type that specifies FILTER options.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="maxResults", + type="integer", + description="The integer value specifying the maximum number of resources returned in a response.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="changePassword", + type="complex", + description="A complex type that specifies configuration options related to changing a password.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="sort", + type="complex", + description="A complex type that specifies sort result options.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="etag", + type="complex", + description="A complex type that specifies ETag configuration options.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="supported", + type="boolean", + description="A Boolean value specifying whether or not the operation is supported.", + required=True, + mutability="readOnly", + ), + ], + ), + _attribute_characteristics( + name="authenticationSchemes", + type="complex", + multi_valued=True, + description="A complex type that specifies supported authentication scheme properties.", + required=True, + mutability="readOnly", + sub_attributes=[ + *_multi_valued_attributes( + type_canonical_values=[ + "oauth", + "oauth2", + "oauthbearertoken", + "httpbasic", + "httpdigest", + ], + type_required=True, + type_mutability="readOnly", + ), + _attribute_characteristics( + name="name", + description="The common authentication scheme name, e.g., HTTP Basic.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="description", + description="A description of the authentication scheme.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="specUri", + type="reference", + reference_types=["external"], + description="An HTTP-addressable URL pointing to the authentication scheme's specification.", + mutability="readOnly", + ), + _attribute_characteristics( + name="documentationUri", + type="reference", + reference_types=["external"], + description="An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", + mutability="readOnly", + ), + ], + ), + ] +} + + +RESOURCE_TYPE_SCHEMA = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "name": "Resource Type", + "description": "Specifies the schema that describes a SCIM resource type.", + "meta": { + "resourceType": "Schema", + "created": "2025-04-16T14:48:00Z", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-16T14:48:00Z", + "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:ResourceType", + }, + "attributes": [ + *_common_resource_attributes(id_required=False, id_uniqueness="global"), + _attribute_characteristics( + name="name", + description="The resource type name.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="description", + description="The resource type's human-readable description.", + mutability="readOnly", + ), + # todo(jon): figure out what the correct type/reference_type is here + _attribute_characteristics( + name="endpoint", + type="reference", + reference_types=["uri"], + description="The resource type's HTTP-addressable endpoint relative to the Base URL of the service provider.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="schema", + type="reference", + reference_types=["uri"], + description="The resource type's primary/base schema URI.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="schemaExtensions", + type="complex", + multi_valued=True, + description="A list of URIs of the resource type's schema extensions.", + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="schema", + type="reference", + reference_types=["uri"], + description="The URI of an extended schema.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="required", + type="boolean", + description="A Boolean value that specifies whether or not the schema extension is required for the resource type.", + required=True, + mutability="readOnly", + ), + ] + ), + ] +} + +SCHEMA_SCHEMA = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", + "name": "Schema", + "description": "Specifies the schema that describes a SCIM schema.", + "meta": { + "resourceType": "Schema", + "created": "2025-04-16T14:48:00Z", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-16T14:48:00Z", + "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:Schema", + }, + "attributes": [ + *_common_resource_attributes(id_uniqueness="global"), + _attribute_characteristics( + name="name", + description="The schema's human‐readable name.", + mutability="readOnly", + ), + _attribute_characteristics( + name="description", + description="The schema's human-readable name.", + mutability="readOnly", + ), + _attribute_characteristics( + name="attributes", + type="complex", + multi_valued=True, + description="A complex attribute that defines service provider attributes and their qualities.", + required=True, + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="name", + description="The attribute's name.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="type", + description="The attribute's data type.", + required=True, + canonical_values=[ + "string", + "complex", + "boolean", + "decimal", + "integer", + "dateTime", + "reference", + ], + case_exact=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="multiValued", + type="boolean", + description="A Boolean value indicating an attribute's plurality.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="description", + description="The attribute's human-readable description.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="required", + type="boolean", + description="A Boolean value indicating whether or not the attribute is required.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="canonicalValues", + multi_valued=True, + description="A collection of canonical values.", + mutability="readOnly", + ), + _attribute_characteristics( + name="caseExact", + type="boolean", + description="A Boolean that specifies whether or not a string attribute is case sensitive.", + mutability="readOnly", + ), + _attribute_characteristics( + name="mutability", + description="A single keyword indicating the circumstances under which the value of the attribute can be (re)defined.", + required=True, + mutability="readOnly", + canonical_values=[ + "readOnly", + "readWrite", + "immutable", + "writeOnly", + ], + case_exact=True, + ), + _attribute_characteristics( + name="returned", + description="A single keyword that indicates when an attribute and associated values are returned in response to a GET request or in response to a PUT, POST, or PATCH request.", + required=True, + mutability="readOnly", + canonical_values=[ + "always", + "never", + "default", + "request", + ], + case_exact=True, + ), + _attribute_characteristics( + name="uniqueness", + description="A single keyword value that specifies how the service provider enforces uniqueness of attribute values.", + required=True, + mutability="readOnly", + canonical_values=[ + "none", + "server", + "global", + ], + case_exact=True, + ), + _attribute_characteristics( + name="referenceTypes", + multi_valued=True, + description="A multi-valued array of JSON strings that indicate the SCIM resource types that may be referenced.", + mutability="readOnly", + canonical_values=[ + # todo(jon): add "User" and "Group" once those are done. + "external", + "uri" + ], + case_exact=True, + ), + _attribute_characteristics( + name="subAttributes", + type="complex", + multi_valued=True, + description="When an attribute is of type 'complex', this defines a set of sub-attributes.", + mutability="readOnly", + sub_attributes=[ + _attribute_characteristics( + name="name", + description="The attribute's name.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="type", + description="The attribute's data type.", + required=True, + canonical_values=[ + "string", + "complex", + "boolean", + "decimal", + "integer", + "dateTime", + "reference", + ], + case_exact=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="multiValued", + type="boolean", + description="A Boolean value indicating an attribute's plurality.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="description", + description="The attribute's human-readable description.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="required", + type="boolean", + description="A Boolean value indicating whether or not the attribute is required.", + required=True, + mutability="readOnly", + ), + _attribute_characteristics( + name="canonicalValues", + multi_valued=True, + description="A collection of canonical values.", + mutability="readOnly", + ), + _attribute_characteristics( + name="caseExact", + type="boolean", + description="A Boolean that specifies whether or not a string attribute is case sensitive.", + mutability="readOnly", + ), + _attribute_characteristics( + name="mutability", + description="A single keyword indicating the circumstances under which the value of the attribute can be (re)defined.", + required=True, + mutability="readOnly", + canonical_values=[ + "readOnly", + "readWrite", + "immutable", + "writeOnly", + ], + case_exact=True, + ), + _attribute_characteristics( + name="returned", + description="A single keyword that indicates when an attribute and associated values are returned in response to a GET request or in response to a PUT, POST, or PATCH request.", + required=True, + mutability="readOnly", + canonical_values=[ + "always", + "never", + "default", + "request", + ], + case_exact=True, + ), + _attribute_characteristics( + name="uniqueness", + description="A single keyword value that specifies how the service provider enforces uniqueness of attribute values.", + required=True, + mutability="readOnly", + canonical_values=[ + "none", + "server", + "global", + ], + case_exact=True, + ), + _attribute_characteristics( + name="referenceTypes", + multi_valued=True, + description="A multi-valued array of JSON strings that indicate the SCIM resource types that may be referenced.", + mutability="readOnly", + canonical_values=[ + # todo(jon): add "User" and "Group" once those are done. + "external", + "uri" + ], + case_exact=True, + ), + ], + ), + ] + ) + ] +} + -RESOURCE_TYPES = sorted( - [ - { - "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", - } - ], - key=lambda x: x["id"], -) SCHEMAS = sorted( # todo(jon): add the user schema [ - # todo(jon): update the ResourceType schema to have the correct values - { - "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "name": "ResourceType", - "description": "Represents the configuration of a SCIM resource type.", - "attributes": [ - { - "name": "id", - "type": "string", - "multiValued": False, - "description": "The resource type's unique identifier.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "global" - }, - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "The resource type's human‐readable name.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": False, - "description": "A brief description of the resource type.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "endpoint", - "type": "string", - "multiValued": False, - "description": "The HTTP endpoint where the resource type is exposed.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "schema", - "type": "string", - "multiValued": False, - "description": "The primary schema URN for the resource type.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "schemaExtensions", - "type": "complex", - "multiValued": True, - "description": "A list of schema extensions for the resource type.", - "required": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "schema", - "type": "string", - "multiValued": False, - "description": "The URN of the extension schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value indicating whether the extension is required.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - } - ] - } - ] - }, - # todo(jon): update the Schema schema to have the correct values - { - "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", - "name": "Schema", - "description": "Defines the attributes and metadata for a SCIM resource.", - "attributes": [ - { - "name": "id", - "type": "string", - "multiValued": False, - "description": "The unique identifier of the schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "global" - }, - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "The human‐readable name of the schema.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "description", - "type": "string", - "multiValued": False, - "description": "A description of the schema.", - "required": False, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none" - }, - { - "name": "attributes", - "type": "complex", - "multiValued": True, - "description": "The list of attributes defined by the schema.", - "required": True, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - "subAttributes": [ - { - "name": "name", - "type": "string", - "multiValued": False, - "description": "Attribute name.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "type", - "type": "string", - "multiValued": False, - "description": "Data type of the attribute.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "multiValued", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether the attribute is multi-valued.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "required", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether the attribute is required.", - "required": True, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "caseExact", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value that indicates whether string comparisons are case sensitive.", - "required": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "mutability", - "type": "string", - "multiValued": False, - "description": "Defines whether the attribute is readOnly, readWrite, immutable, or writeOnly.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "returned", - "type": "string", - "multiValued": False, - "description": "Specifies when the attribute is returned in a response.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - }, - { - "name": "uniqueness", - "type": "string", - "multiValued": False, - "description": "Indicates whether the attribute must be unique.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "always", - "uniqueness": "none" - } - ] - } - ] - }, - { - "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "name": "Service Provider Configuration", - "description": "Schema for representing the service provider's configuration.", - "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": "A Boolean value specifying whether or not 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": "A Boolean value specifying whether or not 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": "A Boolean value specifying whether or not the operation is supported.", - "required": True, - "mutability": "readOnly", - "returned": "default", - }, - { - "name": "maxResults", - "type": "integer", - "multiValued": False, - "description": "The 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 configuration options related to changing a password.", - "required": True, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value specifying whether or not 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": "A Boolean value specifying whether or not the operation is supported.", - "required": True, - "mutability": "readOnly", - "returned": "default", - } - ], - }, - { - "name": "etag", - "type": "complex", - "multiValued": False, - "description": "A complex type that specifies ETag configuration options.", - "required": True, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "supported", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value specifying whether or not 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": "type", - "type": "string", - "multiValued": False, - "description": "The authentication scheme. This specification defines the values 'oauth', 'oauth2', 'oauthbearertoken', 'httpbasic', and 'httpdigest'.", - "required": True, - "caseExact": False, - "mutability": "readOnly", - "returned": "default", - "uniqueness": "none", - }, - { - "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", - }, - { - "name": "primary", - "type": "boolean", - "multiValued": False, - "description": "A Boolean value specifying whether or not the attribute is the preferred attribute value.", - "required": True, - "mutability": "readOnly", - "returned": "default", - } - ] - }, - { - "name": "meta", - "type": "complex", - "multiValued": True, - "description": "A complex type that specifies resource metadata.", - "required": True, - "returned": "default", - "mutability": "readOnly", - "subAttributes": [ - { - "name": "resourceType", - "type": "string", - "multiValued": False, - "description": "The name of the resource type of the resource.", - "required": True, - "caseExact": True, - "mutability": "readOnly", - "returned": "default", - }, - { - "name": "created", - "type": "datetime", - "multiValued": False, - "description": " The 'DateTime' that the resource was added to the service provider.", - "required": True, - "mutability": "readOnly", - "returned": "default", - }, - { - "name": "lastModified", - "type": "datetime", - "multiValued": False, - "description": "The most recent DateTime that the details of this resource were updated at the service provider.", - "required": True, - "mutability": "readOnly", - "returned": "default", - }, - { - "name": "location", - "type": "reference", - "referenceTypes": ["ServiceProviderConfig"], - "multiValued": False, - "description": "The URI of the resource being returned.", - "required": True, - "mutability": "readOnly", - "returned": "default", - }, - ] - }, - ] - }, + SERVICE_PROVIDER_CONFIG_SCHEMA, + RESOURCE_TYPE_SCHEMA, + SCHEMA_SCHEMA, ], key=lambda x: x["id"], ) @@ -588,11 +719,34 @@ SERVICE_PROVIDER_CONFIG = { ], "meta": { "resourceType": "ServiceProviderConfig", - "created": "2025-04-15T15:45", + "created": "2025-04-15T15:45:00Z", # note(jon): we might want to think about adding this resource as part of our db # and then updating these timestamps from an api and such. for now, if we update # the configuration, we should update the timestamp here. - "lastModified": "2025-04-15T15:45", + "lastModified": "2025-04-15T15:45:00Z", "location": "", # note(jon): this field will be computed in the /ServiceProviderConfig endpoint }, } + +RESOURCE_TYPES = sorted( + [ + { + "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", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-16T08:37:00Z", + "location": "ResourceType/User", + }, + } + ], + key=lambda x: x["id"], +) From bcfa421b8fed5c58d7fbeda3d47585d7e836505c Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 17 Apr 2025 14:01:59 +0200 Subject: [PATCH 07/34] update GET Users api to be minimal working rfc version --- ee/api/chalicelib/core/users.py | 262 ++++++++++++++----------------- ee/api/routers/scim.py | 182 ++++++++++++--------- ee/api/routers/scim_constants.py | 34 +++- ee/api/routers/scim_helpers.py | 105 +++++++++++++ 4 files changed, 363 insertions(+), 220 deletions(-) create mode 100644 ee/api/routers/scim_helpers.py diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 94d1e8d41..4fd1e8b09 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -30,7 +30,7 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal query = cur.mogrify(f"""\ WITH u AS ( INSERT INTO public.users (tenant_id, email, role, name, data, role_id) - VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, + VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))) @@ -78,7 +78,7 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))) WHERE user_id=%(user_id)s - RETURNING + RETURNING tenant_id, user_id, email, @@ -104,7 +104,7 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own u.role_id, roles.name AS role_name, TRUE AS has_password - FROM au,u LEFT JOIN roles USING(tenant_id) + FROM au,u LEFT JOIN roles USING(tenant_id) WHERE roles.role_id IS NULL OR roles.role_id = (SELECT u.role_id FROM u);""", {"tenant_id": tenant_id, "user_id": user_id, "email": email, "role": "owner" if owner else "admin" if admin else "member", "name": name, @@ -240,7 +240,7 @@ def __get_invitation_link(invitation_token): def allow_password_change(user_id, delta_min=10): pass_token = secrets.token_urlsafe(8) with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""UPDATE public.basic_authentication + query = cur.mogrify(f"""UPDATE public.basic_authentication SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'), change_pwd_token = %(pass_token)s WHERE user_id = %(user_id)s""", @@ -255,11 +255,11 @@ def get(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, users.tenant_id, - email, - role, + email, + role, users.name, (CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, @@ -283,38 +283,25 @@ def get(user_id, tenant_id): ) r = cur.fetchone() return helper.dict_to_camel_case(r) - + def get_by_uuid(user_uuid, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT - users.user_id, - users.tenant_id, - email, - role, - users.name, - users.data, - users.internal_id, - (CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, - (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, - origin, - role_id, - roles.name AS role_name, - roles.permissions, - roles.all_projects, - basic_authentication.password IS NOT NULL AS has_password, - users.service_account - FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id - LEFT JOIN public.roles USING (role_id) - WHERE - users.data->>'user_id' = %(user_uuid)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) - LIMIT 1;""", - {"user_uuid": user_uuid, "tenant_id": tenant_id}) + """ + SELECT * + FROM public.users + WHERE + users.deleted_at IS NULL + AND users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + LIMIT 1; + """, + { + "user_id": user_uuid, + "tenant_id": tenant_id, + }, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) @@ -323,11 +310,11 @@ def get_deleted_by_uuid(user_uuid, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, users.tenant_id, - email, - role, + email, + role, users.name, users.data, users.internal_id, @@ -375,8 +362,8 @@ def __get_account_info(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT users.name, - tenants.name AS tenant_name, + f"""SELECT users.name, + tenants.name AS tenant_name, tenants.opt_out FROM public.users INNER JOIN public.tenants USING (tenant_id) WHERE users.user_id = %(userId)s @@ -457,11 +444,11 @@ def get_by_email_only(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, users.tenant_id, - users.email, - users.role, + users.email, + users.role, users.name, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, @@ -473,7 +460,7 @@ def get_by_email_only(email): roles.name AS role_name FROM public.users LEFT JOIN public.basic_authentication USING(user_id) INNER JOIN public.roles USING(role_id) - WHERE users.email = %(email)s + WHERE users.email = %(email)s AND users.deleted_at IS NULL LIMIT 1;""", {"email": email}) @@ -481,50 +468,39 @@ def get_by_email_only(email): r = cur.fetchone() return helper.dict_to_camel_case(r) -def get_users_paginated(start_index, count=None, email=None): +def get_users_paginated(start_index, tenant_id, count=None): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT - users.user_id AS id, - users.tenant_id, - users.email AS email, - users.data AS data, - users.role, - users.name AS name, - (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, - (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member, - origin, - basic_authentication.password IS NOT NULL AS has_password, - role_id, - internal_id, - roles.name AS role_name - FROM public.users LEFT JOIN public.basic_authentication USING(user_id) - INNER JOIN public.roles USING(role_id) - WHERE users.deleted_at IS NULL - AND users.data ? 'user_id' - AND email = COALESCE(%(email)s, email) - LIMIT %(count)s - OFFSET %(startIndex)s;;""", - {"startIndex": start_index - 1, "count": count, "email": email}) + """ + SELECT * + FROM public.users + WHERE + users.deleted_at IS NULL + AND users.tenant_id = %(tenant_id)s + LIMIT %(limit)s + OFFSET %(offset)s; + """, + { + "offset": start_index - 1, + "limit": count, + "tenant_id": tenant_id + }, + ) ) r = cur.fetchall() - if len(r): - r = helper.list_to_camel_case(r) - return r - return [] + return helper.list_to_camel_case(r) def get_member(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, - users.email, - users.role, - users.name, + users.email, + users.role, + users.name, users.created_at, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, @@ -535,7 +511,7 @@ def get_member(tenant_id, user_id): invitation_token, role_id, roles.name AS role_name - FROM public.users + FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id LEFT JOIN public.roles USING (role_id) WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL AND users.user_id = %(user_id)s @@ -557,11 +533,11 @@ def get_members(tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, - users.email, - users.role, - users.name, + users.email, + users.role, + users.name, users.created_at, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, @@ -572,10 +548,10 @@ def get_members(tenant_id): invitation_token, role_id, roles.name AS role_name - FROM public.users + FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id LEFT JOIN public.roles USING (role_id) - WHERE users.tenant_id = %(tenant_id)s + WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL AND NOT users.service_account ORDER BY name, user_id""", @@ -614,7 +590,7 @@ def delete_member(user_id, tenant_id, id_to_delete): cur.execute( cur.mogrify(f"""UPDATE public.users SET deleted_at = timezone('utc'::text, now()), - jwt_iat= NULL, jwt_refresh_jti= NULL, + jwt_iat= NULL, jwt_refresh_jti= NULL, jwt_refresh_iat= NULL, role_id=NULL WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""", @@ -634,10 +610,10 @@ def delete_member_as_admin(tenant_id, id_to_delete): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id AS user_id, users.tenant_id, - email, + email, role, users.name, origin, @@ -654,12 +630,12 @@ def delete_member_as_admin(tenant_id, id_to_delete): role = 'owner' AND users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL - AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) + AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) LIMIT 1;""", {"tenant_id": tenant_id, "user_uuid": id_to_delete}) ) r = cur.fetchone() - + if r["user_id"] == id_to_delete: return {"errors": ["unauthorized, cannot delete self"]} @@ -677,7 +653,7 @@ def delete_member_as_admin(tenant_id, id_to_delete): cur.execute( cur.mogrify(f"""UPDATE public.users SET deleted_at = timezone('utc'::text, now()), - jwt_iat= NULL, jwt_refresh_jti= NULL, + jwt_iat= NULL, jwt_refresh_jti= NULL, jwt_refresh_iat= NULL, role_id=NULL WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""", @@ -743,8 +719,8 @@ def email_exists(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT - count(user_id) + f"""SELECT + count(user_id) FROM public.users WHERE email = %(email)s @@ -760,8 +736,8 @@ def get_deleted_user_by_email(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT - * + f"""SELECT + * FROM public.users WHERE email = %(email)s @@ -777,7 +753,7 @@ def get_by_invitation_token(token, pass_token=None): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT *, DATE_PART('day',timezone('utc'::text, now()) \ - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, @@ -797,15 +773,15 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool: cur.execute( cur.mogrify( f"""SELECT user_id, - EXTRACT(epoch FROM jwt_iat)::BIGINT AS jwt_iat, + EXTRACT(epoch FROM jwt_iat)::BIGINT AS jwt_iat, changed_at, service_account, basic_authentication.user_id IS NOT NULL AS has_basic_auth - FROM public.users - LEFT JOIN public.basic_authentication USING(user_id) - WHERE user_id = %(userId)s - AND tenant_id = %(tenant_id)s - AND deleted_at IS NULL + FROM public.users + LEFT JOIN public.basic_authentication USING(user_id) + WHERE user_id = %(userId)s + AND tenant_id = %(tenant_id)s + AND deleted_at IS NULL LIMIT 1;""", {"userId": user_id, "tenant_id": tenant_id}) ) @@ -819,9 +795,9 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool: def refresh_auth_exists(user_id, tenant_id, jwt_jti=None): with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify(f"""SELECT user_id - FROM public.users - WHERE user_id = %(userId)s + cur.mogrify(f"""SELECT user_id + FROM public.users + WHERE user_id = %(userId)s AND tenant_id= %(tenant_id)s AND deleted_at IS NULL AND jwt_refresh_jti = %(jwt_jti)s @@ -866,17 +842,17 @@ def change_jwt_iat_jti(user_id): with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), - jwt_refresh_jti = 0, + jwt_refresh_jti = 0, jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s'), spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), - spot_jwt_refresh_jti = 0, - spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') - WHERE user_id = %(user_id)s - RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, - jwt_refresh_jti, + spot_jwt_refresh_jti = 0, + spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s') + WHERE user_id = %(user_id)s + RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, + jwt_refresh_jti, EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat, - EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, - spot_jwt_refresh_jti, + EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, + spot_jwt_refresh_jti, EXTRACT (epoch FROM spot_jwt_refresh_iat)::BIGINT AS spot_jwt_refresh_iat;""", {"user_id": user_id}) cur.execute(query) @@ -888,10 +864,10 @@ def refresh_jwt_iat_jti(user_id): with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), - jwt_refresh_jti = jwt_refresh_jti + 1 - WHERE user_id = %(user_id)s - RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, - jwt_refresh_jti, + jwt_refresh_jti = jwt_refresh_jti + 1 + WHERE user_id = %(user_id)s + RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, + jwt_refresh_jti, EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat;""", {"user_id": user_id}) cur.execute(query) @@ -904,7 +880,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No return {"errors": ["must sign-in with SSO, enforced by admin"]} with pg_client.PostgresClient() as cur: query = cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, users.tenant_id, users.role, @@ -919,7 +895,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No users.service_account FROM public.users AS users INNER JOIN public.basic_authentication USING(user_id) LEFT JOIN public.roles ON (roles.role_id = users.role_id AND roles.tenant_id = users.tenant_id) - WHERE users.email = %(email)s + WHERE users.email = %(email)s AND basic_authentication.password = crypt(%(password)s, basic_authentication.password) AND basic_authentication.user_id = (SELECT su.user_id FROM public.users AS su WHERE su.email=%(email)s AND su.deleted_at IS NULL LIMIT 1) AND (roles.role_id IS NULL OR roles.deleted_at IS NULL) @@ -932,7 +908,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No query = cur.mogrify( f"""SELECT 1 FROM public.users - WHERE users.email = %(email)s + WHERE users.email = %(email)s AND users.deleted_at IS NULL AND users.origin IS NOT NULL LIMIT 1;""", @@ -983,17 +959,17 @@ def get_user_role(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, - users.email, - users.role, - users.name, + users.email, + users.role, + users.name, users.created_at, (CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, (CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, (CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member - FROM public.users - WHERE users.deleted_at IS NULL + FROM public.users + WHERE users.deleted_at IS NULL AND users.user_id=%(user_id)s AND users.tenant_id=%(tenant_id)s LIMIT 1""", @@ -1007,7 +983,7 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= query = cur.mogrify(f"""\ WITH u AS ( INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id, role_id) - VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s, + VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s, (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))) @@ -1033,7 +1009,7 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= query ) return helper.dict_to_camel_case(cur.fetchone()) - + def create_scim_user( tenant_id, user_uuid, @@ -1094,7 +1070,7 @@ def __hard_delete_user_uuid(user_uuid): with pg_client.PostgresClient() as cur: query = cur.mogrify( f"""DELETE FROM public.users - WHERE users.data->>'user_id' = %(user_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL + WHERE users.data->>'user_id' = %(user_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL {"user_uuid": user_uuid}) cur.execute(query) @@ -1124,7 +1100,7 @@ def refresh(user_id: int, tenant_id: int = -1) -> dict: def authenticate_sso(email: str, internal_id: str): with pg_client.PostgresClient() as cur: query = cur.mogrify( - f"""SELECT + f"""SELECT users.user_id, users.tenant_id, users.role, @@ -1173,13 +1149,13 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""\ WITH u AS ( - UPDATE public.users + UPDATE public.users SET tenant_id= %(tenant_id)s, - role= %(role)s, + role= %(role)s, name= %(name)s, - data= %(data)s, - origin= %(origin)s, - internal_id= %(internal_id)s, + data= %(data)s, + origin= %(origin)s, + internal_id= %(internal_id)s, role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))), @@ -1198,7 +1174,7 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in invited_at= default, change_pwd_token= default, change_pwd_expire_at= default, - changed_at= NULL + changed_at= NULL WHERE user_id = %(user_id)s RETURNING user_id ) @@ -1237,13 +1213,13 @@ def restore_scim_user( with pg_client.PostgresClient() as cur: query = cur.mogrify(f"""\ WITH u AS ( - UPDATE public.users + UPDATE public.users SET tenant_id= %(tenant_id)s, - role= %(role)s, + role= %(role)s, name= %(name)s, - data= %(data)s, - origin= %(origin)s, - internal_id= %(internal_id)s, + data= %(data)s, + origin= %(origin)s, + internal_id= %(internal_id)s, role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))), @@ -1262,7 +1238,7 @@ def restore_scim_user( invited_at= default, change_pwd_token= default, change_pwd_expire_at= default, - changed_at= NULL + changed_at= NULL WHERE user_id = %(user_id)s RETURNING user_id ) @@ -1290,10 +1266,10 @@ def get_user_settings(user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + f"""SELECT settings - FROM public.users - WHERE users.deleted_at IS NULL + FROM public.users + WHERE users.deleted_at IS NULL AND users.user_id=%(user_id)s LIMIT 1""", {"user_id": user_id}) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 2787f185b..61889c82e 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -1,20 +1,22 @@ import logging import re import uuid -from typing import Optional +from typing import Any, Literal, Optional import copy +from datetime import datetime from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer import schemas from chalicelib.core import users, roles, tenants 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 RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG +from routers import scim_helpers logger = logging.getLogger(__name__) @@ -189,90 +191,124 @@ class UserRequest(BaseModel): password: str = Field(default=None) active: bool -class UserResponse(BaseModel): - schemas: list[str] - id: str - userName: str - name: Name - emails: list[Email] # ignore for now - displayName: str - locale: str - externalId: str - active: bool - groups: list[dict] - meta: dict = Field(default={"resourceType": "User"}) class PatchUserRequest(BaseModel): schemas: list[str] Operations: list[dict] -@public_app.get("/Users", dependencies=[Depends(auth_required)]) -async def get_users( - start_index: int = Query(1, alias="startIndex"), - count: Optional[int] = Query(None, alias="count"), - email: Optional[str] = Query(None, alias="filter"), -): - """Get SCIM Users""" - if email: - email = email.split(" ")[2].strip('"') - result_users = users.get_users_paginated(start_index, count, email) +class ResourceMetaResponse(BaseModel): + resourceType: Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None = None + created: datetime | None = None + lastModified: datetime | None = None + location: str | None = None + version: str | None = None - serialized_users = [] - for user in result_users: - serialized_users.append( - UserResponse( - schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], - id = user["data"]["userId"], - userName = user["email"], - name = Name.model_validate(user["data"]["name"]), - emails = [Email.model_validate(user["data"]["emails"])], - displayName = user["name"], - locale = user["data"]["locale"], - externalId = user["internalId"], - active = True, # ignore for now, since, can't insert actual timestamp - groups = [], # ignore - ).model_dump(mode='json') - ) + @field_serializer("created", "lastModified") + def serialize_datetime(self, dt: datetime) -> str | None: + if not dt: + return None + return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +class CommonResourceResponse(BaseModel): + id: str + externalId: str | None = None + schemas: list[ + Literal[ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "urn:ietf:params:scim:schemas:core:2.0:Schema", + "urn:ietf:params:scim:schemas:core:2.0:User", + ] + ] + meta: ResourceMetaResponse | None = None + + +class UserResponse(CommonResourceResponse): + schemas: list[Literal["urn:ietf:params:scim:schemas:core:2.0:User"]] = ["urn:ietf:params:scim:schemas:core:2.0:User"] + userName: str | None = None + + +class QueryResourceResponse(BaseModel): + schemas: list[Literal["urn:ietf:params:scim:api:messages:2.0:ListResponse"]] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] + totalResults: int + # todo(jon): add the other schemas + Resources: list[UserResponse] + startIndex: int + itemsPerPage: int + + +MAX_USERS_PER_PAGE = 10 + + +def _convert_db_user_to_scim_user(db_user: dict[str, Any], attributes: list[str] | None = None, excluded_attributes: list[str] | None = None) -> UserResponse: + user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] + all_attributes = scim_helpers.get_all_attribute_names(user_schema) + attributes = attributes or all_attributes + always_returned_attributes = scim_helpers.get_all_attribute_names_where_returned_is_always(user_schema) + included_attributes = list(set(attributes).union(set(always_returned_attributes))) + excluded_attributes = excluded_attributes or [] + excluded_attributes = list(set(excluded_attributes).difference(set(always_returned_attributes))) + scim_user = { + "id": str(db_user["userId"]), + "meta": { + "resourceType": "User", + "created": db_user["createdAt"], + "lastModified": db_user["createdAt"], # todo(jon): we currently don't keep track of this in the db + "location": f"Users/{db_user['userId']}" + }, + "userName": db_user["email"], + } + scim_user = scim_helpers.filter_attributes(scim_user, included_attributes) + scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes) + return UserResponse(**scim_user) + + +@public_app.get("/Users") +async def get_users( + tenant_id = Depends(auth_required), + requested_start_index: int = Query(1, alias="startIndex"), + requested_items_per_page: int | None = Query(None, alias="count"), + attributes: list[str] | None = Query(None), + excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), +): + start_index = max(1, requested_start_index) + items_per_page = min(max(0, requested_items_per_page or MAX_USERS_PER_PAGE), MAX_USERS_PER_PAGE) + # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. + # but this is the fastest thing at the moment just to test that it's working + total_users = users.get_users_paginated(1, tenant_id) + db_users = users.get_users_paginated(start_index, tenant_id, count=items_per_page) + scim_users = [ + _convert_db_user_to_scim_user(user, attributes, excluded_attributes) + for user in db_users + ] return JSONResponse( status_code=200, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": len(serialized_users), - "startIndex": start_index, - "itemsPerPage": len(serialized_users), - "Resources": serialized_users, - }, + content=QueryResourceResponse( + totalResults=len(total_users), + startIndex=start_index, + itemsPerPage=len(scim_users), + Resources=scim_users, + ).model_dump(mode="json", exclude_none=True), ) -@public_app.get("/Users/{user_id}", dependencies=[Depends(auth_required)]) -def get_user(user_id: str): - """Get SCIM User""" - tenant_id = 1 - user = users.get_by_uuid(user_id, tenant_id) - if not user: - return JSONResponse( - status_code=404, - content={ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "User not found", - "status": 404, - } - ) - res = UserResponse( - schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], - id = user["data"]["userId"], - userName = user["email"], - name = Name.model_validate(user["data"]["name"]), - emails = [Email.model_validate(user["data"]["emails"])], - displayName = user["name"], - locale = user["data"]["locale"], - externalId = user["internalId"], - active = True, # ignore for now, since, can't insert actual timestamp - groups = [], # ignore +@public_app.get("/Users/{user_id}") +def get_user( + user_id: str, + tenant_id = Depends(auth_required), + attributes: list[str] | None = Query(None), + excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), +): + db_user = users.get_by_uuid(user_id, tenant_id) + if not db_user: + return _not_found_error_response(user_id) + scim_user = _convert_db_user_to_scim_user(db_user, attributes, excluded_attributes) + return JSONResponse( + status_code=200, + content=scim_user.model_dump(mode="json", exclude_none=True) ) - return JSONResponse(status_code=201, content=res.model_dump(mode='json')) @public_app.post("/Users", dependencies=[Depends(auth_required)]) diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index ee11cfee9..255bfba3e 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -1,5 +1,6 @@ # note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants -from typing import Any +from typing import Any, Literal + def _attribute_characteristics( name: str, @@ -102,12 +103,12 @@ def _common_resource_attributes(id_required: bool=True, id_uniqueness: str="none "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", "urn:ietf:params:scim:schemas:core:2.0:ResourceType", "urn:ietf:params:scim:schemas:core:2.0:Schema", - # todo(jon): add the user and group schem when completed + "urn:ietf:params:scim:schemas:core:2.0:User", ], case_exact=True, mutability="readOnly", - returned="default", required=True, + returned="always", ), _attribute_characteristics( name="meta", @@ -670,13 +671,38 @@ SCHEMA_SCHEMA = { } +USER_SCHEMA = { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User account.", + "meta": { + "resourceType": "Schema", + "created": "2025-04-16T14:48:00Z", + # note(jon): we might want to think about adding this resource as part of our db + # and then updating these timestamps from an api and such. for now, if we update + # the configuration, we should update the timestamp here. + "lastModified": "2025-04-16T14:48:00Z", + "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:User", + }, + "attributes": [ + *_common_resource_attributes(), + _attribute_characteristics( + name="userName", + description="A service provider's unique identifier for the user.", + required=True, + ), + ], +} + + SCHEMAS = sorted( - # todo(jon): add the user schema [ SERVICE_PROVIDER_CONFIG_SCHEMA, RESOURCE_TYPE_SCHEMA, SCHEMA_SCHEMA, + USER_SCHEMA, ], key=lambda x: x["id"], ) diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py new file mode 100644 index 000000000..d1cc0b651 --- /dev/null +++ b/ee/api/routers/scim_helpers.py @@ -0,0 +1,105 @@ +from typing import Any +from copy import deepcopy + + +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 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 filter_attributes(resource: dict[str, Any], include_list: list[str]) -> dict[str, Any]: + result = {} + for attr in include_list: + parts = attr.split(".", 1) + key = parts[0] + if key not in resource: + continue + + if len(parts) == 1: + # top‑level attr + result[key] = resource[key] + else: + # nested attr + sub = resource[key] + rest = parts[1] + if isinstance(sub, dict): + filtered = filter_attributes(sub, [rest]) + if filtered: + result.setdefault(key, {}).update(filtered) + elif isinstance(sub, list): + # apply to each element + new_list = [] + for item in sub: + if isinstance(item, dict): + f = filter_attributes(item, [rest]) + if f: + new_list.append(f) + if new_list: + result[key] = new_list + return result + + +def exclude_attributes(resource: dict[str, Any], exclude_list: list[str]) -> dict[str, Any]: + exclude_map = {} + for attr in exclude_list: + parts = attr.split(".", 1) + key = parts[0] + # rest is empty string for top-level exclusion + rest = parts[1] if len(parts) == 2 else "" + exclude_map.setdefault(key, []).append(rest) + + new_resource = {} + for key, value in resource.items(): + if key in exclude_map: + subs = exclude_map[key] + # If any attr has no rest, exclude entire key + if "" in subs: + continue + # Exclude nested attributes + if isinstance(value, dict): + new_sub = exclude_attributes(value, subs) + if not new_sub: + continue + new_resource[key] = new_sub + elif isinstance(value, list): + new_list = [] + for item in value: + if isinstance(item, dict): + new_item = exclude_attributes(item, subs) + new_list.append(new_item) + else: + new_list.append(item) + new_resource[key] = new_list + else: + new_resource[key] = value + else: + # No exclusion for this key: copy safely + if isinstance(value, (dict, list)): + new_resource[key] = deepcopy(value) + else: + new_resource[key] = value + return new_resource From 4371fec38ef53f778b71e12dce3bbc6b6f32ae05 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 17 Apr 2025 16:08:23 +0200 Subject: [PATCH 08/34] update POST /Users to use tenancy and not consider soft delete --- ee/api/chalicelib/core/users.py | 95 ++++++++++++++-------------- ee/api/routers/scim.py | 107 ++++++++++++++------------------ 2 files changed, 92 insertions(+), 110 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 4fd1e8b09..de73bafd9 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -1,7 +1,7 @@ import json import logging import secrets -from typing import Optional +from typing import Any, Optional from decouple import config from fastapi import BackgroundTasks, HTTPException @@ -284,7 +284,7 @@ def get(user_id, tenant_id): r = cur.fetchone() return helper.dict_to_camel_case(r) -def get_by_uuid(user_uuid, tenant_id): +def get_scim_user_by_id(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -292,19 +292,17 @@ def get_by_uuid(user_uuid, tenant_id): SELECT * FROM public.users WHERE - users.deleted_at IS NULL - AND users.user_id = %(user_id)s + users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s LIMIT 1; """, { - "user_id": user_uuid, + "user_id": user_id, "tenant_id": tenant_id, }, ) ) - r = cur.fetchone() - return helper.dict_to_camel_case(r) + return helper.dict_to_camel_case(cur.fetchone()) def get_deleted_by_uuid(user_uuid, tenant_id): with pg_client.PostgresClient() as cur: @@ -440,6 +438,21 @@ def edit_member(user_id_to_update, tenant_id, changes: schemas.EditMemberSchema, return {"data": user} +def get_scim_user_by_unique_values(email): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT * + FROM public.users + WHERE users.email = %(email)s + """, + {"email": email} + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + def get_by_email_only(email): with pg_client.PostgresClient() as cur: cur.execute( @@ -475,9 +488,7 @@ def get_users_paginated(start_index, tenant_id, count=None): """ SELECT * FROM public.users - WHERE - users.deleted_at IS NULL - AND users.tenant_id = %(tenant_id)s + WHERE users.tenant_id = %(tenant_id)s LIMIT %(limit)s OFFSET %(offset)s; """, @@ -1011,48 +1022,36 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= return helper.dict_to_camel_case(cur.fetchone()) def create_scim_user( - tenant_id, - user_uuid, email, - admin, - display_name, - full_name: dict, - emails, - origin, - locale, - role_id, - internal_id=None, + name, + tenant_id, ): - with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ - WITH u AS ( - INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id, role_id) - VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s, - (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), - (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), - (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))) - RETURNING * - ), - au AS ( - INSERT INTO public.basic_authentication(user_id) - VALUES ((SELECT user_id FROM u)) - ) - SELECT u.user_id AS id, - u.email, - u.role, - u.name, - u.data, - (CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, - (CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, - origin - FROM u;""", - {"tenant_id": tenant_id, "email": email, "internal_id": internal_id, - "role": "admin" if admin else "member", "name": display_name, "origin": origin, - "role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now(), "user_id": user_uuid, "locale": locale, "name": full_name, "emails": emails})}) cur.execute( - query + cur.mogrify( + """ + WITH u AS ( + INSERT INTO public.users ( + tenant_id, + email, + name + ) + VALUES ( + %(tenant_id)s, + %(email)s, + %(name)s + ) + RETURNING * + ) + SELECT * + FROM u; + """, + { + "tenant_id": tenant_id, + "email": email, + "name": name, + } + ) ) return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 61889c82e..b1c73a568 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -72,6 +72,18 @@ def _not_found_error_response(resource_id: str): ) +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", + } + ) + + @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: @@ -169,27 +181,8 @@ async def get_service_provider_config(r: Request, tenant_id: str | None = Depend """ User endpoints """ - -class Name(BaseModel): - givenName: str - familyName: str - -class Email(BaseModel): - primary: bool - value: str - type: str - class UserRequest(BaseModel): - schemas: list[str] userName: str - name: Name - emails: list[Email] - displayName: str - locale: str - externalId: str - groups: list[dict] - password: str = Field(default=None) - active: bool class PatchUserRequest(BaseModel): @@ -301,7 +294,7 @@ def get_user( attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): - db_user = users.get_by_uuid(user_id, tenant_id) + db_user = users.get_scim_user_by_id(user_id, tenant_id) if not db_user: return _not_found_error_response(user_id) scim_user = _convert_db_user_to_scim_user(db_user, attributes, excluded_attributes) @@ -311,49 +304,39 @@ def get_user( ) -@public_app.post("/Users", dependencies=[Depends(auth_required)]) -async def create_user(r: UserRequest): - """Create SCIM User""" - tenant_id = 1 - existing_user = users.get_by_email_only(r.userName) - deleted_user = users.get_deleted_user_by_email(r.userName) - - if existing_user: - return JSONResponse( - status_code = 409, - content = { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail": "User already exists in the database.", - "status": 409, - } - ) - elif deleted_user: - user_id = users.get_deleted_by_uuid(deleted_user["data"]["userId"], tenant_id) - user = users.restore_scim_user(user_id=user_id["userId"], tenant_id=tenant_id, user_uuid=uuid.uuid4().hex, email=r.emails[0].value, admin=False, - display_name=r.displayName, full_name=r.name.model_dump(mode='json'), emails=r.emails[0].model_dump(mode='json'), - origin="okta", locale=r.locale, role_id=None, internal_id=r.externalId) - else: - try: - user = users.create_scim_user(tenant_id=tenant_id, user_uuid=uuid.uuid4().hex, email=r.emails[0].value, admin=False, - display_name=r.displayName, full_name=r.name.model_dump(mode='json'), emails=r.emails[0].model_dump(mode='json'), - origin="okta", locale=r.locale, role_id=None, internal_id=r.externalId) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - res = UserResponse( - schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], - id = user["data"]["userId"], - userName = r.userName, - name = r.name, - emails = r.emails, - displayName = r.displayName, - locale = r.locale, - externalId = r.externalId, - active = r.active, # ignore for now, since, can't insert actual timestamp - groups = [], # ignore +@public_app.post("/Users") +async def create_user(r: UserRequest, tenant_id = Depends(auth_required)): + existing_db_user = users.get_scim_user_by_unique_values(r.userName) + # todo(jon): we have a conflict in our db schema and the docs for SCIM. + # here is a quote from section 3.6 of RFC 7644: + # For example, if a User resource is deleted, a CREATE + # request for a User resource with the same userName as the previously + # deleted resource SHOULD NOT fail with a 409 error due to userName + # conflict. + # this doesn't work with our db schema as `public.users.email` is UNIQUE + # but not conditionaly unique based on deletion. this would be fine if + # we did a hard delete, but it seems like we do soft deletes. + # so, we need to figure out how to handle this: + # 1. we do hard deletes for scim users. + # 2. we change how we handle the unique constraint for users on the email field. + # i think the easiest thing to do here would be 1, since it wouldn't require + # updating any other parts of the codebase (potentially) + if existing_db_user: + return _uniqueness_error_response() + db_user = users.create_scim_user( + email=r.userName, + # note(jon): scim schema does not require the `name.formatted` attribute, but we require `name`. + # so, we have to define the value ourselves here + name="", + tenant_id=tenant_id, ) - return JSONResponse(status_code=201, content=res.model_dump(mode='json')) - + scim_user = _convert_db_user_to_scim_user(db_user) + response = JSONResponse( + status_code=201, + content=scim_user.model_dump(mode="json", exclude_none=True) + ) + response.headers["Location"] = scim_user.meta.location + return response @public_app.put("/Users/{user_id}", dependencies=[Depends(auth_required)]) From 5a31d1b93fb0aa324ec77b56999b59db45a77ff5 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 17 Apr 2025 17:18:38 +0200 Subject: [PATCH 09/34] update DELETE /Users endpoint to use tenancy and to soft delete --- ee/api/chalicelib/core/users.py | 205 ++++++------------------------- ee/api/routers/scim.py | 50 +++----- ee/api/routers/scim_constants.py | 1 - 3 files changed, 59 insertions(+), 197 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index de73bafd9..f9dba0619 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -294,6 +294,7 @@ def get_scim_user_by_id(user_id, tenant_id): WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL LIMIT 1; """, { @@ -304,42 +305,6 @@ def get_scim_user_by_id(user_id, tenant_id): ) return helper.dict_to_camel_case(cur.fetchone()) -def get_deleted_by_uuid(user_uuid, tenant_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - f"""SELECT - users.user_id, - users.tenant_id, - email, - role, - users.name, - users.data, - users.internal_id, - (CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, - (CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, - origin, - role_id, - roles.name AS role_name, - roles.permissions, - roles.all_projects, - basic_authentication.password IS NOT NULL AS has_password, - users.service_account - FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id - LEFT JOIN public.roles USING (role_id) - WHERE - users.data->>'user_id' = %(user_uuid)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NOT NULL - AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) - LIMIT 1;""", - {"user_uuid": user_uuid, "tenant_id": tenant_id}) - ) - r = cur.fetchone() - return helper.dict_to_camel_case(r) - - def generate_new_api_key(user_id): with pg_client.PostgresClient() as cur: @@ -438,7 +403,7 @@ def edit_member(user_id_to_update, tenant_id, changes: schemas.EditMemberSchema, return {"data": user} -def get_scim_user_by_unique_values(email): +def get_existing_scim_user_by_unique_values(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -488,7 +453,9 @@ def get_users_paginated(start_index, tenant_id, count=None): """ SELECT * FROM public.users - WHERE users.tenant_id = %(tenant_id)s + WHERE + users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL LIMIT %(limit)s OFFSET %(offset)s; """, @@ -616,70 +583,6 @@ def delete_member(user_id, tenant_id, id_to_delete): return {"data": get_members(tenant_id=tenant_id)} -def delete_member_as_admin(tenant_id, id_to_delete): - - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - f"""SELECT - users.user_id AS user_id, - users.tenant_id, - email, - role, - users.name, - origin, - role_id, - roles.name AS role_name, - (CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member, - roles.permissions, - roles.all_projects, - basic_authentication.password IS NOT NULL AS has_password, - users.service_account - FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id - LEFT JOIN public.roles USING (role_id) - WHERE - role = 'owner' - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) - LIMIT 1;""", - {"tenant_id": tenant_id, "user_uuid": id_to_delete}) - ) - r = cur.fetchone() - - if r["user_id"] == id_to_delete: - return {"errors": ["unauthorized, cannot delete self"]} - - if r["member"]: - return {"errors": ["unauthorized"]} - - to_delete = get(user_id=id_to_delete, tenant_id=tenant_id) - if to_delete is None: - return {"errors": ["not found"]} - - if to_delete["superAdmin"]: - return {"errors": ["cannot delete super admin"]} - - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify(f"""UPDATE public.users - SET deleted_at = timezone('utc'::text, now()), - jwt_iat= NULL, jwt_refresh_jti= NULL, - jwt_refresh_iat= NULL, - role_id=NULL - WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""", - {"user_id": id_to_delete, "tenant_id": tenant_id})) - cur.execute( - cur.mogrify(f"""UPDATE public.basic_authentication - SET password= NULL, invitation_token= NULL, - invited_at= NULL, changed_at= NULL, - change_pwd_expire_at= NULL, change_pwd_token= NULL - WHERE user_id=%(user_id)s;""", - {"user_id": id_to_delete, "tenant_id": tenant_id})) - return {"data": get_members(tenant_id=tenant_id)} - - - def change_password(tenant_id, user_id, email, old_password, new_password): item = get(tenant_id=tenant_id, user_id=user_id) if item is None: @@ -1056,6 +959,21 @@ def create_scim_user( return helper.dict_to_camel_case(cur.fetchone()) +def soft_delete_scim_user_by_id(user_id, tenant_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + UPDATE public.users + SET deleted_at = NULL + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + """, + {"user_id": user_id, "tenant_id": tenant_id} + ) + ) + def __hard_delete_user(user_id): with pg_client.PostgresClient() as cur: @@ -1065,13 +983,6 @@ def __hard_delete_user(user_id): {"user_id": user_id}) cur.execute(query) -def __hard_delete_user_uuid(user_uuid): - with pg_client.PostgresClient() as cur: - query = cur.mogrify( - f"""DELETE FROM public.users - WHERE users.data->>'user_id' = %(user_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL - {"user_uuid": user_uuid}) - cur.execute(query) def logout(user_id: int): @@ -1199,64 +1110,28 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in def restore_scim_user( user_id, tenant_id, - user_uuid, - email, - admin, - display_name, - full_name: dict, - emails, - origin, - locale, - role_id, - internal_id=None): +): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ - WITH u AS ( - UPDATE public.users - SET tenant_id= %(tenant_id)s, - role= %(role)s, - name= %(name)s, - data= %(data)s, - origin= %(origin)s, - internal_id= %(internal_id)s, - role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), - (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), - (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))), - deleted_at= NULL, - created_at= default, - api_key= default, - jwt_iat= NULL, - weekly_report= default - WHERE user_id = %(user_id)s - RETURNING * - ), - au AS ( - UPDATE public.basic_authentication - SET password= default, - invitation_token= default, - invited_at= default, - change_pwd_token= default, - change_pwd_expire_at= default, - changed_at= NULL - WHERE user_id = %(user_id)s - RETURNING user_id - ) - SELECT u.user_id AS id, - u.email, - u.role, - u.name, - u.data, - (CASE WHEN u.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin, - (CASE WHEN u.role = 'admin' THEN TRUE ELSE FALSE END) AS admin, - (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, - origin - FROM u;""", - {"tenant_id": tenant_id, "email": email, "internal_id": internal_id, - "role": "admin" if admin else "member", "name": display_name, "origin": origin, - "role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now(), "user_id": user_uuid, "locale": locale, "name": full_name, "emails": emails}), - "user_id": user_id}) cur.execute( - query + cur.mogrify( + """ + WITH u AS ( + UPDATE public.users + SET + tenant_id = %(tenant_id)s, + deleted_at = NULL, + created_at = default, + api_key = default, + jwt_iat = NULL, + weekly_report = default + WHERE user_id = %(user_id)s + RETURNING * + ) + SELECT * + FROM u; + """, + {"tenant_id": tenant_id, "user_id": user_id} + ) ) return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index b1c73a568..83138dba6 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -306,30 +306,20 @@ def get_user( @public_app.post("/Users") async def create_user(r: UserRequest, tenant_id = Depends(auth_required)): - existing_db_user = users.get_scim_user_by_unique_values(r.userName) - # todo(jon): we have a conflict in our db schema and the docs for SCIM. - # here is a quote from section 3.6 of RFC 7644: - # For example, if a User resource is deleted, a CREATE - # request for a User resource with the same userName as the previously - # deleted resource SHOULD NOT fail with a 409 error due to userName - # conflict. - # this doesn't work with our db schema as `public.users.email` is UNIQUE - # but not conditionaly unique based on deletion. this would be fine if - # we did a hard delete, but it seems like we do soft deletes. - # so, we need to figure out how to handle this: - # 1. we do hard deletes for scim users. - # 2. we change how we handle the unique constraint for users on the email field. - # i think the easiest thing to do here would be 1, since it wouldn't require - # updating any other parts of the codebase (potentially) - if existing_db_user: + # note(jon): this method will return soft deleted users as well + existing_db_user = users.get_existing_scim_user_by_unique_values(r.userName) + if existing_db_user and existing_db_user["deletedAt"] is None: return _uniqueness_error_response() - db_user = users.create_scim_user( - email=r.userName, - # note(jon): scim schema does not require the `name.formatted` attribute, but we require `name`. - # so, we have to define the value ourselves here - name="", - tenant_id=tenant_id, - ) + if existing_db_user and existing_db_user["deletedAt"] is not None: + db_user = users.restore_scim_user(existing_db_user["userId"], tenant_id) + else: + db_user = users.create_scim_user( + email=r.userName, + # note(jon): scim schema does not require the `name.formatted` attribute, but we require `name`. + # so, we have to define the value ourselves here + name="", + tenant_id=tenant_id, + ) scim_user = _convert_db_user_to_scim_user(db_user) response = JSONResponse( status_code=201, @@ -391,15 +381,13 @@ def deactivate_user(user_id: str, r: PatchUserRequest): return Response(status_code=204, content="") -@public_app.delete("/Users/{user_uuid}", dependencies=[Depends(auth_required)]) -def delete_user(user_uuid: str): - """Delete user from database, hard-delete""" - tenant_id = 1 - user = users.get_by_uuid(user_uuid, tenant_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - users.__hard_delete_user_uuid(user_uuid) +@public_app.delete("/Users/{user_id}") +def delete_user(user_id: str, tenant_id = Depends(auth_required)): + user = users.get_scim_user_by_id(user_id, tenant_id) + if not user: + return _not_found_error_response(user_id) + users.soft_delete_scim_user_by_id(user_id, tenant_id) return Response(status_code=204, content="") diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index 255bfba3e..5a6256ee3 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -696,7 +696,6 @@ USER_SCHEMA = { } - SCHEMAS = sorted( [ SERVICE_PROVIDER_CONFIG_SCHEMA, From 2cbac647b8ccb2bfa5f2bca07c1db526929d02db Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 17 Apr 2025 17:24:49 +0200 Subject: [PATCH 10/34] remove optional PATCH /Users endpoint --- ee/api/routers/scim.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 83138dba6..bd9cdcf96 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -367,21 +367,6 @@ def update_user(user_id: str, r: UserRequest): raise HTTPException(status_code=500, detail=str(e)) -@public_app.patch("/Users/{user_id}", dependencies=[Depends(auth_required)]) -def deactivate_user(user_id: str, r: PatchUserRequest): - """Deactivate user, soft-delete""" - tenant_id = 1 - active = r.model_dump(mode='json')["Operations"][0]["value"]["active"] - if active: - raise HTTPException(status_code=404, detail="Activating user is not supported") - user = users.get_by_uuid(user_id, tenant_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - users.delete_member_as_admin(tenant_id, user["userId"]) - - return Response(status_code=204, content="") - - @public_app.delete("/Users/{user_id}") def delete_user(user_id: str, tenant_id = Depends(auth_required)): user = users.get_scim_user_by_id(user_id, tenant_id) From 6bee490312ecc68092b2b41aeb8e2c40881fb29e Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 18 Apr 2025 09:51:22 +0200 Subject: [PATCH 11/34] update PUT /Users endpoint --- ee/api/chalicelib/core/users.py | 33 ++++++++++++++- ee/api/routers/scim.py | 71 +++++++++++++++++---------------- ee/api/routers/scim_helpers.py | 37 +++++++++++++++++ 3 files changed, 106 insertions(+), 35 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index f9dba0619..52bfb1485 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -143,6 +143,37 @@ def reset_member(tenant_id, editor_id, user_id_to_update): return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}} +def update_scim_user( + user_id: int, + tenant_id: int, + email: str, +): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH u AS ( + UPDATE public.users + SET email = %(email)s + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + RETURNING * + ) + SELECT * + FROM u; + """, + { + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + } + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + def update(tenant_id, user_id, changes, output=True): AUTH_KEYS = ["password", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"] if len(changes.keys()) == 0: @@ -1124,7 +1155,7 @@ def restore_scim_user( api_key = default, jwt_iat = NULL, weekly_report = default - WHERE user_id = %(user_id)s + WHERE users.user_id = %(user_id)s RETURNING * ) SELECT * diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index bd9cdcf96..5908cc5ec 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -84,6 +84,18 @@ def _uniqueness_error_response(): ) +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", + } + ) + + @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: @@ -329,42 +341,33 @@ async def create_user(r: UserRequest, tenant_id = Depends(auth_required)): return response -@public_app.put("/Users/{user_id}", dependencies=[Depends(auth_required)]) -def update_user(user_id: str, r: UserRequest): - """Update SCIM User""" - tenant_id = 1 - user = users.get_by_uuid(user_id, tenant_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - changes = r.model_dump(mode='json', exclude={"schemas", "emails", "name", "locale", "groups", "password", "active"}) # some of these should be added later if necessary - nested_changes = r.model_dump(mode='json', include={"name", "emails"}) - mapping = {"userName": "email", "displayName": "name", "externalId": "internal_id"} # mapping between scim schema field names and local database model, can be done as config? - for k, v in mapping.items(): - if k in changes: - changes[v] = changes.pop(k) - changes["data"] = {} - for k, v in nested_changes.items(): - value_to_insert = v[0] if k == "emails" else v - changes["data"][k] = value_to_insert +@public_app.put("/Users/{user_id}") +def update_user(user_id: str, r: UserRequest, tenant_id = Depends(auth_required)): + db_resource = users.get_scim_user_by_id(user_id, tenant_id) + if not db_resource: + return _not_found_error_response(user_id) + current_scim_resource = _convert_db_user_to_scim_user(db_resource).model_dump(mode="json", exclude_none=True) + changes = r.model_dump(mode="json") + schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] try: - users.update(tenant_id, user["userId"], changes) - res = UserResponse( - schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], - id = user["data"]["userId"], - userName = r.userName, - name = r.name, - emails = r.emails, - displayName = r.displayName, - locale = r.locale, - externalId = r.externalId, - active = r.active, # ignore for now, since, can't insert actual timestamp - groups = [], # ignore + valid_mutable_changes = scim_helpers.filter_mutable_attributes(schema, changes, current_scim_resource) + except ValueError: + # todo(jon): will need to add a test for this once we have an immutable field + return _mutability_error_response() + try: + updated_db_resource = users.update_scim_user( + user_id, + tenant_id, + email=valid_mutable_changes["userName"], ) - - return JSONResponse(status_code=201, content=res.model_dump(mode='json')) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + updated_scim_resource = _convert_db_user_to_scim_user(updated_db_resource) + return JSONResponse( + status_code=200, + content=updated_scim_resource.model_dump(mode="json", exclude_none=True), + ) + except Exception: + # note(jon): for now, this is the only error that would happen when updating the scim user + return _uniqueness_error_response() @public_app.delete("/Users/{user_id}") diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py index d1cc0b651..7d1cf4b95 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim_helpers.py @@ -103,3 +103,40 @@ def exclude_attributes(resource: dict[str, Any], exclude_list: list[str]) -> dic else: new_resource[key] = value return new_resource + + +def filter_mutable_attributes(schema: dict[str, Any], requested_changes: dict[str, Any], current: 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) + + else: + # Unknown mutability: default to safe behavior (ignore) + continue + + return valid_changes From a8d36d40b5bee5da68d73307c5e920236aaa74b1 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 18 Apr 2025 10:19:05 +0200 Subject: [PATCH 12/34] remove Group endpoints as they don't seem required --- ee/api/chalicelib/core/roles.py | 181 ----------------------------- ee/api/routers/scim.py | 195 -------------------------------- 2 files changed, 376 deletions(-) diff --git a/ee/api/chalicelib/core/roles.py b/ee/api/chalicelib/core/roles.py index 955c76af0..0bad7aade 100644 --- a/ee/api/chalicelib/core/roles.py +++ b/ee/api/chalicelib/core/roles.py @@ -1,4 +1,3 @@ -import json from typing import Optional from fastapi import HTTPException, status @@ -79,21 +78,6 @@ def update(tenant_id, user_id, role_id, data: schemas.RolePayloadSchema): return helper.dict_to_camel_case(row) -def update_group_name(tenant_id, group_id, name): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""UPDATE public.roles - SET name= %(name)s - WHERE roles.data->>'group_id' = %(group_id)s - AND tenant_id = %(tenant_id)s - AND deleted_at ISNULL - AND protected = FALSE - RETURNING *;""", - {"tenant_id": tenant_id, "group_id": group_id, "name": name }) - cur.execute(query=query) - row = cur.fetchone() - - return helper.dict_to_camel_case(row) - def create(tenant_id, user_id, data: schemas.RolePayloadSchema): admin = users.get(user_id=user_id, tenant_id=tenant_id) @@ -128,35 +112,6 @@ def create(tenant_id, user_id, data: schemas.RolePayloadSchema): row["projects"] = [r["project_id"] for r in cur.fetchall()] return helper.dict_to_camel_case(row) -def create_as_admin(tenant_id, group_id, data: schemas.RolePayloadSchema): - - if __exists_by_name(tenant_id=tenant_id, name=data.name, exclude_id=None): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"name already exists.") - - if not data.all_projects and (data.projects is None or len(data.projects) == 0): - return {"errors": ["must specify a project or all projects"]} - if data.projects is not None and len(data.projects) > 0 and not data.all_projects: - data.projects = projects.is_authorized_batch(project_ids=data.projects, tenant_id=tenant_id) - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""INSERT INTO roles(tenant_id, name, description, permissions, all_projects, data) - VALUES (%(tenant_id)s, %(name)s, %(description)s, %(permissions)s::text[], %(all_projects)s, %(data)s) - RETURNING *;""", - {"tenant_id": tenant_id, "name": data.name, "description": data.description, - "permissions": data.permissions, "all_projects": data.all_projects, "data": json.dumps({ "group_id": group_id })}) - cur.execute(query=query) - row = cur.fetchone() - row["created_at"] = TimeUTC.datetime_to_timestamp(row["created_at"]) - row["projects"] = [] - if not data.all_projects: - role_id = row["role_id"] - query = cur.mogrify(f"""INSERT INTO roles_projects(role_id, project_id) - VALUES {",".join(f"(%(role_id)s,%(project_id_{i})s)" for i in range(len(data.projects)))} - RETURNING project_id;""", - {"role_id": role_id, **{f"project_id_{i}": p for i, p in enumerate(data.projects)}}) - cur.execute(query=query) - row["projects"] = [r["project_id"] for r in cur.fetchall()] - return helper.dict_to_camel_case(row) - def get_roles(tenant_id): with pg_client.PostgresClient() as cur: @@ -178,52 +133,8 @@ def get_roles(tenant_id): r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"]) return helper.list_to_camel_case(rows) -def get_roles_with_uuid(tenant_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT roles.*, COALESCE(projects, '{}') AS projects - FROM public.roles - LEFT JOIN LATERAL (SELECT array_agg(project_id) AS projects - FROM roles_projects - INNER JOIN projects USING (project_id) - WHERE roles_projects.role_id = roles.role_id - AND projects.deleted_at ISNULL ) AS role_projects ON (TRUE) - WHERE tenant_id =%(tenant_id)s - AND data ? 'group_id' - AND deleted_at IS NULL - AND not service_role - ORDER BY role_id;""", - {"tenant_id": tenant_id}) - cur.execute(query=query) - rows = cur.fetchall() - for r in rows: - r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"]) - return helper.list_to_camel_case(rows) - -def get_roles_with_uuid_paginated(tenant_id, start_index, count=None, name=None): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT roles.*, COALESCE(projects, '{}') AS projects - FROM public.roles - LEFT JOIN LATERAL (SELECT array_agg(project_id) AS projects - FROM roles_projects - INNER JOIN projects USING (project_id) - WHERE roles_projects.role_id = roles.role_id - AND projects.deleted_at ISNULL ) AS role_projects ON (TRUE) - WHERE tenant_id =%(tenant_id)s - AND data ? 'group_id' - AND deleted_at IS NULL - AND not service_role - AND name = COALESCE(%(name)s, name) - ORDER BY role_id - LIMIT %(count)s - OFFSET %(startIndex)s;""", - {"tenant_id": tenant_id, "name": name, "startIndex": start_index - 1, "count": count}) - cur.execute(query=query) - rows = cur.fetchall() - return helper.list_to_camel_case(rows) - def get_role_by_name(tenant_id, name): - ### "name" isn't unique in database with pg_client.PostgresClient() as cur: query = cur.mogrify("""SELECT * FROM public.roles @@ -272,29 +183,6 @@ def delete(tenant_id, user_id, role_id): cur.execute(query=query) return get_roles(tenant_id=tenant_id) -def delete_scim_group(tenant_id, group_uuid): - - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT 1 - FROM public.roles - WHERE data->>'group_id' = %(group_uuid)s - AND tenant_id = %(tenant_id)s - AND protected = TRUE - LIMIT 1;""", - {"tenant_id": tenant_id, "group_uuid": group_uuid}) - cur.execute(query) - if cur.fetchone() is not None: - return {"errors": ["this role is protected"]} - - query = cur.mogrify( - f"""DELETE FROM public.roles - WHERE roles.data->>'group_id' = %(group_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL - {"group_uuid": group_uuid}) - cur.execute(query) - - return get_roles(tenant_id=tenant_id) - - def get_role(tenant_id, role_id): with pg_client.PostgresClient() as cur: @@ -311,72 +199,3 @@ def get_role(tenant_id, role_id): if row is not None: row["created_at"] = TimeUTC.datetime_to_timestamp(row["created_at"]) return helper.dict_to_camel_case(row) - -def get_role_by_group_id(tenant_id, group_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT roles.* - FROM public.roles - WHERE tenant_id =%(tenant_id)s - AND deleted_at IS NULL - AND not service_role - AND data->>'group_id' = %(group_id)s - LIMIT 1;""", - {"tenant_id": tenant_id, "group_id": group_id}) - cur.execute(query=query) - row = cur.fetchone() - if row is not None: - row["created_at"] = TimeUTC.datetime_to_timestamp(row["created_at"]) - return helper.dict_to_camel_case(row) - -def get_users_by_group_uuid(tenant_id, group_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT - u.user_id, - u.name, - u.data - FROM public.roles r - LEFT JOIN public.users u USING (role_id, tenant_id) - WHERE u.tenant_id = %(tenant_id)s - AND u.deleted_at IS NULL - AND r.data->>'group_id' = %(group_id)s - """, - {"tenant_id": tenant_id, "group_id": group_id}) - cur.execute(query=query) - rows = cur.fetchall() - return helper.list_to_camel_case(rows) - -def get_member_permissions(tenant_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT - r.permissions - FROM public.roles r - WHERE r.tenant_id = %(tenant_id)s - AND r.name = 'Member' - AND r.deleted_at IS NULL - """, - {"tenant_id": tenant_id}) - cur.execute(query=query) - row = cur.fetchone() - return helper.dict_to_camel_case(row) - -def remove_group_membership(tenant_id, group_id, user_id): - with pg_client.PostgresClient() as cur: - query = cur.mogrify("""WITH r AS ( - SELECT role_id - FROM public.roles - WHERE data->>'group_id' = %(group_id)s - LIMIT 1 - ) - UPDATE public.users u - SET role_id= NULL - FROM r - WHERE u.data->>'user_id' = %(user_id)s - AND u.role_id = r.role_id - AND u.tenant_id = %(tenant_id)s - AND u.deleted_at IS NULL - RETURNING *;""", - {"tenant_id": tenant_id, "group_id": group_id, "user_id": user_id}) - cur.execute(query=query) - row = cur.fetchone() - - return helper.dict_to_camel_case(row) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 5908cc5ec..ec287b511 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -377,198 +377,3 @@ def delete_user(user_id: str, tenant_id = Depends(auth_required)): return _not_found_error_response(user_id) users.soft_delete_scim_user_by_id(user_id, tenant_id) return Response(status_code=204, content="") - - - -""" -Group endpoints -""" - -class Operation(BaseModel): - op: str - path: str = Field(default=None) - value: list[dict] | dict = Field(default=None) - -class GroupGetResponse(BaseModel): - schemas: list[str] = Field(default=["urn:ietf:params:scim:api:messages:2.0:ListResponse"]) - totalResults: int - startIndex: int - itemsPerPage: int - resources: list = Field(alias="Resources") - -class GroupRequest(BaseModel): - schemas: list[str] = Field(default=["urn:ietf:params:scim:schemas:core:2.0:Group"]) - displayName: str = Field(default=None) - members: list = Field(default=None) - operations: list[Operation] = Field(default=None, alias="Operations") - -class GroupPatchRequest(BaseModel): - schemas: list[str] = Field(default=["urn:ietf:params:scim:api:messages:2.0:PatchOp"]) - operations: list[Operation] = Field(alias="Operations") - -class GroupResponse(BaseModel): - schemas: list[str] = Field(default=["urn:ietf:params:scim:schemas:core:2.0:Group"]) - id: str - displayName: str - members: list - meta: dict = Field(default={"resourceType": "Group"}) - - -@public_app.get("/Groups", dependencies=[Depends(auth_required)]) -def get_groups( - start_index: int = Query(1, alias="startIndex"), - count: Optional[int] = Query(None, alias="count"), - group_name: Optional[str] = Query(None, alias="filter"), - ): - """Get groups""" - tenant_id = 1 - res = [] - if group_name: - group_name = group_name.split(" ")[2].strip('"') - - groups = roles.get_roles_with_uuid_paginated(tenant_id, start_index, count, group_name) - res = [{ - "id": group["data"]["groupId"], - "meta": { - "created": group["createdAt"], - "lastModified": "", # not currently a field - "version": "v1.0" - }, - "displayName": group["name"] - } for group in groups - ] - return JSONResponse( - status_code=200, - content=GroupGetResponse( - totalResults=len(groups), - startIndex=start_index, - itemsPerPage=len(groups), - Resources=res - ).model_dump(mode='json')) - -@public_app.get("/Groups/{group_id}", dependencies=[Depends(auth_required)]) -def get_group(group_id: str): - """Get a group by id""" - tenant_id = 1 - group = roles.get_role_by_group_id(tenant_id, group_id) - if not group: - raise HTTPException(status_code=404, detail="Group not found") - members = roles.get_users_by_group_uuid(tenant_id, group["data"]["groupId"]) - members = [{"value": member["data"]["userId"], "display": member["name"]} for member in members] - - return JSONResponse( - status_code=200, - content=GroupResponse( - id=group["data"]["groupId"], - displayName=group["name"], - members=members, - ).model_dump(mode='json')) - -@public_app.post("/Groups", dependencies=[Depends(auth_required)]) -def create_group(r: GroupRequest): - """Create a group""" - tenant_id = 1 - member_role = roles.get_member_permissions(tenant_id) - try: - data = schemas.RolePayloadSchema(name=r.displayName, permissions=member_role["permissions"]) # permissions by default are same as for member role - group = roles.create_as_admin(tenant_id, uuid.uuid4().hex, data) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - added_members = [] - for member in r.members: - user = users.get_by_uuid(member["value"], tenant_id) - if user: - users.update(tenant_id, user["userId"], {"role_id": group["roleId"]}) - added_members.append({ - "value": user["data"]["userId"], - "display": user["name"] - }) - - return JSONResponse( - status_code=200, - content=GroupResponse( - id=group["data"]["groupId"], - displayName=group["name"], - members=added_members, - ).model_dump(mode='json')) - - -@public_app.put("/Groups/{group_id}", dependencies=[Depends(auth_required)]) -def update_put_group(group_id: str, r: GroupRequest): - """Update a group or members of the group (not used by anything yet)""" - tenant_id = 1 - group = roles.get_role_by_group_id(tenant_id, group_id) - if not group: - raise HTTPException(status_code=404, detail="Group not found") - - if r.operations and r.operations[0].op == "replace" and r.operations[0].path is None: - roles.update_group_name(tenant_id, group["data"]["groupId"], r.operations[0].value["displayName"]) - return Response(status_code=200, content="") - - members = r.members - modified_members = [] - for member in members: - user = users.get_by_uuid(member["value"], tenant_id) - if user: - users.update(tenant_id, user["userId"], {"role_id": group["roleId"]}) - modified_members.append({ - "value": user["data"]["userId"], - "display": user["name"] - }) - - return JSONResponse( - status_code=200, - content=GroupResponse( - id=group_id, - displayName=group["name"], - members=modified_members, - ).model_dump(mode='json')) - - -@public_app.patch("/Groups/{group_id}", dependencies=[Depends(auth_required)]) -def update_patch_group(group_id: str, r: GroupPatchRequest): - """Update a group or members of the group, used by AIW""" - tenant_id = 1 - group = roles.get_role_by_group_id(tenant_id, group_id) - if not group: - raise HTTPException(status_code=404, detail="Group not found") - if r.operations[0].op == "replace" and r.operations[0].path is None: - roles.update_group_name(tenant_id, group["data"]["groupId"], r.operations[0].value["displayName"]) - return Response(status_code=200, content="") - - modified_members = [] - for op in r.operations: - if op.op == "add" or op.op == "replace": - # Both methods work as "replace" - for u in op.value: - user = users.get_by_uuid(u["value"], tenant_id) - if user: - users.update(tenant_id, user["userId"], {"role_id": group["roleId"]}) - modified_members.append({ - "value": user["data"]["userId"], - "display": user["name"] - }) - elif op.op == "remove": - user_id = re.search(r'\[value eq \"([a-f0-9]+)\"\]', op.path).group(1) - roles.remove_group_membership(tenant_id, group_id, user_id) - return JSONResponse( - status_code=200, - content=GroupResponse( - id=group_id, - displayName=group["name"], - members=modified_members, - ).model_dump(mode='json')) - - -@public_app.delete("/Groups/{group_id}", dependencies=[Depends(auth_required)]) -def delete_group(group_id: str): - """Delete a group, hard-delete""" - # possibly need to set the user's roles to default member role, instead of null - tenant_id = 1 - group = roles.get_role_by_group_id(tenant_id, group_id) - if not group: - raise HTTPException(status_code=404, detail="Group not found") - roles.delete_scim_group(tenant_id, group["data"]["groupId"]) - - return Response(status_code=200, content="") From 464b9b1b4774e1121ea92489677ccc917f6af362 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 18 Apr 2025 10:37:31 +0200 Subject: [PATCH 13/34] reformat files and remove unnecessary imports --- ee/api/chalicelib/core/roles.py | 107 ++++-- ee/api/chalicelib/core/users.py | 549 ++++++++++++++++++--------- ee/api/chalicelib/utils/scim_auth.py | 23 +- ee/api/routers/scim.py | 124 +++--- ee/api/routers/scim_constants.py | 63 +-- ee/api/routers/scim_helpers.py | 22 +- 6 files changed, 588 insertions(+), 300 deletions(-) diff --git a/ee/api/chalicelib/core/roles.py b/ee/api/chalicelib/core/roles.py index 0bad7aade..321ca1102 100644 --- a/ee/api/chalicelib/core/roles.py +++ b/ee/api/chalicelib/core/roles.py @@ -9,13 +9,15 @@ from chalicelib.utils.TimeUTC import TimeUTC def __exists_by_name(tenant_id: int, name: str, exclude_id: Optional[int]) -> bool: with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""SELECT EXISTS(SELECT 1 + query = cur.mogrify( + f"""SELECT EXISTS(SELECT 1 FROM public.roles WHERE tenant_id = %(tenant_id)s AND name ILIKE %(name)s AND deleted_at ISNULL {"AND role_id!=%(exclude_id)s" if exclude_id else ""}) AS exists;""", - {"tenant_id": tenant_id, "name": name, "exclude_id": exclude_id}) + {"tenant_id": tenant_id, "name": name, "exclude_id": exclude_id}, + ) cur.execute(query=query) row = cur.fetchone() return row["exists"] @@ -27,24 +29,31 @@ def update(tenant_id, user_id, role_id, data: schemas.RolePayloadSchema): if not admin["admin"] and not admin["superAdmin"]: return {"errors": ["unauthorized"]} if __exists_by_name(tenant_id=tenant_id, name=data.name, exclude_id=role_id): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"name already exists.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="name already exists." + ) if not data.all_projects and (data.projects is None or len(data.projects) == 0): return {"errors": ["must specify a project or all projects"]} if data.projects is not None and len(data.projects) > 0 and not data.all_projects: - data.projects = projects.is_authorized_batch(project_ids=data.projects, tenant_id=tenant_id) + data.projects = projects.is_authorized_batch( + project_ids=data.projects, tenant_id=tenant_id + ) with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT 1 + query = cur.mogrify( + """SELECT 1 FROM public.roles WHERE role_id = %(role_id)s AND tenant_id = %(tenant_id)s AND protected = TRUE LIMIT 1;""", - {"tenant_id": tenant_id, "role_id": role_id}) + {"tenant_id": tenant_id, "role_id": role_id}, + ) cur.execute(query=query) if cur.fetchone() is not None: return {"errors": ["this role is protected"]} - query = cur.mogrify("""UPDATE public.roles + query = cur.mogrify( + """UPDATE public.roles SET name= %(name)s, description= %(description)s, permissions= %(permissions)s, @@ -56,23 +65,31 @@ def update(tenant_id, user_id, role_id, data: schemas.RolePayloadSchema): RETURNING *, COALESCE((SELECT ARRAY_AGG(project_id) FROM roles_projects WHERE roles_projects.role_id=%(role_id)s),'{}') AS projects;""", - {"tenant_id": tenant_id, "role_id": role_id, **data.model_dump()}) + {"tenant_id": tenant_id, "role_id": role_id, **data.model_dump()}, + ) cur.execute(query=query) row = cur.fetchone() row["created_at"] = TimeUTC.datetime_to_timestamp(row["created_at"]) if not data.all_projects: d_projects = [i for i in row["projects"] if i not in data.projects] if len(d_projects) > 0: - query = cur.mogrify("""DELETE FROM roles_projects + query = cur.mogrify( + """DELETE FROM roles_projects WHERE role_id=%(role_id)s AND project_id IN %(project_ids)s""", - {"role_id": role_id, "project_ids": tuple(d_projects)}) + {"role_id": role_id, "project_ids": tuple(d_projects)}, + ) cur.execute(query=query) n_projects = [i for i in data.projects if i not in row["projects"]] if len(n_projects) > 0: - query = cur.mogrify(f"""INSERT INTO roles_projects(role_id, project_id) + query = cur.mogrify( + f"""INSERT INTO roles_projects(role_id, project_id) VALUES {",".join([f"(%(role_id)s,%(project_id_{i})s)" for i in range(len(n_projects))])}""", - {"role_id": role_id, **{f"project_id_{i}": p for i, p in enumerate(n_projects)}}) + { + "role_id": role_id, + **{f"project_id_{i}": p for i, p in enumerate(n_projects)}, + }, + ) cur.execute(query=query) row["projects"] = data.projects @@ -86,28 +103,44 @@ def create(tenant_id, user_id, data: schemas.RolePayloadSchema): return {"errors": ["unauthorized"]} if __exists_by_name(tenant_id=tenant_id, name=data.name, exclude_id=None): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"name already exists.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="name already exists." + ) if not data.all_projects and (data.projects is None or len(data.projects) == 0): return {"errors": ["must specify a project or all projects"]} if data.projects is not None and len(data.projects) > 0 and not data.all_projects: - data.projects = projects.is_authorized_batch(project_ids=data.projects, tenant_id=tenant_id) + data.projects = projects.is_authorized_batch( + project_ids=data.projects, tenant_id=tenant_id + ) with pg_client.PostgresClient() as cur: - query = cur.mogrify("""INSERT INTO roles(tenant_id, name, description, permissions, all_projects) + query = cur.mogrify( + """INSERT INTO roles(tenant_id, name, description, permissions, all_projects) VALUES (%(tenant_id)s, %(name)s, %(description)s, %(permissions)s::text[], %(all_projects)s) RETURNING *;""", - {"tenant_id": tenant_id, "name": data.name, "description": data.description, - "permissions": data.permissions, "all_projects": data.all_projects}) + { + "tenant_id": tenant_id, + "name": data.name, + "description": data.description, + "permissions": data.permissions, + "all_projects": data.all_projects, + }, + ) cur.execute(query=query) row = cur.fetchone() row["created_at"] = TimeUTC.datetime_to_timestamp(row["created_at"]) row["projects"] = [] if not data.all_projects: role_id = row["role_id"] - query = cur.mogrify(f"""INSERT INTO roles_projects(role_id, project_id) + query = cur.mogrify( + f"""INSERT INTO roles_projects(role_id, project_id) VALUES {",".join(f"(%(role_id)s,%(project_id_{i})s)" for i in range(len(data.projects)))} RETURNING project_id;""", - {"role_id": role_id, **{f"project_id_{i}": p for i, p in enumerate(data.projects)}}) + { + "role_id": role_id, + **{f"project_id_{i}": p for i, p in enumerate(data.projects)}, + }, + ) cur.execute(query=query) row["projects"] = [r["project_id"] for r in cur.fetchall()] return helper.dict_to_camel_case(row) @@ -115,7 +148,8 @@ def create(tenant_id, user_id, data: schemas.RolePayloadSchema): def get_roles(tenant_id): with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT roles.*, COALESCE(projects, '{}') AS projects + query = cur.mogrify( + """SELECT roles.*, COALESCE(projects, '{}') AS projects FROM public.roles LEFT JOIN LATERAL (SELECT array_agg(project_id) AS projects FROM roles_projects @@ -126,7 +160,8 @@ def get_roles(tenant_id): AND deleted_at IS NULL AND not service_role ORDER BY role_id;""", - {"tenant_id": tenant_id}) + {"tenant_id": tenant_id}, + ) cur.execute(query=query) rows = cur.fetchall() for r in rows: @@ -136,12 +171,14 @@ def get_roles(tenant_id): def get_role_by_name(tenant_id, name): with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT * + query = cur.mogrify( + """SELECT * FROM public.roles WHERE tenant_id =%(tenant_id)s AND deleted_at IS NULL AND name ILIKE %(name)s;""", - {"tenant_id": tenant_id, "name": name}) + {"tenant_id": tenant_id, "name": name}, + ) cur.execute(query=query) row = cur.fetchone() if row is not None: @@ -155,45 +192,53 @@ def delete(tenant_id, user_id, role_id): if not admin["admin"] and not admin["superAdmin"]: return {"errors": ["unauthorized"]} with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT 1 + query = cur.mogrify( + """SELECT 1 FROM public.roles WHERE role_id = %(role_id)s AND tenant_id = %(tenant_id)s AND protected = TRUE LIMIT 1;""", - {"tenant_id": tenant_id, "role_id": role_id}) + {"tenant_id": tenant_id, "role_id": role_id}, + ) cur.execute(query=query) if cur.fetchone() is not None: return {"errors": ["this role is protected"]} - query = cur.mogrify("""SELECT 1 + query = cur.mogrify( + """SELECT 1 FROM public.users WHERE role_id = %(role_id)s AND tenant_id = %(tenant_id)s LIMIT 1;""", - {"tenant_id": tenant_id, "role_id": role_id}) + {"tenant_id": tenant_id, "role_id": role_id}, + ) cur.execute(query=query) if cur.fetchone() is not None: return {"errors": ["this role is already attached to other user(s)"]} - query = cur.mogrify("""UPDATE public.roles + query = cur.mogrify( + """UPDATE public.roles SET deleted_at = timezone('utc'::text, now()) WHERE role_id = %(role_id)s AND tenant_id = %(tenant_id)s AND protected = FALSE;""", - {"tenant_id": tenant_id, "role_id": role_id}) + {"tenant_id": tenant_id, "role_id": role_id}, + ) cur.execute(query=query) return get_roles(tenant_id=tenant_id) def get_role(tenant_id, role_id): with pg_client.PostgresClient() as cur: - query = cur.mogrify("""SELECT roles.* + query = cur.mogrify( + """SELECT roles.* FROM public.roles WHERE tenant_id =%(tenant_id)s AND deleted_at IS NULL AND not service_role AND role_id = %(role_id)s LIMIT 1;""", - {"tenant_id": tenant_id, "role_id": role_id}) + {"tenant_id": tenant_id, "role_id": role_id}, + ) cur.execute(query=query) row = cur.fetchone() if row is not None: diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 52bfb1485..a57a194e5 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -1,7 +1,7 @@ import json import logging import secrets -from typing import Any, Optional +from typing import Optional from decouple import config from fastapi import BackgroundTasks, HTTPException @@ -25,9 +25,12 @@ def __generate_invitation_token(): return secrets.token_urlsafe(64) -def create_new_member(tenant_id, email, invitation_token, admin, name, owner=False, role_id=None): +def create_new_member( + tenant_id, email, invitation_token, admin, name, owner=False, role_id=None +): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ + query = cur.mogrify( + """\ WITH u AS ( INSERT INTO public.users (tenant_id, email, role, name, data, role_id) VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, @@ -53,10 +56,16 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal roles.name AS role_name, TRUE AS has_password FROM au,u LEFT JOIN roles USING(tenant_id) WHERE roles.role_id IS NULL OR roles.role_id = (SELECT u.role_id FROM u);""", - {"tenant_id": tenant_id, "email": email, - "role": "owner" if owner else "admin" if admin else "member", "name": name, - "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), - "invitation_token": invitation_token, "role_id": role_id}) + { + "tenant_id": tenant_id, + "email": email, + "role": "owner" if owner else "admin" if admin else "member", + "name": name, + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), + "invitation_token": invitation_token, + "role_id": role_id, + }, + ) cur.execute(query) row = helper.dict_to_camel_case(cur.fetchone()) if row: @@ -64,9 +73,12 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal return row -def restore_member(tenant_id, user_id, email, invitation_token, admin, name, owner=False, role_id=None): +def restore_member( + tenant_id, user_id, email, invitation_token, admin, name, owner=False, role_id=None +): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ + query = cur.mogrify( + """\ WITH u AS (UPDATE public.users SET name= %(name)s, role = %(role)s, @@ -106,9 +118,16 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own TRUE AS has_password FROM au,u LEFT JOIN roles USING(tenant_id) WHERE roles.role_id IS NULL OR roles.role_id = (SELECT u.role_id FROM u);""", - {"tenant_id": tenant_id, "user_id": user_id, "email": email, - "role": "owner" if owner else "admin" if admin else "member", "name": name, - "role_id": role_id, "invitation_token": invitation_token}) + { + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + "role": "owner" if owner else "admin" if admin else "member", + "name": name, + "role_id": role_id, + "invitation_token": invitation_token, + }, + ) cur.execute(query) result = cur.fetchone() result["created_at"] = TimeUTC.datetime_to_timestamp(result["created_at"]) @@ -118,7 +137,8 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own def generate_new_invitation(user_id): invitation_token = __generate_invitation_token() with pg_client.PostgresClient() as cur: - query = cur.mogrify("""\ + query = cur.mogrify( + """\ UPDATE public.basic_authentication SET invitation_token = %(invitation_token)s, invited_at = timezone('utc'::text, now()), @@ -126,10 +146,9 @@ def generate_new_invitation(user_id): change_pwd_token = NULL WHERE user_id=%(user_id)s RETURNING invitation_token;""", - {"user_id": user_id, "invitation_token": invitation_token}) - cur.execute( - query + {"user_id": user_id, "invitation_token": invitation_token}, ) + cur.execute(query) return __get_invitation_link(cur.fetchone().pop("invitation_token")) @@ -168,14 +187,20 @@ def update_scim_user( "tenant_id": tenant_id, "user_id": user_id, "email": email, - } + }, ) ) return helper.dict_to_camel_case(cur.fetchone()) def update(tenant_id, user_id, changes, output=True): - AUTH_KEYS = ["password", "invitationToken", "invitedAt", "changePwdExpireAt", "changePwdToken"] + AUTH_KEYS = [ + "password", + "invitationToken", + "invitedAt", + "changePwdExpireAt", + "changePwdToken", + ] if len(changes.keys()) == 0: return None @@ -184,7 +209,9 @@ def update(tenant_id, user_id, changes, output=True): for key in changes.keys(): if key in AUTH_KEYS: if key == "password": - sub_query_bauth.append("password = crypt(%(password)s, gen_salt('bf', 12))") + sub_query_bauth.append( + "password = crypt(%(password)s, gen_salt('bf', 12))" + ) sub_query_bauth.append("changed_at = timezone('utc'::text, now())") else: sub_query_bauth.append(f"{helper.key_to_snake_case(key)} = %({key})s") @@ -193,7 +220,9 @@ def update(tenant_id, user_id, changes, output=True): sub_query_users.append("""role_id=(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1), (SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))""") - elif key == "data": # this is hardcoded, maybe a generic solution would be better + elif ( + key == "data" + ): # this is hardcoded, maybe a generic solution would be better sub_query_users.append(f"data = data || %({(key)})s") else: sub_query_users.append(f"{helper.key_to_snake_case(key)} = %({key})s") @@ -202,26 +231,35 @@ def update(tenant_id, user_id, changes, output=True): changes["data"] = Json(changes["data"]) with pg_client.PostgresClient() as cur: if len(sub_query_users) > 0: - query = cur.mogrify(f"""\ + query = cur.mogrify( + f"""\ UPDATE public.users SET {" ,".join(sub_query_users)} WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s;""", - {"tenant_id": tenant_id, "user_id": user_id, **changes}) + {"tenant_id": tenant_id, "user_id": user_id, **changes}, + ) cur.execute(query) if len(sub_query_bauth) > 0: - query = cur.mogrify(f"""\ + query = cur.mogrify( + f"""\ UPDATE public.basic_authentication SET {" ,".join(sub_query_bauth)} WHERE basic_authentication.user_id = %(user_id)s;""", - {"tenant_id": tenant_id, "user_id": user_id, **changes}) + {"tenant_id": tenant_id, "user_id": user_id, **changes}, + ) cur.execute(query) if not output: return None return get(user_id=user_id, tenant_id=tenant_id) -def create_member(tenant_id, user_id, data: schemas.CreateMemberSchema, background_tasks: BackgroundTasks): +def create_member( + tenant_id, + user_id, + data: schemas.CreateMemberSchema, + background_tasks: BackgroundTasks, +): admin = get(tenant_id=tenant_id, user_id=user_id) if not admin["admin"] and not admin["superAdmin"]: return {"errors": ["unauthorized"]} @@ -235,7 +273,9 @@ def create_member(tenant_id, user_id, data: schemas.CreateMemberSchema, backgrou data.name = data.email role_id = data.roleId if role_id is None: - role_id = roles.get_role_by_name(tenant_id=tenant_id, name="member").get("roleId") + role_id = roles.get_role_by_name(tenant_id=tenant_id, name="member").get( + "roleId" + ) else: role = roles.get_role(tenant_id=tenant_id, role_id=role_id) if role is None: @@ -245,22 +285,46 @@ def create_member(tenant_id, user_id, data: schemas.CreateMemberSchema, backgrou invitation_token = __generate_invitation_token() user = get_deleted_user_by_email(email=data.email) if user is not None and user["tenantId"] == tenant_id: - new_member = restore_member(tenant_id=tenant_id, email=data.email, invitation_token=invitation_token, - admin=data.admin, name=data.name, user_id=user["userId"], role_id=role_id) + new_member = restore_member( + tenant_id=tenant_id, + email=data.email, + invitation_token=invitation_token, + admin=data.admin, + name=data.name, + user_id=user["userId"], + role_id=role_id, + ) elif user is not None: __hard_delete_user(user_id=user["userId"]) - new_member = create_new_member(tenant_id=tenant_id, email=data.email, invitation_token=invitation_token, - admin=data.admin, name=data.name, role_id=role_id) + new_member = create_new_member( + tenant_id=tenant_id, + email=data.email, + invitation_token=invitation_token, + admin=data.admin, + name=data.name, + role_id=role_id, + ) else: - new_member = create_new_member(tenant_id=tenant_id, email=data.email, invitation_token=invitation_token, - admin=data.admin, name=data.name, role_id=role_id) - new_member["invitationLink"] = __get_invitation_link(new_member.pop("invitationToken")) - background_tasks.add_task(email_helper.send_team_invitation, **{ - "recipient": data.email, - "invitation_link": new_member["invitationLink"], - "client_id": tenants.get_by_tenant_id(tenant_id)["name"], - "sender_name": admin["name"] - }) + new_member = create_new_member( + tenant_id=tenant_id, + email=data.email, + invitation_token=invitation_token, + admin=data.admin, + name=data.name, + role_id=role_id, + ) + new_member["invitationLink"] = __get_invitation_link( + new_member.pop("invitationToken") + ) + background_tasks.add_task( + email_helper.send_team_invitation, + **{ + "recipient": data.email, + "invitation_link": new_member["invitationLink"], + "client_id": tenants.get_by_tenant_id(tenant_id)["name"], + "sender_name": admin["name"], + }, + ) return {"data": new_member} @@ -271,14 +335,14 @@ def __get_invitation_link(invitation_token): def allow_password_change(user_id, delta_min=10): pass_token = secrets.token_urlsafe(8) with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""UPDATE public.basic_authentication + query = cur.mogrify( + """UPDATE public.basic_authentication SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'), change_pwd_token = %(pass_token)s WHERE user_id = %(user_id)s""", - {"user_id": user_id, "delta": delta_min, "pass_token": pass_token}) - cur.execute( - query + {"user_id": user_id, "delta": delta_min, "pass_token": pass_token}, ) + cur.execute(query) return pass_token @@ -286,7 +350,7 @@ def get(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.tenant_id, email, @@ -310,11 +374,13 @@ def get(user_id, tenant_id): AND users.deleted_at IS NULL AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s) LIMIT 1;""", - {"userId": user_id, "tenant_id": tenant_id}) + {"userId": user_id, "tenant_id": tenant_id}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) + def get_scim_user_by_id(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -341,12 +407,13 @@ def generate_new_api_key(user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""UPDATE public.users + """UPDATE public.users SET api_key=generate_api_key(20) WHERE users.user_id = %(userId)s AND deleted_at IS NULL RETURNING api_key;""", - {"userId": user_id}) + {"userId": user_id}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) @@ -356,7 +423,7 @@ def __get_account_info(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT users.name, + """SELECT users.name, tenants.name AS tenant_name, tenants.opt_out FROM public.users INNER JOIN public.tenants USING (tenant_id) @@ -364,14 +431,19 @@ def __get_account_info(tenant_id, user_id): AND tenants.tenant_id= %(tenantId)s AND tenants.deleted_at IS NULL AND users.deleted_at IS NULL;""", - {"tenantId": tenant_id, "userId": user_id}) + {"tenantId": tenant_id, "userId": user_id}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) def edit_account(user_id, tenant_id, changes: schemas.EditAccountSchema): - if changes.opt_out is not None or changes.tenantName is not None and len(changes.tenantName) > 0: + if ( + changes.opt_out is not None + or changes.tenantName is not None + and len(changes.tenantName) > 0 + ): user = get(user_id=user_id, tenant_id=tenant_id) if not user["superAdmin"] and not user["admin"]: return {"errors": ["unauthorized"]} @@ -391,7 +463,9 @@ def edit_account(user_id, tenant_id, changes: schemas.EditAccountSchema): return {"data": __get_account_info(tenant_id=tenant_id, user_id=user_id)} -def edit_member(user_id_to_update, tenant_id, changes: schemas.EditMemberSchema, editor_id): +def edit_member( + user_id_to_update, tenant_id, changes: schemas.EditMemberSchema, editor_id +): user = get_member(user_id=user_id_to_update, tenant_id=tenant_id) _changes = {} if editor_id != user_id_to_update: @@ -429,7 +503,12 @@ def edit_member(user_id_to_update, tenant_id, changes: schemas.EditMemberSchema, return {"errors": ["invalid role"]} if len(_changes.keys()) > 0: - update(tenant_id=tenant_id, user_id=user_id_to_update, changes=_changes, output=False) + update( + tenant_id=tenant_id, + user_id=user_id_to_update, + changes=_changes, + output=False, + ) return {"data": get_member(user_id=user_id_to_update, tenant_id=tenant_id)} return {"data": user} @@ -443,7 +522,7 @@ def get_existing_scim_user_by_unique_values(email): FROM public.users WHERE users.email = %(email)s """, - {"email": email} + {"email": email}, ) ) return helper.dict_to_camel_case(cur.fetchone()) @@ -453,7 +532,7 @@ def get_by_email_only(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.tenant_id, users.email, @@ -472,11 +551,13 @@ def get_by_email_only(email): WHERE users.email = %(email)s AND users.deleted_at IS NULL LIMIT 1;""", - {"email": email}) + {"email": email}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) + def get_users_paginated(start_index, tenant_id, count=None): with pg_client.PostgresClient() as cur: cur.execute( @@ -490,11 +571,7 @@ def get_users_paginated(start_index, tenant_id, count=None): LIMIT %(limit)s OFFSET %(offset)s; """, - { - "offset": start_index - 1, - "limit": count, - "tenant_id": tenant_id - }, + {"offset": start_index - 1, "limit": count, "tenant_id": tenant_id}, ) ) r = cur.fetchall() @@ -505,7 +582,7 @@ def get_member(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.email, users.role, @@ -525,7 +602,8 @@ def get_member(tenant_id, user_id): LEFT JOIN public.roles USING (role_id) WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL AND users.user_id = %(user_id)s ORDER BY name, user_id""", - {"tenant_id": tenant_id, "user_id": user_id}) + {"tenant_id": tenant_id, "user_id": user_id}, + ) ) u = helper.dict_to_camel_case(cur.fetchone()) if u: @@ -542,7 +620,7 @@ def get_members(tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.email, users.role, @@ -564,7 +642,8 @@ def get_members(tenant_id): AND users.deleted_at IS NULL AND NOT users.service_account ORDER BY name, user_id""", - {"tenant_id": tenant_id}) + {"tenant_id": tenant_id}, + ) ) r = cur.fetchall() if len(r): @@ -572,7 +651,9 @@ def get_members(tenant_id): for u in r: u["createdAt"] = TimeUTC.datetime_to_timestamp(u["createdAt"]) if u["invitationToken"]: - u["invitationLink"] = __get_invitation_link(u.pop("invitationToken")) + u["invitationLink"] = __get_invitation_link( + u.pop("invitationToken") + ) else: u["invitationLink"] = None return r @@ -597,20 +678,26 @@ def delete_member(user_id, tenant_id, id_to_delete): with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify(f"""UPDATE public.users + cur.mogrify( + """UPDATE public.users SET deleted_at = timezone('utc'::text, now()), jwt_iat= NULL, jwt_refresh_jti= NULL, jwt_refresh_iat= NULL, role_id=NULL WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""", - {"user_id": id_to_delete, "tenant_id": tenant_id})) + {"user_id": id_to_delete, "tenant_id": tenant_id}, + ) + ) cur.execute( - cur.mogrify(f"""UPDATE public.basic_authentication + cur.mogrify( + """UPDATE public.basic_authentication SET password= NULL, invitation_token= NULL, invited_at= NULL, changed_at= NULL, change_pwd_expire_at= NULL, change_pwd_token= NULL WHERE user_id=%(user_id)s;""", - {"user_id": id_to_delete, "tenant_id": tenant_id})) + {"user_id": id_to_delete, "tenant_id": tenant_id}, + ) + ) return {"data": get_members(tenant_id=tenant_id)} @@ -618,11 +705,21 @@ def change_password(tenant_id, user_id, email, old_password, new_password): item = get(tenant_id=tenant_id, user_id=user_id) if item is None: return {"errors": ["access denied"]} - if item["origin"] is not None and config("enforce_SSO", cast=bool, default=False) \ - and not item["superAdmin"] and helper.is_saml2_available(): - return {"errors": ["Please use your SSO to change your password, enforced by admin"]} + if ( + item["origin"] is not None + and config("enforce_SSO", cast=bool, default=False) + and not item["superAdmin"] + and helper.is_saml2_available() + ): + return { + "errors": ["Please use your SSO to change your password, enforced by admin"] + } if item["origin"] is not None and item["hasPassword"] is False: - return {"errors": ["cannot change your password because you are logged-in from an SSO service"]} + return { + "errors": [ + "cannot change your password because you are logged-in from an SSO service" + ] + } if old_password == new_password: return {"errors": ["old and new password are the same"]} auth = authenticate(email, old_password, for_change_password=True) @@ -630,23 +727,7 @@ def change_password(tenant_id, user_id, email, old_password, new_password): return {"errors": ["wrong password"]} changes = {"password": new_password} user = update(tenant_id=tenant_id, user_id=user_id, changes=changes) - r = authenticate(user['email'], new_password) - - return { - "jwt": r.pop("jwt"), - "refreshToken": r.pop("refreshToken"), - "refreshTokenMaxAge": r.pop("refreshTokenMaxAge"), - "spotJwt": r.pop("spotJwt"), - "spotRefreshToken": r.pop("spotRefreshToken"), - "spotRefreshTokenMaxAge": r.pop("spotRefreshTokenMaxAge"), - "tenantId": tenant_id - } - - -def set_password_invitation(tenant_id, user_id, new_password): - changes = {"password": new_password} - user = update(tenant_id=tenant_id, user_id=user_id, changes=changes) - r = authenticate(user['email'], new_password) + r = authenticate(user["email"], new_password) return { "jwt": r.pop("jwt"), @@ -656,7 +737,23 @@ def set_password_invitation(tenant_id, user_id, new_password): "spotRefreshToken": r.pop("spotRefreshToken"), "spotRefreshTokenMaxAge": r.pop("spotRefreshTokenMaxAge"), "tenantId": tenant_id, - **r + } + + +def set_password_invitation(tenant_id, user_id, new_password): + changes = {"password": new_password} + user = update(tenant_id=tenant_id, user_id=user_id, changes=changes) + r = authenticate(user["email"], new_password) + + return { + "jwt": r.pop("jwt"), + "refreshToken": r.pop("refreshToken"), + "refreshTokenMaxAge": r.pop("refreshTokenMaxAge"), + "spotJwt": r.pop("spotJwt"), + "spotRefreshToken": r.pop("spotRefreshToken"), + "spotRefreshTokenMaxAge": r.pop("spotRefreshTokenMaxAge"), + "tenantId": tenant_id, + **r, } @@ -664,14 +761,15 @@ def email_exists(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT count(user_id) FROM public.users WHERE email = %(email)s AND deleted_at IS NULL LIMIT 1;""", - {"email": email}) + {"email": email}, + ) ) r = cur.fetchone() return r["count"] > 0 @@ -681,14 +779,15 @@ def get_deleted_user_by_email(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT * FROM public.users WHERE email = %(email)s AND deleted_at NOTNULL LIMIT 1;""", - {"email": email}) + {"email": email}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) @@ -698,7 +797,7 @@ def get_by_invitation_token(token, pass_token=None): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT *, DATE_PART('day',timezone('utc'::text, now()) \ - COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation, @@ -707,7 +806,8 @@ def get_by_invitation_token(token, pass_token=None): FROM public.users INNER JOIN public.basic_authentication USING(user_id) WHERE invitation_token = %(token)s {"AND change_pwd_token = %(pass_token)s" if pass_token else ""} LIMIT 1;""", - {"token": token, "pass_token": pass_token}) + {"token": token, "pass_token": pass_token}, + ) ) r = cur.fetchone() return helper.dict_to_camel_case(r) @@ -717,7 +817,7 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT user_id, + """SELECT user_id, EXTRACT(epoch FROM jwt_iat)::BIGINT AS jwt_iat, changed_at, service_account, @@ -728,26 +828,31 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool: AND tenant_id = %(tenant_id)s AND deleted_at IS NULL LIMIT 1;""", - {"userId": user_id, "tenant_id": tenant_id}) + {"userId": user_id, "tenant_id": tenant_id}, + ) ) r = cur.fetchone() - return r is not None \ - and (r["service_account"] and not r["has_basic_auth"] - or r.get("jwt_iat") is not None \ - and (abs(jwt_iat - r["jwt_iat"]) <= 1)) + return r is not None and ( + r["service_account"] + and not r["has_basic_auth"] + or r.get("jwt_iat") is not None + and (abs(jwt_iat - r["jwt_iat"]) <= 1) + ) def refresh_auth_exists(user_id, tenant_id, jwt_jti=None): with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify(f"""SELECT user_id + cur.mogrify( + """SELECT user_id FROM public.users WHERE user_id = %(userId)s AND tenant_id= %(tenant_id)s AND deleted_at IS NULL AND jwt_refresh_jti = %(jwt_jti)s LIMIT 1;""", - {"userId": user_id, "tenant_id": tenant_id, "jwt_jti": jwt_jti}) + {"userId": user_id, "tenant_id": tenant_id, "jwt_jti": jwt_jti}, + ) ) r = cur.fetchone() return r is not None @@ -785,7 +890,8 @@ class RefreshSpotJWTs(FullLoginJWTs): def change_jwt_iat_jti(user_id): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""UPDATE public.users + query = cur.mogrify( + """UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), jwt_refresh_jti = 0, jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s'), @@ -799,7 +905,8 @@ def change_jwt_iat_jti(user_id): EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat, spot_jwt_refresh_jti, EXTRACT (epoch FROM spot_jwt_refresh_iat)::BIGINT AS spot_jwt_refresh_iat;""", - {"user_id": user_id}) + {"user_id": user_id}, + ) cur.execute(query) row = cur.fetchone() return FullLoginJWTs(**row) @@ -807,14 +914,16 @@ def change_jwt_iat_jti(user_id): def refresh_jwt_iat_jti(user_id): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""UPDATE public.users + query = cur.mogrify( + """UPDATE public.users SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'), jwt_refresh_jti = jwt_refresh_jti + 1 WHERE user_id = %(user_id)s RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat, jwt_refresh_jti, EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat;""", - {"user_id": user_id}) + {"user_id": user_id}, + ) cur.execute(query) row = cur.fetchone() return RefreshLoginJWTs(**row) @@ -825,7 +934,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No return {"errors": ["must sign-in with SSO, enforced by admin"]} with pg_client.PostgresClient() as cur: query = cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.tenant_id, users.role, @@ -845,19 +954,21 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No AND basic_authentication.user_id = (SELECT su.user_id FROM public.users AS su WHERE su.email=%(email)s AND su.deleted_at IS NULL LIMIT 1) AND (roles.role_id IS NULL OR roles.deleted_at IS NULL) LIMIT 1;""", - {"email": email, "password": password}) + {"email": email, "password": password}, + ) cur.execute(query) r = cur.fetchone() if r is None and helper.is_saml2_available(): query = cur.mogrify( - f"""SELECT 1 + """SELECT 1 FROM public.users WHERE users.email = %(email)s AND users.deleted_at IS NULL AND users.origin IS NOT NULL LIMIT 1;""", - {"email": email}) + {"email": email}, + ) cur.execute(query) if cur.fetchone() is not None: return {"errors": ["must sign-in with SSO"]} @@ -867,33 +978,51 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No return True r = helper.dict_to_camel_case(r) if r["serviceAccount"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, - detail="service account is not authorized to login") - elif config("enforce_SSO", cast=bool, default=False) and helper.is_saml2_available(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="service account is not authorized to login", + ) + elif ( + config("enforce_SSO", cast=bool, default=False) + and helper.is_saml2_available() + ): return {"errors": ["must sign-in with SSO, enforced by admin"]} - j_r = change_jwt_iat_jti(user_id=r['userId']) + j_r = change_jwt_iat_jti(user_id=r["userId"]) response = { - "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=j_r.jwt_iat, - aud=AUDIENCE), - "refreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], - tenant_id=r['tenantId'], - iat=j_r.jwt_refresh_iat, - aud=AUDIENCE, - jwt_jti=j_r.jwt_refresh_jti, - for_spot=False), + "jwt": authorizers.generate_jwt( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.jwt_iat, + aud=AUDIENCE, + ), + "refreshToken": authorizers.generate_jwt_refresh( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.jwt_refresh_iat, + aud=AUDIENCE, + jwt_jti=j_r.jwt_refresh_jti, + for_spot=False, + ), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), "email": email, - "spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], - iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE, for_spot=True), - "spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], - tenant_id=r['tenantId'], - iat=j_r.spot_jwt_refresh_iat, - aud=spot.AUDIENCE, - jwt_jti=j_r.spot_jwt_refresh_jti, - for_spot=True), + "spotJwt": authorizers.generate_jwt( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.spot_jwt_iat, + aud=spot.AUDIENCE, + for_spot=True, + ), + "spotRefreshToken": authorizers.generate_jwt_refresh( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.spot_jwt_refresh_iat, + aud=spot.AUDIENCE, + jwt_jti=j_r.spot_jwt_refresh_jti, + for_spot=True, + ), "spotRefreshTokenMaxAge": config("JWT_SPOT_REFRESH_EXPIRATION", cast=int), - **r + **r, } return response @@ -904,7 +1033,7 @@ def get_user_role(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.email, users.role, @@ -918,14 +1047,16 @@ def get_user_role(tenant_id, user_id): AND users.user_id=%(user_id)s AND users.tenant_id=%(tenant_id)s LIMIT 1""", - {"tenant_id": tenant_id, "user_id": user_id}) + {"tenant_id": tenant_id, "user_id": user_id}, + ) ) return helper.dict_to_camel_case(cur.fetchone()) def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id=None): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ + query = cur.mogrify( + """\ WITH u AS ( INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id, role_id) VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s, @@ -947,14 +1078,21 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, origin FROM u;""", - {"tenant_id": tenant_id, "email": email, "internal_id": internal_id, - "role": "admin" if admin else "member", "name": name, "origin": origin, - "role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now()})}) - cur.execute( - query + { + "tenant_id": tenant_id, + "email": email, + "internal_id": internal_id, + "role": "admin" if admin else "member", + "name": name, + "origin": origin, + "role_id": role_id, + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), + }, ) + cur.execute(query) return helper.dict_to_camel_case(cur.fetchone()) + def create_scim_user( email, name, @@ -984,7 +1122,7 @@ def create_scim_user( "tenant_id": tenant_id, "email": email, "name": name, - } + }, ) ) return helper.dict_to_camel_case(cur.fetchone()) @@ -1001,7 +1139,7 @@ def soft_delete_scim_user_by_id(user_id, tenant_id): users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s """, - {"user_id": user_id, "tenant_id": tenant_id} + {"user_id": user_id, "tenant_id": tenant_id}, ) ) @@ -1009,13 +1147,13 @@ def soft_delete_scim_user_by_id(user_id, tenant_id): def __hard_delete_user(user_id): with pg_client.PostgresClient() as cur: query = cur.mogrify( - f"""DELETE FROM public.users + """DELETE FROM public.users WHERE users.user_id = %(user_id)s AND users.deleted_at IS NOT NULL ;""", - {"user_id": user_id}) + {"user_id": user_id}, + ) cur.execute(query) - def logout(user_id: int): with pg_client.PostgresClient() as cur: query = cur.mogrify( @@ -1023,25 +1161,33 @@ def logout(user_id: int): SET jwt_iat = NULL, jwt_refresh_jti = NULL, jwt_refresh_iat = NULL, spot_jwt_iat = NULL, spot_jwt_refresh_jti = NULL, spot_jwt_refresh_iat = NULL WHERE user_id = %(user_id)s;""", - {"user_id": user_id}) + {"user_id": user_id}, + ) cur.execute(query) def refresh(user_id: int, tenant_id: int = -1) -> dict: j = refresh_jwt_iat_jti(user_id=user_id) return { - "jwt": authorizers.generate_jwt(user_id=user_id, tenant_id=tenant_id, iat=j.jwt_iat, - aud=AUDIENCE), - "refreshToken": authorizers.generate_jwt_refresh(user_id=user_id, tenant_id=tenant_id, iat=j.jwt_refresh_iat, - aud=AUDIENCE, jwt_jti=j.jwt_refresh_jti), - "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int) - (j.jwt_iat - j.jwt_refresh_iat), + "jwt": authorizers.generate_jwt( + user_id=user_id, tenant_id=tenant_id, iat=j.jwt_iat, aud=AUDIENCE + ), + "refreshToken": authorizers.generate_jwt_refresh( + user_id=user_id, + tenant_id=tenant_id, + iat=j.jwt_refresh_iat, + aud=AUDIENCE, + jwt_jti=j.jwt_refresh_jti, + ), + "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int) + - (j.jwt_iat - j.jwt_refresh_iat), } def authenticate_sso(email: str, internal_id: str): with pg_client.PostgresClient() as cur: query = cur.mogrify( - f"""SELECT + """SELECT users.user_id, users.tenant_id, users.role, @@ -1054,7 +1200,8 @@ def authenticate_sso(email: str, internal_id: str): service_account FROM public.users AS users WHERE users.email = %(email)s AND internal_id = %(internal_id)s;""", - {"email": email, "internal_id": internal_id}) + {"email": email, "internal_id": internal_id}, + ) cur.execute(query) r = cur.fetchone() @@ -1062,33 +1209,56 @@ def authenticate_sso(email: str, internal_id: str): if r is not None: r = helper.dict_to_camel_case(r) if r["serviceAccount"]: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, - detail="service account is not authorized to login") - j_r = change_jwt_iat_jti(user_id=r['userId']) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="service account is not authorized to login", + ) + j_r = change_jwt_iat_jti(user_id=r["userId"]) response = { - "jwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], iat=j_r.jwt_iat, - aud=AUDIENCE), - "refreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], tenant_id=r['tenantId'], - iat=j_r.jwt_refresh_iat, - aud=AUDIENCE, jwt_jti=j_r.jwt_refresh_jti), + "jwt": authorizers.generate_jwt( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.jwt_iat, + aud=AUDIENCE, + ), + "refreshToken": authorizers.generate_jwt_refresh( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.jwt_refresh_iat, + aud=AUDIENCE, + jwt_jti=j_r.jwt_refresh_jti, + ), "refreshTokenMaxAge": config("JWT_REFRESH_EXPIRATION", cast=int), - "spotJwt": authorizers.generate_jwt(user_id=r['userId'], tenant_id=r['tenantId'], - iat=j_r.spot_jwt_iat, aud=spot.AUDIENCE, for_spot=True), - "spotRefreshToken": authorizers.generate_jwt_refresh(user_id=r['userId'], - tenant_id=r['tenantId'], - iat=j_r.spot_jwt_refresh_iat, - aud=spot.AUDIENCE, - jwt_jti=j_r.spot_jwt_refresh_jti, for_spot=True), - "spotRefreshTokenMaxAge": config("JWT_SPOT_REFRESH_EXPIRATION", cast=int) + "spotJwt": authorizers.generate_jwt( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.spot_jwt_iat, + aud=spot.AUDIENCE, + for_spot=True, + ), + "spotRefreshToken": authorizers.generate_jwt_refresh( + user_id=r["userId"], + tenant_id=r["tenantId"], + iat=j_r.spot_jwt_refresh_iat, + aud=spot.AUDIENCE, + jwt_jti=j_r.spot_jwt_refresh_jti, + for_spot=True, + ), + "spotRefreshTokenMaxAge": config("JWT_SPOT_REFRESH_EXPIRATION", cast=int), } return response - logger.warning(f"SSO user not found with email: {email} and internal_id: {internal_id}") + logger.warning( + f"SSO user not found with email: {email} and internal_id: {internal_id}" + ) return None -def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, internal_id=None): +def restore_sso_user( + user_id, tenant_id, email, admin, name, origin, role_id, internal_id=None +): with pg_client.PostgresClient() as cur: - query = cur.mogrify(f"""\ + query = cur.mogrify( + """\ WITH u AS ( UPDATE public.users SET tenant_id= %(tenant_id)s, @@ -1128,13 +1298,19 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in (CASE WHEN u.role = 'member' THEN TRUE ELSE FALSE END) AS member, origin FROM u;""", - {"tenant_id": tenant_id, "email": email, "internal_id": internal_id, - "role": "admin" if admin else "member", "name": name, "origin": origin, - "role_id": role_id, "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), - "user_id": user_id}) - cur.execute( - query + { + "tenant_id": tenant_id, + "email": email, + "internal_id": internal_id, + "role": "admin" if admin else "member", + "name": name, + "origin": origin, + "role_id": role_id, + "data": json.dumps({"lastAnnouncementView": TimeUTC.now()}), + "user_id": user_id, + }, ) + cur.execute(query) return helper.dict_to_camel_case(cur.fetchone()) @@ -1161,23 +1337,25 @@ def restore_scim_user( SELECT * FROM u; """, - {"tenant_id": tenant_id, "user_id": user_id} + {"tenant_id": tenant_id, "user_id": user_id}, ) ) return helper.dict_to_camel_case(cur.fetchone()) + def get_user_settings(user_id): # read user settings from users.settings:jsonb column with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""SELECT + """SELECT settings FROM public.users WHERE users.deleted_at IS NULL AND users.user_id=%(user_id)s LIMIT 1""", - {"user_id": user_id}) + {"user_id": user_id}, + ) ) return helper.dict_to_camel_case(cur.fetchone()) @@ -1209,11 +1387,12 @@ def update_user_settings(user_id, settings): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( - f"""UPDATE public.users + """UPDATE public.users SET settings = %(settings)s WHERE users.user_id = %(user_id)s AND deleted_at IS NULL RETURNING settings;""", - {"user_id": user_id, "settings": json.dumps(settings)}) + {"user_id": user_id, "settings": json.dumps(settings)}, + ) ) return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/chalicelib/utils/scim_auth.py b/ee/api/chalicelib/utils/scim_auth.py index fb73b9dbb..a8deaa136 100644 --- a/ee/api/chalicelib/utils/scim_auth.py +++ b/ee/api/chalicelib/utils/scim_auth.py @@ -13,8 +13,8 @@ REFRESH_SECRET_KEY = config("SCIM_REFRESH_SECRET_KEY") ALGORITHM = config("SCIM_JWT_ALGORITHM") ACCESS_TOKEN_EXPIRE_SECONDS = int(config("SCIM_ACCESS_TOKEN_EXPIRE_SECONDS")) REFRESH_TOKEN_EXPIRE_SECONDS = int(config("SCIM_REFRESH_TOKEN_EXPIRE_SECONDS")) -AUDIENCE="okta_client" -ISSUER=config("JWT_ISSUER"), +AUDIENCE = "okta_client" +ISSUER = (config("JWT_ISSUER"),) # Simulated Okta Client Credentials # OKTA_CLIENT_ID = "okta-client" @@ -23,7 +23,7 @@ ISSUER=config("JWT_ISSUER"), # class TokenRequest(BaseModel): # client_id: str # client_secret: str - + # async def authenticate_client(token_request: TokenRequest): # """Validate Okta Client Credentials and issue JWT""" # if token_request.client_id != OKTA_CLIENT_ID or token_request.client_secret != OKTA_CLIENT_SECRET: @@ -31,6 +31,7 @@ ISSUER=config("JWT_ISSUER"), # return {"access_token": create_jwt(), "token_type": "bearer"} + def create_tokens(tenant_id): curr_time = time.time() access_payload = { @@ -38,7 +39,7 @@ def create_tokens(tenant_id): "sub": "scim_server", "aud": AUDIENCE, "iss": ISSUER, - "exp": "" + "exp": "", } access_payload.update({"exp": curr_time + ACCESS_TOKEN_EXPIRE_SECONDS}) access_token = jwt.encode(access_payload, ACCESS_SECRET_KEY, algorithm=ALGORITHM) @@ -49,18 +50,24 @@ def create_tokens(tenant_id): return access_token, refresh_token + def verify_access_token(token: str): try: - payload = jwt.decode(token, ACCESS_SECRET_KEY, algorithms=[ALGORITHM], audience=AUDIENCE) + payload = jwt.decode( + token, ACCESS_SECRET_KEY, algorithms=[ALGORITHM], audience=AUDIENCE + ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") + def verify_refresh_token(token: str): try: - payload = jwt.decode(token, REFRESH_SECRET_KEY, algorithms=[ALGORITHM], audience=AUDIENCE) + payload = jwt.decode( + token, REFRESH_SECRET_KEY, algorithms=[ALGORITHM], audience=AUDIENCE + ) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") @@ -69,6 +76,8 @@ def verify_refresh_token(token: str): required_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + # Authentication Dependency def auth_required(token: str = Depends(required_oauth2_scheme)): """Dependency to check Authorization header.""" @@ -78,6 +87,8 @@ def auth_required(token: str = Depends(required_oauth2_scheme)): optional_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) + + def auth_optional(token: str | None = Depends(optional_oauth2_scheme)): if token is None: return None diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index ec287b511..40230c14f 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -1,7 +1,5 @@ import logging -import re -import uuid -from typing import Any, Literal, Optional +from typing import Any, Literal import copy from datetime import datetime @@ -9,11 +7,15 @@ from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel, Field, field_serializer +from pydantic import BaseModel, field_serializer -import schemas -from chalicelib.core import users, roles, tenants -from chalicelib.utils.scim_auth import auth_optional, auth_required, create_tokens, verify_refresh_token +from chalicelib.core import users, tenants +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 RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG from routers import scim_helpers @@ -26,29 +28,41 @@ public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") """Authentication endpoints""" + class RefreshRequest(BaseModel): refresh_token: str + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + # Login endpoint to generate tokens @public_app.post("/token") -async def login(host: str = Header(..., alias="Host"), form_data: OAuth2PasswordRequestForm = Depends()): +async def login( + host: str = Header(..., alias="Host"), + form_data: OAuth2PasswordRequestForm = Depends(), +): subdomain = host.split(".")[0] # Missing authentication part, to add - if form_data.username != config("SCIM_USER") or form_data.password != config("SCIM_PASSWORD"): + if form_data.username != config("SCIM_USER") or form_data.password != config( + "SCIM_PASSWORD" + ): raise HTTPException(status_code=401, detail="Invalid credentials") tenant = tenants.get_by_name(subdomain) access_token, refresh_token = create_tokens(tenant_id=tenant["tenantId"]) - return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"} + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + # Refresh token endpoint @public_app.post("/refresh") async def refresh_token(r: RefreshRequest): - payload = verify_refresh_token(r.refresh_token) new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) @@ -68,7 +82,7 @@ def _not_found_error_response(resource_id: str): "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": f"Resource {resource_id} not found", "status": "404", - } + }, ) @@ -80,7 +94,7 @@ def _uniqueness_error_response(): "detail": "One or more of the attribute values are already in use or are reserved.", "status": "409", "scimType": "uniqueness", - } + }, ) @@ -92,7 +106,7 @@ def _mutability_error_response(): "detail": "The attempted modification is not compatible with the target attribute's mutability or current state.", "status": "400", "scimType": "mutability", - } + }, ) @@ -105,7 +119,7 @@ async def get_resource_types(filter_param: str | None = Query(None, alias="filte "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": "Operation is not permitted based on the supplied authorization", "status": "403", - } + }, ) return JSONResponse( status_code=200, @@ -130,8 +144,7 @@ async def get_resource_type(resource_id: str): SCHEMA_IDS_TO_SCHEMA_DETAILS = { - schema_detail["id"]: schema_detail - for schema_detail in SCHEMAS + schema_detail["id"]: schema_detail for schema_detail in SCHEMAS } @@ -144,7 +157,7 @@ async def get_schemas(filter_param: str | None = Query(None, alias="filter")): "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": "Operation is not permitted based on the supplied authorization", "status": "403", - } + }, ) return JSONResponse( status_code=200, @@ -154,9 +167,8 @@ async def get_schemas(filter_param: str | None = Query(None, alias="filter")): "startIndex": 1, "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], "Resources": [ - value - for _, value in sorted(SCHEMA_IDS_TO_SCHEMA_DETAILS.items()) - ] + value for _, value in sorted(SCHEMA_IDS_TO_SCHEMA_DETAILS.items()) + ], }, ) @@ -174,7 +186,9 @@ async def get_schema(schema_id: str): # 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)): +async def get_service_provider_config( + r: Request, tenant_id: str | None = Depends(auth_optional) +): content = copy.deepcopy(SERVICE_PROVIDER_CONFIG) content["meta"]["location"] = str(r.url) is_authenticated = tenant_id is not None @@ -193,6 +207,8 @@ async def get_service_provider_config(r: Request, tenant_id: str | None = Depend """ User endpoints """ + + class UserRequest(BaseModel): userName: str @@ -203,7 +219,9 @@ class PatchUserRequest(BaseModel): class ResourceMetaResponse(BaseModel): - resourceType: Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None = None + resourceType: ( + Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None + ) = None created: datetime | None = None lastModified: datetime | None = None location: str | None = None @@ -231,12 +249,16 @@ class CommonResourceResponse(BaseModel): class UserResponse(CommonResourceResponse): - schemas: list[Literal["urn:ietf:params:scim:schemas:core:2.0:User"]] = ["urn:ietf:params:scim:schemas:core:2.0:User"] + schemas: list[Literal["urn:ietf:params:scim:schemas:core:2.0:User"]] = [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ] userName: str | None = None class QueryResourceResponse(BaseModel): - schemas: list[Literal["urn:ietf:params:scim:api:messages:2.0:ListResponse"]] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"] + schemas: list[Literal["urn:ietf:params:scim:api:messages:2.0:ListResponse"]] = [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ] totalResults: int # todo(jon): add the other schemas Resources: list[UserResponse] @@ -247,21 +269,33 @@ class QueryResourceResponse(BaseModel): MAX_USERS_PER_PAGE = 10 -def _convert_db_user_to_scim_user(db_user: dict[str, Any], attributes: list[str] | None = None, excluded_attributes: list[str] | None = None) -> UserResponse: - user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] +def _convert_db_user_to_scim_user( + db_user: dict[str, Any], + attributes: list[str] | None = None, + excluded_attributes: list[str] | None = None, +) -> UserResponse: + user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[ + "urn:ietf:params:scim:schemas:core:2.0:User" + ] all_attributes = scim_helpers.get_all_attribute_names(user_schema) attributes = attributes or all_attributes - always_returned_attributes = scim_helpers.get_all_attribute_names_where_returned_is_always(user_schema) + always_returned_attributes = ( + scim_helpers.get_all_attribute_names_where_returned_is_always(user_schema) + ) included_attributes = list(set(attributes).union(set(always_returned_attributes))) excluded_attributes = excluded_attributes or [] - excluded_attributes = list(set(excluded_attributes).difference(set(always_returned_attributes))) + excluded_attributes = list( + set(excluded_attributes).difference(set(always_returned_attributes)) + ) scim_user = { "id": str(db_user["userId"]), "meta": { "resourceType": "User", "created": db_user["createdAt"], - "lastModified": db_user["createdAt"], # todo(jon): we currently don't keep track of this in the db - "location": f"Users/{db_user['userId']}" + "lastModified": db_user[ + "createdAt" + ], # todo(jon): we currently don't keep track of this in the db + "location": f"Users/{db_user['userId']}", }, "userName": db_user["email"], } @@ -272,14 +306,16 @@ def _convert_db_user_to_scim_user(db_user: dict[str, Any], attributes: list[str] @public_app.get("/Users") async def get_users( - tenant_id = Depends(auth_required), + tenant_id=Depends(auth_required), requested_start_index: int = Query(1, alias="startIndex"), requested_items_per_page: int | None = Query(None, alias="count"), attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): start_index = max(1, requested_start_index) - items_per_page = min(max(0, requested_items_per_page or MAX_USERS_PER_PAGE), MAX_USERS_PER_PAGE) + items_per_page = min( + max(0, requested_items_per_page or MAX_USERS_PER_PAGE), MAX_USERS_PER_PAGE + ) # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. # but this is the fastest thing at the moment just to test that it's working total_users = users.get_users_paginated(1, tenant_id) @@ -302,7 +338,7 @@ async def get_users( @public_app.get("/Users/{user_id}") def get_user( user_id: str, - tenant_id = Depends(auth_required), + tenant_id=Depends(auth_required), attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): @@ -311,13 +347,12 @@ def get_user( return _not_found_error_response(user_id) scim_user = _convert_db_user_to_scim_user(db_user, attributes, excluded_attributes) return JSONResponse( - status_code=200, - content=scim_user.model_dump(mode="json", exclude_none=True) + status_code=200, content=scim_user.model_dump(mode="json", exclude_none=True) ) @public_app.post("/Users") -async def create_user(r: UserRequest, tenant_id = Depends(auth_required)): +async def create_user(r: UserRequest, tenant_id=Depends(auth_required)): # note(jon): this method will return soft deleted users as well existing_db_user = users.get_existing_scim_user_by_unique_values(r.userName) if existing_db_user and existing_db_user["deletedAt"] is None: @@ -334,23 +369,26 @@ async def create_user(r: UserRequest, tenant_id = Depends(auth_required)): ) scim_user = _convert_db_user_to_scim_user(db_user) response = JSONResponse( - status_code=201, - content=scim_user.model_dump(mode="json", exclude_none=True) + status_code=201, content=scim_user.model_dump(mode="json", exclude_none=True) ) response.headers["Location"] = scim_user.meta.location return response @public_app.put("/Users/{user_id}") -def update_user(user_id: str, r: UserRequest, tenant_id = Depends(auth_required)): +def update_user(user_id: str, r: UserRequest, tenant_id=Depends(auth_required)): db_resource = users.get_scim_user_by_id(user_id, tenant_id) if not db_resource: return _not_found_error_response(user_id) - current_scim_resource = _convert_db_user_to_scim_user(db_resource).model_dump(mode="json", exclude_none=True) + current_scim_resource = _convert_db_user_to_scim_user(db_resource).model_dump( + mode="json", exclude_none=True + ) changes = r.model_dump(mode="json") schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] try: - valid_mutable_changes = scim_helpers.filter_mutable_attributes(schema, changes, current_scim_resource) + valid_mutable_changes = scim_helpers.filter_mutable_attributes( + schema, changes, current_scim_resource + ) except ValueError: # todo(jon): will need to add a test for this once we have an immutable field return _mutability_error_response() @@ -371,7 +409,7 @@ def update_user(user_id: str, r: UserRequest, tenant_id = Depends(auth_required) @public_app.delete("/Users/{user_id}") -def delete_user(user_id: str, tenant_id = Depends(auth_required)): +def delete_user(user_id: str, tenant_id=Depends(auth_required)): user = users.get_scim_user_by_id(user_id, tenant_id) if not user: return _not_found_error_response(user_id) diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index 5a6256ee3..74e00ee01 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -1,22 +1,22 @@ # note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants -from typing import Any, Literal +from typing import Any def _attribute_characteristics( - name: str, - description: str, - type: str="string", - sub_attributes: dict[str, Any] | None=None, - # note(jon): no default for multiValued is defined in the docs and it is marked as optional. - # from our side, we'll default it to False. - multi_valued: bool=False, - required: bool=False, - canonical_values: list[str] | None=None, - case_exact: bool=False, - mutability: str="readWrite", - returned: str="default", - uniqueness: str="none", - reference_types: list[str] | None=None, + name: str, + description: str, + type: str = "string", + sub_attributes: dict[str, Any] | None = None, + # note(jon): no default for multiValued is defined in the docs and it is marked as optional. + # from our side, we'll default it to False. + multi_valued: bool = False, + required: bool = False, + canonical_values: list[str] | None = None, + case_exact: bool = False, + mutability: str = "readWrite", + returned: str = "default", + uniqueness: str = "none", + reference_types: list[str] | None = None, ): characteristics = { "name": name, @@ -33,14 +33,16 @@ def _attribute_characteristics( "referenceTypes": reference_types, } characteristics_without_none = { - key: value - for key, value in characteristics.items() - if value is not None + key: value for key, value in characteristics.items() if value is not None } return characteristics_without_none -def _multi_valued_attributes(type_canonical_values: list[str], type_required: bool=False, type_mutability="readWrite"): +def _multi_valued_attributes( + type_canonical_values: list[str], + type_required: bool = False, + type_mutability="readWrite", +): return [ _attribute_characteristics( name="type", @@ -68,7 +70,7 @@ def _multi_valued_attributes(type_canonical_values: list[str], type_required: bo name="$ref", type="reference", reference_types=["uri"], - description="The reference URI of a target resource." + description="The reference URI of a target resource.", ), ] @@ -77,7 +79,7 @@ def _multi_valued_attributes(type_canonical_values: list[str], type_required: bo # in section 3.1 of RFC7643, it is specified that ResourceType and # ServiceProviderConfig are not included in the common attributes. but # in other references, they treat them as a resource. -def _common_resource_attributes(id_required: bool=True, id_uniqueness: str="none"): +def _common_resource_attributes(id_required: bool = True, id_uniqueness: str = "none"): return [ _attribute_characteristics( name="id", @@ -151,7 +153,6 @@ def _common_resource_attributes(id_required: bool=True, id_uniqueness: str="none ] - SERVICE_PROVIDER_CONFIG_SCHEMA = { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", @@ -339,7 +340,7 @@ SERVICE_PROVIDER_CONFIG_SCHEMA = { ), ], ), - ] + ], } @@ -409,9 +410,9 @@ RESOURCE_TYPE_SCHEMA = { required=True, mutability="readOnly", ), - ] + ], ), - ] + ], } SCHEMA_SCHEMA = { @@ -548,7 +549,7 @@ SCHEMA_SCHEMA = { canonical_values=[ # todo(jon): add "User" and "Group" once those are done. "external", - "uri" + "uri", ], case_exact=True, ), @@ -659,15 +660,15 @@ SCHEMA_SCHEMA = { canonical_values=[ # todo(jon): add "User" and "Group" once those are done. "external", - "uri" + "uri", ], case_exact=True, ), ], ), - ] - ) - ] + ], + ), + ], } @@ -749,7 +750,7 @@ SERVICE_PROVIDER_CONFIG = { # and then updating these timestamps from an api and such. for now, if we update # the configuration, we should update the timestamp here. "lastModified": "2025-04-15T15:45:00Z", - "location": "", # note(jon): this field will be computed in the /ServiceProviderConfig endpoint + "location": "", # note(jon): this field will be computed in the /ServiceProviderConfig endpoint }, } diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py index 7d1cf4b95..6c04ecab8 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim_helpers.py @@ -4,6 +4,7 @@ from copy import deepcopy def get_all_attribute_names(schema: dict[str, Any]) -> list[str]: result = [] + def _walk(attrs, prefix=None): for attr in attrs: name = attr["name"] @@ -12,12 +13,16 @@ def get_all_attribute_names(schema: dict[str, Any]) -> list[str]: if attr["type"] == "complex": sub = attr.get("subAttributes") or attr.get("attributes") or [] _walk(sub, path) + _walk(schema["attributes"]) return result -def get_all_attribute_names_where_returned_is_always(schema: dict[str, Any]) -> list[str]: +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"] @@ -27,11 +32,14 @@ def get_all_attribute_names_where_returned_is_always(schema: dict[str, Any]) -> if attr["type"] == "complex": sub = attr.get("subAttributes") or attr.get("attributes") or [] _walk(sub, path) + _walk(schema["attributes"]) return result -def filter_attributes(resource: dict[str, Any], include_list: list[str]) -> dict[str, Any]: +def filter_attributes( + resource: dict[str, Any], include_list: list[str] +) -> dict[str, Any]: result = {} for attr in include_list: parts = attr.split(".", 1) @@ -63,7 +71,9 @@ def filter_attributes(resource: dict[str, Any], include_list: list[str]) -> dict return result -def exclude_attributes(resource: dict[str, Any], exclude_list: list[str]) -> dict[str, Any]: +def exclude_attributes( + resource: dict[str, Any], exclude_list: list[str] +) -> dict[str, Any]: exclude_map = {} for attr in exclude_list: parts = attr.split(".", 1) @@ -105,7 +115,11 @@ def exclude_attributes(resource: dict[str, Any], exclude_list: list[str]) -> dic return new_resource -def filter_mutable_attributes(schema: dict[str, Any], requested_changes: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]: +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 = {} From 62736ff29bb17386c6942c998afdaa453d0bff1f Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 18 Apr 2025 15:59:41 +0200 Subject: [PATCH 14/34] clean up files --- ee/api/routers/fixtures/group_schema.json | 70 ++ ee/api/routers/fixtures/resource_type.json | 19 + .../fixtures/resource_type_schema.json | 102 +++ ee/api/routers/fixtures/schema_schema.json | 304 +++++++ .../fixtures/service_provider_config.json | 41 + .../service_provider_config_schema.json | 212 +++++ ee/api/routers/fixtures/user_schema.json | 386 +++++++++ ee/api/routers/scim.py | 253 +++--- ee/api/routers/scim_constants.py | 777 +----------------- 9 files changed, 1239 insertions(+), 925 deletions(-) create mode 100644 ee/api/routers/fixtures/group_schema.json create mode 100644 ee/api/routers/fixtures/resource_type.json create mode 100644 ee/api/routers/fixtures/resource_type_schema.json create mode 100644 ee/api/routers/fixtures/schema_schema.json create mode 100644 ee/api/routers/fixtures/service_provider_config.json create mode 100644 ee/api/routers/fixtures/service_provider_config_schema.json create mode 100644 ee/api/routers/fixtures/user_schema.json diff --git a/ee/api/routers/fixtures/group_schema.json b/ee/api/routers/fixtures/group_schema.json new file mode 100644 index 000000000..f1ef0f71f --- /dev/null +++ b/ee/api/routers/fixtures/group_schema.json @@ -0,0 +1,70 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "name": "Group", + "description": "Group", + "attributes": [ + { + "name": "displayName", + "type": "string", + "multiValued": false, + "description": "Human readable name for the Group. REQUIRED.", + "required": false, + "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": false, + "caseExact": false, + "mutability": "immutable", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "$ref", + "type": "reference", + "referenceTypes": ["User", "Group"], + "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' or 'Group'.", + "required": false, + "caseExact": false, + "canonicalValues": ["User", "Group"], + "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:00", + "lastModified": "2025-04-17T15:48:00" + } +} diff --git a/ee/api/routers/fixtures/resource_type.json b/ee/api/routers/fixtures/resource_type.json new file mode 100644 index 000000000..284266c4e --- /dev/null +++ b/ee/api/routers/fixtures/resource_type.json @@ -0,0 +1,19 @@ +[ + { + "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": [] + } +] diff --git a/ee/api/routers/fixtures/resource_type_schema.json b/ee/api/routers/fixtures/resource_type_schema.json new file mode 100644 index 000000000..040fd071c --- /dev/null +++ b/ee/api/routers/fixtures/resource_type_schema.json @@ -0,0 +1,102 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", + "name": "ResourceType", + "description": "Specifies the schema that describes a SCIM Resource Type", + "attributes": [ + { + "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": "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:00", + "lastModified": "2025-04-17T15:48:00" + } +} diff --git a/ee/api/routers/fixtures/schema_schema.json b/ee/api/routers/fixtures/schema_schema.json new file mode 100644 index 000000000..4099700f3 --- /dev/null +++ b/ee/api/routers/fixtures/schema_schema.json @@ -0,0 +1,304 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", + "name": "Schema", + "description": "Specifies the schema that describes a SCIM Schema", + "attributes": [ + { + "name": "id", + "type": "string", + "multiValued": false, + "description": "The unique URI of the schema.", + "required": true, + "caseExact": false, + "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", + "subAttributes": [ + { + "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:00", + "lastModified": "2025-04-17T15:48:00" + } +} diff --git a/ee/api/routers/fixtures/service_provider_config.json b/ee/api/routers/fixtures/service_provider_config.json new file mode 100644 index 000000000..ebb37bec4 --- /dev/null +++ b/ee/api/routers/fixtures/service_provider_config.json @@ -0,0 +1,41 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" + ], + "patch": { + "supported": false + }, + "bulk": { + "supported": false, + "maxOperations": 0, + "maxPayloadSize": 0 + }, + "filter": { + "supported": false, + "maxResults": 0 + }, + "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/fixtures/service_provider_config_schema.json b/ee/api/routers/fixtures/service_provider_config_schema.json new file mode 100644 index 000000000..c17ca5e18 --- /dev/null +++ b/ee/api/routers/fixtures/service_provider_config_schema.json @@ -0,0 +1,212 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", + "name": "Service Provider Configuration", + "description": "Schema for representing the service provider's configuration", + "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:00", + "lastModified": "2025-04-17T15:48:00" + } +} diff --git a/ee/api/routers/fixtures/user_schema.json b/ee/api/routers/fixtures/user_schema.json new file mode 100644 index 000000000..736694c91 --- /dev/null +++ b/ee/api/routers/fixtures/user_schema.json @@ -0,0 +1,386 @@ +{ + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "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:00", + "lastModified": "2025-04-17T15:48:00" + } +} diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 40230c14f..cf21cb878 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -1,13 +1,11 @@ import logging -from typing import Any, Literal -import copy -from datetime import datetime +from typing import Any from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi.responses import JSONResponse -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm -from pydantic import BaseModel, field_serializer +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel from chalicelib.core import users, tenants from chalicelib.utils.scim_auth import ( @@ -26,19 +24,8 @@ logger = logging.getLogger(__name__) public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") -"""Authentication endpoints""" - - -class RefreshRequest(BaseModel): - refresh_token: str - - -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - - -# Login endpoint to generate tokens @public_app.post("/token") -async def login( +async def post_token( host: str = Header(..., alias="Host"), form_data: OAuth2PasswordRequestForm = Depends(), ): @@ -56,17 +43,19 @@ async def login( return { "access_token": access_token, "refresh_token": refresh_token, - "token_type": "bearer", + "token_type": "Bearer", } -# Refresh token endpoint +class RefreshRequest(BaseModel): + refresh_token: str + + @public_app.post("/refresh") -async def refresh_token(r: RefreshRequest): +async def post_refresh(r: RefreshRequest): payload = verify_refresh_token(r.refresh_token) new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) - - return {"access_token": new_access_token, "token_type": "bearer"} + return {"access_token": new_access_token, "token_type": "Bearer"} RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { @@ -110,17 +99,33 @@ def _mutability_error_response(): ) +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", + }, + ) + + @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 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", - }, - ) + return _operation_not_permitted_error_response() return JSONResponse( status_code=200, content={ @@ -151,14 +156,7 @@ SCHEMA_IDS_TO_SCHEMA_DETAILS = { @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 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", - }, - ) + return _operation_not_permitted_error_response() return JSONResponse( status_code=200, content={ @@ -189,81 +187,19 @@ async def get_schema(schema_id: str): async def get_service_provider_config( r: Request, tenant_id: str | None = Depends(auth_optional) ): - content = copy.deepcopy(SERVICE_PROVIDER_CONFIG) - content["meta"]["location"] = str(r.url) is_authenticated = tenant_id is not None if not is_authenticated: - content = { - "schemas": content["schemas"], - "authenticationSchemes": content["authenticationSchemes"], - "meta": content["meta"], - } - return JSONResponse( - status_code=200, - content=content, - ) - - -""" -User endpoints -""" - - -class UserRequest(BaseModel): - userName: str - - -class PatchUserRequest(BaseModel): - schemas: list[str] - Operations: list[dict] - - -class ResourceMetaResponse(BaseModel): - resourceType: ( - Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None - ) = None - created: datetime | None = None - lastModified: datetime | None = None - location: str | None = None - version: str | None = None - - @field_serializer("created", "lastModified") - def serialize_datetime(self, dt: datetime) -> str | None: - if not dt: - return None - return dt.strftime("%Y-%m-%dT%H:%M:%SZ") - - -class CommonResourceResponse(BaseModel): - id: str - externalId: str | None = None - schemas: list[ - Literal[ - "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "urn:ietf:params:scim:schemas:core:2.0:Schema", - "urn:ietf:params:scim:schemas:core:2.0:User", - ] - ] - meta: ResourceMetaResponse | None = None - - -class UserResponse(CommonResourceResponse): - schemas: list[Literal["urn:ietf:params:scim:schemas:core:2.0:User"]] = [ - "urn:ietf:params:scim:schemas:core:2.0:User" - ] - userName: str | None = None - - -class QueryResourceResponse(BaseModel): - schemas: list[Literal["urn:ietf:params:scim:api:messages:2.0:ListResponse"]] = [ - "urn:ietf:params:scim:api:messages:2.0:ListResponse" - ] - totalResults: int - # todo(jon): add the other schemas - Resources: list[UserResponse] - startIndex: int - itemsPerPage: int + 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) MAX_USERS_PER_PAGE = 10 @@ -273,7 +209,7 @@ def _convert_db_user_to_scim_user( db_user: dict[str, Any], attributes: list[str] | None = None, excluded_attributes: list[str] | None = None, -) -> UserResponse: +) -> dict[str, Any]: user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[ "urn:ietf:params:scim:schemas:core:2.0:User" ] @@ -289,19 +225,19 @@ def _convert_db_user_to_scim_user( ) scim_user = { "id": str(db_user["userId"]), + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "meta": { "resourceType": "User", - "created": db_user["createdAt"], - "lastModified": db_user[ - "createdAt" - ], # todo(jon): we currently don't keep track of this in the db + "created": db_user["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), + # todo(jon): we currently don't keep track of this in the db + "lastModified": db_user["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), "location": f"Users/{db_user['userId']}", }, "userName": db_user["email"], } scim_user = scim_helpers.filter_attributes(scim_user, included_attributes) scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes) - return UserResponse(**scim_user) + return scim_user @public_app.get("/Users") @@ -318,79 +254,81 @@ async def get_users( ) # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. # but this is the fastest thing at the moment just to test that it's working - total_users = users.get_users_paginated(1, tenant_id) - db_users = users.get_users_paginated(start_index, tenant_id, count=items_per_page) - scim_users = [ - _convert_db_user_to_scim_user(user, attributes, excluded_attributes) - for user in db_users + total_resources = users.get_users_paginated(1, tenant_id) + db_resources = users.get_users_paginated( + start_index, tenant_id, count=items_per_page + ) + scim_resources = [ + _convert_db_user_to_scim_user(resource, attributes, excluded_attributes) + for resource in db_resources ] return JSONResponse( status_code=200, - content=QueryResourceResponse( - totalResults=len(total_users), - startIndex=start_index, - itemsPerPage=len(scim_users), - Resources=scim_users, - ).model_dump(mode="json", exclude_none=True), + content={ + "totalResults": len(total_resources), + "startIndex": start_index, + "itemsPerPage": len(scim_resources), + "Resources": scim_resources, + }, ) @public_app.get("/Users/{user_id}") -def get_user( +async def get_user( user_id: str, tenant_id=Depends(auth_required), attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): - db_user = users.get_scim_user_by_id(user_id, tenant_id) - if not db_user: + db_resource = users.get_scim_user_by_id(user_id, tenant_id) + if not db_resource: return _not_found_error_response(user_id) - scim_user = _convert_db_user_to_scim_user(db_user, attributes, excluded_attributes) - return JSONResponse( - status_code=200, content=scim_user.model_dump(mode="json", exclude_none=True) + scim_resource = _convert_db_user_to_scim_user( + db_resource, attributes, excluded_attributes ) + return JSONResponse(status_code=200, content=scim_resource) @public_app.post("/Users") -async def create_user(r: UserRequest, tenant_id=Depends(auth_required)): +async def create_user(r: Request, tenant_id=Depends(auth_required)): + payload = await r.json() + if "userName" not in payload: + return _invalid_value_error_response() # note(jon): this method will return soft deleted users as well - existing_db_user = users.get_existing_scim_user_by_unique_values(r.userName) - if existing_db_user and existing_db_user["deletedAt"] is None: + existing_db_resource = users.get_existing_scim_user_by_unique_values( + payload["userName"] + ) + if existing_db_resource and existing_db_resource["deletedAt"] is None: return _uniqueness_error_response() - if existing_db_user and existing_db_user["deletedAt"] is not None: - db_user = users.restore_scim_user(existing_db_user["userId"], tenant_id) + if existing_db_resource and existing_db_resource["deletedAt"] is not None: + db_resource = users.restore_scim_user(existing_db_resource["userId"], tenant_id) else: - db_user = users.create_scim_user( - email=r.userName, + db_resource = users.create_scim_user( + email=payload["userName"], # note(jon): scim schema does not require the `name.formatted` attribute, but we require `name`. # so, we have to define the value ourselves here name="", tenant_id=tenant_id, ) - scim_user = _convert_db_user_to_scim_user(db_user) - response = JSONResponse( - status_code=201, content=scim_user.model_dump(mode="json", exclude_none=True) - ) - response.headers["Location"] = scim_user.meta.location + scim_resource = _convert_db_user_to_scim_user(db_resource) + response = JSONResponse(status_code=201, content=scim_resource) + response.headers["Location"] = scim_resource["meta"]["location"] return response @public_app.put("/Users/{user_id}") -def update_user(user_id: str, r: UserRequest, tenant_id=Depends(auth_required)): +async def update_user(user_id: str, r: Request, tenant_id=Depends(auth_required)): db_resource = users.get_scim_user_by_id(user_id, tenant_id) if not db_resource: return _not_found_error_response(user_id) - current_scim_resource = _convert_db_user_to_scim_user(db_resource).model_dump( - mode="json", exclude_none=True - ) - changes = r.model_dump(mode="json") + current_scim_resource = _convert_db_user_to_scim_user(db_resource) + changes = await r.json() schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] try: valid_mutable_changes = scim_helpers.filter_mutable_attributes( schema, changes, current_scim_resource ) except ValueError: - # todo(jon): will need to add a test for this once we have an immutable field return _mutability_error_response() try: updated_db_resource = users.update_scim_user( @@ -399,19 +337,16 @@ def update_user(user_id: str, r: UserRequest, tenant_id=Depends(auth_required)): email=valid_mutable_changes["userName"], ) updated_scim_resource = _convert_db_user_to_scim_user(updated_db_resource) - return JSONResponse( - status_code=200, - content=updated_scim_resource.model_dump(mode="json", exclude_none=True), - ) + return JSONResponse(status_code=200, content=updated_scim_resource) except Exception: # note(jon): for now, this is the only error that would happen when updating the scim user return _uniqueness_error_response() @public_app.delete("/Users/{user_id}") -def delete_user(user_id: str, tenant_id=Depends(auth_required)): - user = users.get_scim_user_by_id(user_id, tenant_id) - if not user: +async def delete_user(user_id: str, tenant_id=Depends(auth_required)): + db_resource = users.get_scim_user_by_id(user_id, tenant_id) + if not db_resource: return _not_found_error_response(user_id) users.soft_delete_scim_user_by_id(user_id, tenant_id) return Response(status_code=204, content="") diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index 74e00ee01..090135ffc 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -1,778 +1,23 @@ # note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants -from typing import Any - - -def _attribute_characteristics( - name: str, - description: str, - type: str = "string", - sub_attributes: dict[str, Any] | None = None, - # note(jon): no default for multiValued is defined in the docs and it is marked as optional. - # from our side, we'll default it to False. - multi_valued: bool = False, - required: bool = False, - canonical_values: list[str] | None = None, - case_exact: bool = False, - mutability: str = "readWrite", - returned: str = "default", - uniqueness: str = "none", - reference_types: list[str] | None = None, -): - characteristics = { - "name": name, - "type": type, - "subAttributes": sub_attributes, - "multiValued": multi_valued, - "description": description, - "required": required, - "canonicalValues": canonical_values, - "caseExact": case_exact, - "mutability": mutability, - "returned": returned, - "uniqueness": uniqueness, - "referenceTypes": reference_types, - } - characteristics_without_none = { - key: value for key, value in characteristics.items() if value is not None - } - return characteristics_without_none - - -def _multi_valued_attributes( - type_canonical_values: list[str], - type_required: bool = False, - type_mutability="readWrite", -): - return [ - _attribute_characteristics( - name="type", - description="A label indicating the attribute's function.", - canonical_values=type_canonical_values, - case_exact=True, - required=type_required, - mutability=type_mutability, - ), - _attribute_characteristics( - name="primary", - type="boolean", - description="A Boolean value indicating the 'primary' or preferred attribute value for this attribute.", - ), - _attribute_characteristics( - name="display", - description="A human-readable name.", - mutability="immutable", - ), - _attribute_characteristics( - name="value", - description="The attribute's significant value.", - ), - _attribute_characteristics( - name="$ref", - type="reference", - reference_types=["uri"], - description="The reference URI of a target resource.", - ), - ] - - -# note(jon): the docs are a little confusing regarding this, but -# in section 3.1 of RFC7643, it is specified that ResourceType and -# ServiceProviderConfig are not included in the common attributes. but -# in other references, they treat them as a resource. -def _common_resource_attributes(id_required: bool = True, id_uniqueness: str = "none"): - return [ - _attribute_characteristics( - name="id", - description="A unique identifier for the SCIM resource.", - case_exact=True, - mutability="readOnly", - returned="always", - required=id_required, - uniqueness=id_uniqueness, - ), - _attribute_characteristics( - name="externalId", - description="A String that is an identifier for the resource as defined by the provisioning client.", - case_exact=True, - ), - _attribute_characteristics( - name="schemas", - type="reference", - reference_types=["uri"], - 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.", - multi_valued=True, - canonical_values=[ - "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "urn:ietf:params:scim:schemas:core:2.0:Schema", - "urn:ietf:params:scim:schemas:core:2.0:User", - ], - case_exact=True, - mutability="readOnly", - required=True, - returned="always", - ), - _attribute_characteristics( - name="meta", - type="complex", - description="A complex attribute containing resource metadata.", - required=True, - sub_attributes=[ - _attribute_characteristics( - name="resourceType", - description="The name of the resource type of the resource.", - mutability="readOnly", - case_exact=True, - required=True, - ), - _attribute_characteristics( - name="created", - type="dateTime", - description="The 'DateTime' that the resource was added to the service provider.", - mutability="readOnly", - required=True, - ), - _attribute_characteristics( - name="lastModified", - type="dateTime", - description="The most recent DateTime that the details of this resource were updated at the service provider.", - mutability="readOnly", - required=True, - ), - _attribute_characteristics( - name="location", - type="reference", - reference_types=["uri"], - description="The URI of the resource being returned.", - mutability="readOnly", - required=True, - ), - # todo(jon): decide if we'll handle versioning. for now, we won't do it. - ], - ), - ] - - -SERVICE_PROVIDER_CONFIG_SCHEMA = { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "name": "Service Provider Configuration", - "description": "Schema for representing the service provider's configuration.", - "meta": { - "resourceType": "Schema", - "created": "2025-04-16T14:48:00Z", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-16T14:48:00Z", - "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - }, - "attributes": [ - *_common_resource_attributes(id_required=False), - _attribute_characteristics( - name="documentationUri", - type="reference", - reference_types=["external"], - description="An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.", - mutability="readOnly", - ), - _attribute_characteristics( - name="patch", - type="complex", - description="A complex type that specifies PATCH configuration options.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="bulk", - type="complex", - description="A complex type that specifies bulk configuration options.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="maxOperations", - type="integer", - description="An integer value specifying the maximum number of operations.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="maxPayloadSize", - type="integer", - description="An integer value specifying the maximum payload size in bytes.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="filter", - type="complex", - description="A complex type that specifies FILTER options.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="maxResults", - type="integer", - description="The integer value specifying the maximum number of resources returned in a response.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="changePassword", - type="complex", - description="A complex type that specifies configuration options related to changing a password.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="sort", - type="complex", - description="A complex type that specifies sort result options.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="etag", - type="complex", - description="A complex type that specifies ETag configuration options.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="supported", - type="boolean", - description="A Boolean value specifying whether or not the operation is supported.", - required=True, - mutability="readOnly", - ), - ], - ), - _attribute_characteristics( - name="authenticationSchemes", - type="complex", - multi_valued=True, - description="A complex type that specifies supported authentication scheme properties.", - required=True, - mutability="readOnly", - sub_attributes=[ - *_multi_valued_attributes( - type_canonical_values=[ - "oauth", - "oauth2", - "oauthbearertoken", - "httpbasic", - "httpdigest", - ], - type_required=True, - type_mutability="readOnly", - ), - _attribute_characteristics( - name="name", - description="The common authentication scheme name, e.g., HTTP Basic.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="description", - description="A description of the authentication scheme.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="specUri", - type="reference", - reference_types=["external"], - description="An HTTP-addressable URL pointing to the authentication scheme's specification.", - mutability="readOnly", - ), - _attribute_characteristics( - name="documentationUri", - type="reference", - reference_types=["external"], - description="An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", - mutability="readOnly", - ), - ], - ), - ], -} - - -RESOURCE_TYPE_SCHEMA = { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": "urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "name": "Resource Type", - "description": "Specifies the schema that describes a SCIM resource type.", - "meta": { - "resourceType": "Schema", - "created": "2025-04-16T14:48:00Z", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-16T14:48:00Z", - "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:ResourceType", - }, - "attributes": [ - *_common_resource_attributes(id_required=False, id_uniqueness="global"), - _attribute_characteristics( - name="name", - description="The resource type name.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="description", - description="The resource type's human-readable description.", - mutability="readOnly", - ), - # todo(jon): figure out what the correct type/reference_type is here - _attribute_characteristics( - name="endpoint", - type="reference", - reference_types=["uri"], - description="The resource type's HTTP-addressable endpoint relative to the Base URL of the service provider.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="schema", - type="reference", - reference_types=["uri"], - description="The resource type's primary/base schema URI.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="schemaExtensions", - type="complex", - multi_valued=True, - description="A list of URIs of the resource type's schema extensions.", - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="schema", - type="reference", - reference_types=["uri"], - description="The URI of an extended schema.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="required", - type="boolean", - description="A Boolean value that specifies whether or not the schema extension is required for the resource type.", - required=True, - mutability="readOnly", - ), - ], - ), - ], -} - -SCHEMA_SCHEMA = { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": "urn:ietf:params:scim:schemas:core:2.0:Schema", - "name": "Schema", - "description": "Specifies the schema that describes a SCIM schema.", - "meta": { - "resourceType": "Schema", - "created": "2025-04-16T14:48:00Z", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-16T14:48:00Z", - "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:Schema", - }, - "attributes": [ - *_common_resource_attributes(id_uniqueness="global"), - _attribute_characteristics( - name="name", - description="The schema's human‐readable name.", - mutability="readOnly", - ), - _attribute_characteristics( - name="description", - description="The schema's human-readable name.", - mutability="readOnly", - ), - _attribute_characteristics( - name="attributes", - type="complex", - multi_valued=True, - description="A complex attribute that defines service provider attributes and their qualities.", - required=True, - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="name", - description="The attribute's name.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="type", - description="The attribute's data type.", - required=True, - canonical_values=[ - "string", - "complex", - "boolean", - "decimal", - "integer", - "dateTime", - "reference", - ], - case_exact=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="multiValued", - type="boolean", - description="A Boolean value indicating an attribute's plurality.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="description", - description="The attribute's human-readable description.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="required", - type="boolean", - description="A Boolean value indicating whether or not the attribute is required.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="canonicalValues", - multi_valued=True, - description="A collection of canonical values.", - mutability="readOnly", - ), - _attribute_characteristics( - name="caseExact", - type="boolean", - description="A Boolean that specifies whether or not a string attribute is case sensitive.", - mutability="readOnly", - ), - _attribute_characteristics( - name="mutability", - description="A single keyword indicating the circumstances under which the value of the attribute can be (re)defined.", - required=True, - mutability="readOnly", - canonical_values=[ - "readOnly", - "readWrite", - "immutable", - "writeOnly", - ], - case_exact=True, - ), - _attribute_characteristics( - name="returned", - description="A single keyword that indicates when an attribute and associated values are returned in response to a GET request or in response to a PUT, POST, or PATCH request.", - required=True, - mutability="readOnly", - canonical_values=[ - "always", - "never", - "default", - "request", - ], - case_exact=True, - ), - _attribute_characteristics( - name="uniqueness", - description="A single keyword value that specifies how the service provider enforces uniqueness of attribute values.", - required=True, - mutability="readOnly", - canonical_values=[ - "none", - "server", - "global", - ], - case_exact=True, - ), - _attribute_characteristics( - name="referenceTypes", - multi_valued=True, - description="A multi-valued array of JSON strings that indicate the SCIM resource types that may be referenced.", - mutability="readOnly", - canonical_values=[ - # todo(jon): add "User" and "Group" once those are done. - "external", - "uri", - ], - case_exact=True, - ), - _attribute_characteristics( - name="subAttributes", - type="complex", - multi_valued=True, - description="When an attribute is of type 'complex', this defines a set of sub-attributes.", - mutability="readOnly", - sub_attributes=[ - _attribute_characteristics( - name="name", - description="The attribute's name.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="type", - description="The attribute's data type.", - required=True, - canonical_values=[ - "string", - "complex", - "boolean", - "decimal", - "integer", - "dateTime", - "reference", - ], - case_exact=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="multiValued", - type="boolean", - description="A Boolean value indicating an attribute's plurality.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="description", - description="The attribute's human-readable description.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="required", - type="boolean", - description="A Boolean value indicating whether or not the attribute is required.", - required=True, - mutability="readOnly", - ), - _attribute_characteristics( - name="canonicalValues", - multi_valued=True, - description="A collection of canonical values.", - mutability="readOnly", - ), - _attribute_characteristics( - name="caseExact", - type="boolean", - description="A Boolean that specifies whether or not a string attribute is case sensitive.", - mutability="readOnly", - ), - _attribute_characteristics( - name="mutability", - description="A single keyword indicating the circumstances under which the value of the attribute can be (re)defined.", - required=True, - mutability="readOnly", - canonical_values=[ - "readOnly", - "readWrite", - "immutable", - "writeOnly", - ], - case_exact=True, - ), - _attribute_characteristics( - name="returned", - description="A single keyword that indicates when an attribute and associated values are returned in response to a GET request or in response to a PUT, POST, or PATCH request.", - required=True, - mutability="readOnly", - canonical_values=[ - "always", - "never", - "default", - "request", - ], - case_exact=True, - ), - _attribute_characteristics( - name="uniqueness", - description="A single keyword value that specifies how the service provider enforces uniqueness of attribute values.", - required=True, - mutability="readOnly", - canonical_values=[ - "none", - "server", - "global", - ], - case_exact=True, - ), - _attribute_characteristics( - name="referenceTypes", - multi_valued=True, - description="A multi-valued array of JSON strings that indicate the SCIM resource types that may be referenced.", - mutability="readOnly", - canonical_values=[ - # todo(jon): add "User" and "Group" once those are done. - "external", - "uri", - ], - case_exact=True, - ), - ], - ), - ], - ), - ], -} - - -USER_SCHEMA = { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": "urn:ietf:params:scim:schemas:core:2.0:User", - "name": "User", - "description": "User account.", - "meta": { - "resourceType": "Schema", - "created": "2025-04-16T14:48:00Z", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-16T14:48:00Z", - "location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:User", - }, - "attributes": [ - *_common_resource_attributes(), - _attribute_characteristics( - name="userName", - description="A service provider's unique identifier for the user.", - required=True, - ), - ], -} - +import json SCHEMAS = sorted( [ - SERVICE_PROVIDER_CONFIG_SCHEMA, - RESOURCE_TYPE_SCHEMA, - SCHEMA_SCHEMA, - USER_SCHEMA, + json.load(open("routers/fixtures/service_provider_config_schema.json", "r")), + json.load(open("routers/fixtures/resource_type_schema.json", "r")), + json.load(open("routers/fixtures/schema_schema.json", "r")), + json.load(open("routers/fixtures/user_schema.json", "r")), + # todo(jon): add this when we have groups + # json.load(open("routers/schemas/group_schema.json", "r")), ], key=lambda x: x["id"], ) -SERVICE_PROVIDER_CONFIG = { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], - "patch": { - # todo(jon): this needs to be updated to True once we properly implement patching for users and groups - "supported": False, - }, - "bulk": { - "supported": False, - "maxOperations": 0, - "maxPayloadSize": 0, - }, - "filter": { - # todo(jon): this needs to be updated to True once we properly implement filtering for users and groups - "supported": False, - "maxResults": 0, - }, - "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", - # todo(jon): see if we have our own documentation for this - # "documentationUri": "", # optional - "primary": True, - }, - ], - "meta": { - "resourceType": "ServiceProviderConfig", - "created": "2025-04-15T15:45:00Z", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-15T15:45:00Z", - "location": "", # note(jon): this field will be computed in the /ServiceProviderConfig endpoint - }, -} +SERVICE_PROVIDER_CONFIG = json.load( + open("routers/fixtures/service_provider_config.json", "r") +) RESOURCE_TYPES = sorted( - [ - { - "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", - # note(jon): we might want to think about adding this resource as part of our db - # and then updating these timestamps from an api and such. for now, if we update - # the configuration, we should update the timestamp here. - "lastModified": "2025-04-16T08:37:00Z", - "location": "ResourceType/User", - }, - } - ], + json.load(open("routers/fixtures/resource_type.json", "r")), key=lambda x: x["id"], ) From d0a1e894d6d9593b10109b79952bca9e7a079486 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 18 Apr 2025 17:28:03 +0200 Subject: [PATCH 15/34] add externalId, name, and displayName for scim user --- ee/api/routers/scim.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index cf21cb878..a33aed847 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -234,6 +234,12 @@ def _convert_db_user_to_scim_user( "location": f"Users/{db_user['userId']}", }, "userName": db_user["email"], + "externalId": db_user["internalId"], + "name": { + "formatted": db_user["name"], + }, + "displayName": db_user["name"] or db_user["email"], + } scim_user = scim_helpers.filter_attributes(scim_user, included_attributes) scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes) From ebeff746cb80b4d26f263b08c377e1eaf08de3e6 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 22 Apr 2025 09:00:47 +0200 Subject: [PATCH 16/34] added new fields to user endpoints --- ee/api/chalicelib/core/users.py | 279 ++++++++++++++++++-------------- ee/api/routers/scim.py | 86 ++++++---- 2 files changed, 221 insertions(+), 144 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index a57a194e5..8310c8d10 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -162,37 +162,6 @@ def reset_member(tenant_id, editor_id, user_id_to_update): return {"data": {"invitationLink": generate_new_invitation(user_id_to_update)}} -def update_scim_user( - user_id: int, - tenant_id: int, - email: str, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - UPDATE public.users - SET email = %(email)s - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - RETURNING * - ) - SELECT * - FROM u; - """, - { - "tenant_id": tenant_id, - "user_id": user_id, - "email": email, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - def update(tenant_id, user_id, changes, output=True): AUTH_KEYS = [ "password", @@ -381,13 +350,39 @@ def get(user_id, tenant_id): return helper.dict_to_camel_case(r) +def get_scim_users_paginated(start_index, tenant_id, count=None): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT + users.*, + roles.name AS role_name + FROM public.users + LEFT JOIN public.roles USING (role_id) + WHERE + users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + LIMIT %(limit)s + OFFSET %(offset)s; + """, + {"offset": start_index - 1, "limit": count, "tenant_id": tenant_id}, + ) + ) + r = cur.fetchall() + return helper.list_to_camel_case(r) + + def get_scim_user_by_id(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( """ - SELECT * + SELECT + users.*, + roles.name AS role_name FROM public.users + LEFT JOIN public.roles USING (role_id) WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -403,6 +398,140 @@ def get_scim_user_by_id(user_id, tenant_id): return helper.dict_to_camel_case(cur.fetchone()) +def create_scim_user( + email: str, + tenant_id: int, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, +): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH u AS ( + INSERT INTO public.users ( + tenant_id, + email, + name, + internal_id, + role_id + ) + VALUES ( + %(tenant_id)s, + %(email)s, + %(name)s, + %(internal_id)s, + %(role_id)s + ) + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + +def restore_scim_user( + user_id: int, + tenant_id: int, + email: str, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, +): + with pg_client.PostgresClient() as 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, + role_id = %(role_id)s, + deleted_at = NULL, + created_at = default, + api_key = default, + jwt_iat = NULL, + weekly_report = default + WHERE users.user_id = %(user_id)s + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + +def update_scim_user( + user_id: int, + tenant_id: int, + email: str, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, +): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH u AS ( + UPDATE public.users + SET + email = %(email)s, + name = %(name)s, + internal_id = %(internal_id)s, + role_id = %(role_id)s + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "user_id": user_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + def generate_new_api_key(user_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -513,7 +642,7 @@ def edit_member( return {"data": user} -def get_existing_scim_user_by_unique_values(email): +def get_existing_scim_user_by_unique_values_from_all_users(email): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -558,26 +687,6 @@ def get_by_email_only(email): return helper.dict_to_camel_case(r) -def get_users_paginated(start_index, tenant_id, count=None): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT * - FROM public.users - WHERE - users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - LIMIT %(limit)s - OFFSET %(offset)s; - """, - {"offset": start_index - 1, "limit": count, "tenant_id": tenant_id}, - ) - ) - r = cur.fetchall() - return helper.list_to_camel_case(r) - - def get_member(tenant_id, user_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -1093,41 +1202,6 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= return helper.dict_to_camel_case(cur.fetchone()) -def create_scim_user( - email, - name, - tenant_id, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - INSERT INTO public.users ( - tenant_id, - email, - name - ) - VALUES ( - %(tenant_id)s, - %(email)s, - %(name)s - ) - RETURNING * - ) - SELECT * - FROM u; - """, - { - "tenant_id": tenant_id, - "email": email, - "name": name, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - def soft_delete_scim_user_by_id(user_id, tenant_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -1314,35 +1388,6 @@ def restore_sso_user( return helper.dict_to_camel_case(cur.fetchone()) -def restore_scim_user( - user_id, - tenant_id, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - UPDATE public.users - SET - tenant_id = %(tenant_id)s, - deleted_at = NULL, - created_at = default, - api_key = default, - jwt_iat = NULL, - weekly_report = default - WHERE users.user_id = %(user_id)s - RETURNING * - ) - SELECT * - FROM u; - """, - {"tenant_id": tenant_id, "user_id": user_id}, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - def get_user_settings(user_id): # read user settings from users.settings:jsonb column with pg_client.PostgresClient() as cur: diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index a33aed847..3e832dcf8 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -1,3 +1,4 @@ +from copy import deepcopy import logging from typing import Any @@ -7,7 +8,7 @@ from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel -from chalicelib.core import users, tenants +from chalicelib.core import users, roles, tenants from chalicelib.utils.scim_auth import ( auth_optional, auth_required, @@ -171,13 +172,21 @@ async def get_schemas(filter_param: str | None = Query(None, alias="filter")): ) -@public_app.get("/Schemas/{schema_id}", dependencies=[Depends(auth_required)]) -async def get_schema(schema_id: str): +@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_IDS_TO_SCHEMA_DETAILS[schema_id], + content=schema, ) @@ -205,7 +214,22 @@ async def get_service_provider_config( MAX_USERS_PER_PAGE = 10 -def _convert_db_user_to_scim_user( +def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, Any]: + role_id = None + if "userType" in data: + role = roles.get_role_by_name(tenant_id, data["userType"]) + role_id = role["roleId"] if role else None + result = { + "email": data["userName"], + "internal_id": data.get("externalId"), + "name": data.get("name", {}).get("formatted") or data.get("displayName"), + "role_id": role_id, + } + result = {k: v for k, v in result.items() if v is not None} + return result + + +def _serialize_db_user_to_scim_user( db_user: dict[str, Any], attributes: list[str] | None = None, excluded_attributes: list[str] | None = None, @@ -239,7 +263,8 @@ def _convert_db_user_to_scim_user( "formatted": db_user["name"], }, "displayName": db_user["name"] or db_user["email"], - + "userType": db_user.get("roleName"), + "active": db_user["deletedAt"] is None, } scim_user = scim_helpers.filter_attributes(scim_user, included_attributes) scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes) @@ -260,12 +285,12 @@ async def get_users( ) # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. # but this is the fastest thing at the moment just to test that it's working - total_resources = users.get_users_paginated(1, tenant_id) - db_resources = users.get_users_paginated( + total_resources = users.get_scim_users_paginated(1, tenant_id) + db_resources = users.get_scim_users_paginated( start_index, tenant_id, count=items_per_page ) scim_resources = [ - _convert_db_user_to_scim_user(resource, attributes, excluded_attributes) + _serialize_db_user_to_scim_user(resource, attributes, excluded_attributes) for resource in db_resources ] return JSONResponse( @@ -289,7 +314,7 @@ async def get_user( db_resource = users.get_scim_user_by_id(user_id, tenant_id) if not db_resource: return _not_found_error_response(user_id) - scim_resource = _convert_db_user_to_scim_user( + scim_resource = _serialize_db_user_to_scim_user( db_resource, attributes, excluded_attributes ) return JSONResponse(status_code=200, content=scim_resource) @@ -297,26 +322,28 @@ async def get_user( @public_app.post("/Users") async def create_user(r: Request, tenant_id=Depends(auth_required)): - payload = await r.json() - if "userName" not in payload: + scim_payload = await r.json() + try: + db_payload = _parse_scim_user_input(scim_payload, tenant_id) + except KeyError: return _invalid_value_error_response() - # note(jon): this method will return soft deleted users as well - existing_db_resource = users.get_existing_scim_user_by_unique_values( - payload["userName"] + existing_db_resource = users.get_existing_scim_user_by_unique_values_from_all_users( + db_payload["email"] ) if existing_db_resource and existing_db_resource["deletedAt"] is None: return _uniqueness_error_response() if existing_db_resource and existing_db_resource["deletedAt"] is not None: - db_resource = users.restore_scim_user(existing_db_resource["userId"], tenant_id) + db_resource = users.restore_scim_user( + user_id=existing_db_resource["userId"], + tenant_id=tenant_id, + **db_payload, + ) else: db_resource = users.create_scim_user( - email=payload["userName"], - # note(jon): scim schema does not require the `name.formatted` attribute, but we require `name`. - # so, we have to define the value ourselves here - name="", tenant_id=tenant_id, + **db_payload, ) - scim_resource = _convert_db_user_to_scim_user(db_resource) + scim_resource = _serialize_db_user_to_scim_user(db_resource) response = JSONResponse(status_code=201, content=scim_resource) response.headers["Location"] = scim_resource["meta"]["location"] return response @@ -327,22 +354,26 @@ async def update_user(user_id: str, r: Request, tenant_id=Depends(auth_required) db_resource = users.get_scim_user_by_id(user_id, tenant_id) if not db_resource: return _not_found_error_response(user_id) - current_scim_resource = _convert_db_user_to_scim_user(db_resource) - changes = await r.json() + current_scim_resource = _serialize_db_user_to_scim_user(db_resource) + requested_scim_changes = await r.json() schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] try: - valid_mutable_changes = scim_helpers.filter_mutable_attributes( - schema, changes, current_scim_resource + valid_mutable_scim_changes = scim_helpers.filter_mutable_attributes( + schema, requested_scim_changes, current_scim_resource ) except ValueError: return _mutability_error_response() + valid_mutable_db_changes = _parse_scim_user_input( + valid_mutable_scim_changes, + tenant_id, + ) try: updated_db_resource = users.update_scim_user( user_id, tenant_id, - email=valid_mutable_changes["userName"], + **valid_mutable_db_changes, ) - updated_scim_resource = _convert_db_user_to_scim_user(updated_db_resource) + updated_scim_resource = _serialize_db_user_to_scim_user(updated_db_resource) return JSONResponse(status_code=200, content=updated_scim_resource) except Exception: # note(jon): for now, this is the only error that would happen when updating the scim user @@ -351,6 +382,7 @@ async def update_user(user_id: str, r: Request, tenant_id=Depends(auth_required) @public_app.delete("/Users/{user_id}") async def delete_user(user_id: str, tenant_id=Depends(auth_required)): + # note(jon): this is a soft delete db_resource = users.get_scim_user_by_id(user_id, tenant_id) if not db_resource: return _not_found_error_response(user_id) From 9057637b84e0f91b3012b4cd467e2b4da29271e2 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 22 Apr 2025 16:40:37 +0200 Subject: [PATCH 17/34] added groups endpoints and reformatted code --- ee/api/chalicelib/core/users.py | 11 +- ee/api/routers/fixtures/group_schema.json | 107 +++++- ee/api/routers/scim.py | 304 +++++++++++++----- ee/api/routers/scim_constants.py | 3 +- ee/api/routers/scim_groups.py | 211 ++++++++++++ ee/api/routers/scim_helpers.py | 38 ++- .../db/init_dbs/postgresql/init_schema.sql | 13 +- 7 files changed, 579 insertions(+), 108 deletions(-) create mode 100644 ee/api/routers/scim_groups.py diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 8310c8d10..f7e084b8a 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -444,12 +444,13 @@ def create_scim_user( def restore_scim_user( - user_id: int, - tenant_id: int, + userId: int, + tenantId: int, email: str, name: str = "", internal_id: str | None = None, role_id: int | None = None, + **kwargs, ): with pg_client.PostgresClient() as cur: cur.execute( @@ -477,8 +478,8 @@ def restore_scim_user( FROM u LEFT JOIN public.roles USING (role_id); """, { - "tenant_id": tenant_id, - "user_id": user_id, + "tenant_id": tenantId, + "user_id": userId, "email": email, "name": name, "internal_id": internal_id, @@ -642,7 +643,7 @@ def edit_member( return {"data": user} -def get_existing_scim_user_by_unique_values_from_all_users(email): +def get_existing_scim_user_by_unique_values_from_all_users(email: str, **kwargs): with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( diff --git a/ee/api/routers/fixtures/group_schema.json b/ee/api/routers/fixtures/group_schema.json index f1ef0f71f..1a56a3cdf 100644 --- a/ee/api/routers/fixtures/group_schema.json +++ b/ee/api/routers/fixtures/group_schema.json @@ -3,12 +3,107 @@ "name": "Group", "description": "Group", "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": false, + "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", @@ -26,8 +121,8 @@ "type": "string", "multiValued": false, "description": "Identifier of the member of this Group.", - "required": false, - "caseExact": false, + "required": true, + "caseExact": true, "mutability": "immutable", "returned": "default", "uniqueness": "none" @@ -35,7 +130,7 @@ { "name": "$ref", "type": "reference", - "referenceTypes": ["User", "Group"], + "referenceTypes": ["User"], "multiValued": false, "description": "The URI of the corresponding member resource of this Group.", "required": false, @@ -48,10 +143,10 @@ "name": "type", "type": "string", "multiValued": false, - "description": "A label indicating the type of resource; e.g., 'User' or 'Group'.", + "description": "A label indicating the type of resource; e.g., 'User'.", "required": false, "caseExact": false, - "canonicalValues": ["User", "Group"], + "canonicalValues": ["User"], "mutability": "immutable", "returned": "default", "uniqueness": "none" diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 3e832dcf8..c50ea41a5 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -1,12 +1,14 @@ from copy import deepcopy import logging -from typing import Any +from typing import Any, Callable +from enum import Enum from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi.responses import JSONResponse from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel +from psycopg2 import errors from chalicelib.core import users, roles, tenants from chalicelib.utils.scim_auth import ( @@ -17,7 +19,7 @@ from chalicelib.utils.scim_auth import ( ) from routers.base import get_routers from routers.scim_constants import RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG -from routers import scim_helpers +from routers import scim_helpers, scim_groups logger = logging.getLogger(__name__) @@ -65,7 +67,7 @@ RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { } -def _not_found_error_response(resource_id: str): +def _not_found_error_response(resource_id: int): return JSONResponse( status_code=404, content={ @@ -123,6 +125,17 @@ def _invalid_value_error_response(): ) +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", + }, + ) + + @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: @@ -211,7 +224,28 @@ async def get_service_provider_config( return JSONResponse(status_code=200, content=SERVICE_PROVIDER_CONFIG) -MAX_USERS_PER_PAGE = 10 +def _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource: dict[str, Any], + schema_id: str, + serialize_db_resource_to_scim_resource: Callable[[dict[str, Any]], dict[str, Any]], + attributes: list[str] | None = None, + excluded_attributes: list[str] | None = None, +) -> dict[str, Any]: + schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id] + all_attributes = scim_helpers.get_all_attribute_names(schema) + attributes = attributes or all_attributes + always_returned_attributes = ( + scim_helpers.get_all_attribute_names_where_returned_is_always(schema) + ) + included_attributes = list(set(attributes).union(set(always_returned_attributes))) + excluded_attributes = excluded_attributes or [] + excluded_attributes = list( + set(excluded_attributes).difference(set(always_returned_attributes)) + ) + scim_resource = serialize_db_resource_to_scim_resource(db_resource) + scim_resource = scim_helpers.filter_attributes(scim_resource, included_attributes) + scim_resource = scim_helpers.exclude_attributes(scim_resource, excluded_attributes) + return scim_resource def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, Any]: @@ -229,25 +263,8 @@ def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, An return result -def _serialize_db_user_to_scim_user( - db_user: dict[str, Any], - attributes: list[str] | None = None, - excluded_attributes: list[str] | None = None, -) -> dict[str, Any]: - user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[ - "urn:ietf:params:scim:schemas:core:2.0:User" - ] - all_attributes = scim_helpers.get_all_attribute_names(user_schema) - attributes = attributes or all_attributes - always_returned_attributes = ( - scim_helpers.get_all_attribute_names_where_returned_is_always(user_schema) - ) - included_attributes = list(set(attributes).union(set(always_returned_attributes))) - excluded_attributes = excluded_attributes or [] - excluded_attributes = list( - set(excluded_attributes).difference(set(always_returned_attributes)) - ) - scim_user = { +def _serialize_db_user_to_scim_user(db_user: dict[str, Any]) -> dict[str, Any]: + return { "id": str(db_user["userId"]), "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "meta": { @@ -266,32 +283,107 @@ def _serialize_db_user_to_scim_user( "userType": db_user.get("roleName"), "active": db_user["deletedAt"] is None, } - scim_user = scim_helpers.filter_attributes(scim_user, included_attributes) - scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes) - return scim_user -@public_app.get("/Users") -async def get_users( +def _serialize_db_group_to_scim_group(db_resource: dict[str, Any]) -> dict[str, Any]: + members = db_resource["users"] or [] + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": str(db_resource["groupId"]), + "externalId": db_resource["externalId"], + "meta": { + "resourceType": "Group", + "created": db_resource["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), + "lastModified": db_resource["updatedAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), + "location": f"Groups/{db_resource['groupId']}", + }, + "displayName": db_resource["name"], + "members": [ + { + "value": str(member["userId"]), + "$ref": f"Users/{member['userId']}", + "type": "User", + } + for member in members + ], + } + + +def _parse_scim_group_input(data: dict[str, Any], tenant_id: int) -> dict[str, Any]: + return { + "name": data["displayName"], + "external_id": data.get("externalId"), + "user_ids": [int(member["value"]) for member in data.get("members", [])], + } + + +RESOURCE_TYPE_TO_RESOURCE_CONFIG = { + "Users": { + "max_items_per_page": 10, + "schema_id": "urn:ietf:params:scim:schemas:core:2.0:User", + "db_to_scim_serializer": _serialize_db_user_to_scim_user, + "get_paginated_resources": users.get_scim_users_paginated, + "get_unique_resource": users.get_scim_user_by_id, + "parse_post_payload": _parse_scim_user_input, + "get_resource_by_unique_values": users.get_existing_scim_user_by_unique_values_from_all_users, + "restore_resource": users.restore_scim_user, + "create_resource": users.create_scim_user, + "delete_resource": users.soft_delete_scim_user_by_id, + "parse_put_payload": _parse_scim_user_input, + "update_resource": users.update_scim_user, + }, + "Groups": { + "max_items_per_page": 10, + "schema_id": "urn:ietf:params:scim:schemas:core:2.0:Group", + "db_to_scim_serializer": _serialize_db_group_to_scim_group, + "get_paginated_resources": scim_groups.get_resources_paginated, + "get_unique_resource": scim_groups.get_resource_by_id, + "parse_post_payload": _parse_scim_group_input, + "get_resource_by_unique_values": scim_groups.get_existing_resource_by_unique_values_from_all_resources, + "restore_resource": scim_groups.restore_resource, + "create_resource": scim_groups.create_resource, + "delete_resource": scim_groups.delete_resource, + "parse_put_payload": _parse_scim_group_input, + "update_resource": scim_groups.update_resource, + }, +} + + +class ListResourceType(str, Enum): + USERS = "Users" + GROUPS = "Groups" + + +@public_app.get("/{resource_type}") +async def get_resources( + resource_type: ListResourceType, tenant_id=Depends(auth_required), requested_start_index: int = Query(1, alias="startIndex"), requested_items_per_page: int | None = Query(None, alias="count"), attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] start_index = max(1, requested_start_index) + max_items_per_page = resource_config["max_items_per_page"] items_per_page = min( - max(0, requested_items_per_page or MAX_USERS_PER_PAGE), MAX_USERS_PER_PAGE + max(0, requested_items_per_page or max_items_per_page), max_items_per_page ) # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. # but this is the fastest thing at the moment just to test that it's working - total_resources = users.get_scim_users_paginated(1, tenant_id) - db_resources = users.get_scim_users_paginated( - start_index, tenant_id, count=items_per_page + total_resources = resource_config["get_paginated_resources"](1, tenant_id) + db_resources = resource_config["get_paginated_resources"]( + start_index, tenant_id, items_per_page ) scim_resources = [ - _serialize_db_user_to_scim_user(resource, attributes, excluded_attributes) - for resource in db_resources + _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + attributes, + excluded_attributes, + ) + for db_resource in db_resources ] return JSONResponse( status_code=200, @@ -304,87 +396,145 @@ async def get_users( ) -@public_app.get("/Users/{user_id}") -async def get_user( - user_id: str, +class GetResourceType(str, Enum): + USERS = "Users" + GROUPS = "Groups" + + +@public_app.get("/{resource_type}/{resource_id}") +async def get_resource( + resource_type: GetResourceType, + resource_id: int, tenant_id=Depends(auth_required), attributes: list[str] | None = Query(None), excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), ): - db_resource = users.get_scim_user_by_id(user_id, tenant_id) + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] + db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) if not db_resource: - return _not_found_error_response(user_id) - scim_resource = _serialize_db_user_to_scim_user( - db_resource, attributes, excluded_attributes + return _not_found_error_response(resource_id) + scim_resource = _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + attributes, + excluded_attributes, ) return JSONResponse(status_code=200, content=scim_resource) -@public_app.post("/Users") -async def create_user(r: Request, tenant_id=Depends(auth_required)): +class PostResourceType(str, Enum): + USERS = "Users" + GROUPS = "Groups" + + +@public_app.post("/{resource_type}") +async def create_resource( + resource_type: PostResourceType, + r: Request, + tenant_id=Depends(auth_required), +): + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] scim_payload = await r.json() try: - db_payload = _parse_scim_user_input(scim_payload, tenant_id) + db_payload = resource_config["parse_post_payload"](scim_payload, tenant_id) except KeyError: return _invalid_value_error_response() - existing_db_resource = users.get_existing_scim_user_by_unique_values_from_all_users( - db_payload["email"] + existing_db_resource = resource_config["get_resource_by_unique_values"]( + **db_payload ) - if existing_db_resource and existing_db_resource["deletedAt"] is None: + if existing_db_resource and existing_db_resource.get("deletedAt") is None: return _uniqueness_error_response() - if existing_db_resource and existing_db_resource["deletedAt"] is not None: - db_resource = users.restore_scim_user( - user_id=existing_db_resource["userId"], - tenant_id=tenant_id, - **db_payload, - ) + if existing_db_resource and existing_db_resource.get("deletedAt") is not None: + # todo(jon): not a super elegant solution overwriting the existing db resource. + # maybe we should try something else. + existing_db_resource.update(db_payload) + db_resource = resource_config["restore_resource"](**existing_db_resource) else: - db_resource = users.create_scim_user( + db_resource = resource_config["create_resource"]( tenant_id=tenant_id, **db_payload, ) - scim_resource = _serialize_db_user_to_scim_user(db_resource) + scim_resource = _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + ) response = JSONResponse(status_code=201, content=scim_resource) response.headers["Location"] = scim_resource["meta"]["location"] return response -@public_app.put("/Users/{user_id}") -async def update_user(user_id: str, r: Request, tenant_id=Depends(auth_required)): - db_resource = users.get_scim_user_by_id(user_id, tenant_id) +class DeleteResourceType(str, Enum): + USERS = "Users" + GROUPS = "Groups" + + +@public_app.delete("/{resource_type}/{resource_id}") +async def delete_resource( + resource_type: DeleteResourceType, + resource_id: str, + tenant_id=Depends(auth_required), +): + # note(jon): this can be a soft or a hard delete + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] + db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) if not db_resource: - return _not_found_error_response(user_id) - current_scim_resource = _serialize_db_user_to_scim_user(db_resource) + return _not_found_error_response(resource_id) + resource_config["delete_resource"](resource_id, tenant_id) + return Response(status_code=204, content="") + + +class PutResourceType(str, Enum): + USERS = "Users" + GROUPS = "Groups" + + +@public_app.put("/{resource_type}/{resource_id}") +async def update_resource( + resource_type: PutResourceType, + resource_id: str, + r: Request, + tenant_id=Depends(auth_required), +): + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] + db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) + if not db_resource: + return _not_found_error_response(resource_id) + current_scim_resource = ( + _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + ) + ) requested_scim_changes = await r.json() - schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"] + schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[resource_config["schema_id"]] try: valid_mutable_scim_changes = scim_helpers.filter_mutable_attributes( schema, requested_scim_changes, current_scim_resource ) except ValueError: return _mutability_error_response() - valid_mutable_db_changes = _parse_scim_user_input( + valid_mutable_db_changes = resource_config["parse_put_payload"]( valid_mutable_scim_changes, tenant_id, ) try: - updated_db_resource = users.update_scim_user( - user_id, + updated_db_resource = resource_config["update_resource"]( + resource_id, tenant_id, **valid_mutable_db_changes, ) - updated_scim_resource = _serialize_db_user_to_scim_user(updated_db_resource) + updated_scim_resource = ( + _serialize_db_resource_to_scim_resource_with_attribute_awareness( + updated_db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + ) + ) return JSONResponse(status_code=200, content=updated_scim_resource) - except Exception: - # note(jon): for now, this is the only error that would happen when updating the scim user + except errors.UniqueViolation: return _uniqueness_error_response() - - -@public_app.delete("/Users/{user_id}") -async def delete_user(user_id: str, tenant_id=Depends(auth_required)): - # note(jon): this is a soft delete - db_resource = users.get_scim_user_by_id(user_id, tenant_id) - if not db_resource: - return _not_found_error_response(user_id) - users.soft_delete_scim_user_by_id(user_id, tenant_id) - return Response(status_code=204, content="") + except Exception as e: + return _internal_server_error_response(str(e)) diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py index 090135ffc..4b52b1a8e 100644 --- a/ee/api/routers/scim_constants.py +++ b/ee/api/routers/scim_constants.py @@ -7,8 +7,7 @@ SCHEMAS = sorted( json.load(open("routers/fixtures/resource_type_schema.json", "r")), json.load(open("routers/fixtures/schema_schema.json", "r")), json.load(open("routers/fixtures/user_schema.json", "r")), - # todo(jon): add this when we have groups - # json.load(open("routers/schemas/group_schema.json", "r")), + json.load(open("routers/fixtures/group_schema.json", "r")), ], key=lambda x: x["id"], ) diff --git a/ee/api/routers/scim_groups.py b/ee/api/routers/scim_groups.py new file mode 100644 index 000000000..d80bf818d --- /dev/null +++ b/ee/api/routers/scim_groups.py @@ -0,0 +1,211 @@ +from typing import Any + +from chalicelib.utils import helper, pg_client + + +def get_resources_paginated( + offset_one_indexed: int, tenant_id: int, limit: int | None = None +) -> list[dict[str, Any]]: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT + groups.*, + users_data.array as users + FROM public.groups + LEFT JOIN LATERAL ( + SELECT json_agg(users) AS array + FROM public.users + WHERE users.group_id = groups.group_id + ) users_data ON true + WHERE groups.tenant_id = %(tenant_id)s + LIMIT %(limit)s + OFFSET %(offset)s; + """, + { + "offset": offset_one_indexed - 1, + "limit": limit, + "tenant_id": tenant_id, + }, + ) + ) + return helper.list_to_camel_case(cur.fetchall()) + + +def get_resource_by_id(group_id: int, tenant_id: int) -> dict[str, Any]: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT + groups.*, + users_data.array as users + FROM public.groups + LEFT JOIN LATERAL ( + SELECT json_agg(users) AS array + FROM public.users + WHERE users.group_id = groups.group_id + ) users_data ON true + WHERE + groups.tenant_id = %(tenant_id)s + AND groups.group_id = %(group_id)s + LIMIT 1; + """, + {"group_id": group_id, "tenant_id": tenant_id}, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + +def get_existing_resource_by_unique_values_from_all_resources( + **kwargs, +) -> dict[str, Any] | None: + # note(jon): we do not really use this for groups as we don't have unique values outside + # of the primary key + return None + + +def restore_resource(**kwargs: dict[str, Any]) -> dict[str, Any] | None: + # note(jon): we're not soft deleting groups, so we don't need this + return None + + +def create_resource( + name: str, tenant_id: int, **kwargs: dict[str, Any] +) -> dict[str, Any]: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH g AS( + INSERT INTO public.groups + (tenant_id, name, external_id) + VALUES (%(tenant_id)s, %(name)s, %(external_id)s) + RETURNING * + ) + SELECT g.group_id + FROM g; + """, + { + "tenant_id": tenant_id, + "name": name, + "external_id": kwargs.get("external_id"), + }, + ) + ) + group_id = cur.fetchone()["group_id"] + user_ids = kwargs.get("user_ids", []) + if user_ids: + cur.execute( + cur.mogrify( + """ + UPDATE public.users + SET group_id = %s + WHERE users.user_id = ANY(%s) + """, + (group_id, user_ids), + ) + ) + cur.execute( + cur.mogrify( + """ + SELECT + groups.*, + users_data.array as users + FROM public.groups + LEFT JOIN LATERAL ( + SELECT json_agg(users) AS array + FROM public.users + WHERE users.group_id = %(group_id)s + ) users_data ON true + WHERE + groups.group_id = %(group_id)s + AND groups.tenant_id = %(tenant_id)s + LIMIT 1; + """, + { + "group_id": group_id, + "tenant_id": tenant_id, + "name": name, + "external_id": kwargs.get("external_id"), + }, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) + + +def delete_resource(group_id: int, tenant_id: int) -> None: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + DELETE FROM public.groups + WHERE groups.group_id = %(group_id)s AND groups.tenant_id = %(tenant_id)s; + """ + ), + {"tenant_id": tenant_id, "group_id": group_id}, + ) + + +def update_resource( + group_id: int, tenant_id: int, name: str, **kwargs: dict[str, Any] +) -> dict[str, Any]: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + UPDATE public.users + SET group_id = null + WHERE users.group_id = %(group_id)s; + """, + {"group_id": group_id}, + ) + ) + user_ids = kwargs.get("user_ids", []) + if user_ids: + cur.execute( + cur.mogrify( + """ + UPDATE public.users + SET group_id = %s + WHERE users.user_id = ANY(%s); + """, + (group_id, user_ids), + ) + ) + cur.execute( + cur.mogrify( + """ + WITH g AS ( + UPDATE public.groups + SET + tenant_id = %(tenant_id)s, + name = %(name)s, + external_id = %(external_id)s, + updated_at = default + WHERE + groups.group_id = %(group_id)s + AND groups.tenant_id = %(tenant_id)s + RETURNING * + ) + SELECT + g.*, + users_data.array as users + FROM g + LEFT JOIN LATERAL ( + SELECT json_agg(users) AS array + FROM public.users + WHERE users.group_id = g.group_id + ) users_data ON true + LIMIT 1; + """, + { + "group_id": group_id, + "tenant_id": tenant_id, + "name": name, + "external_id": kwargs.get("external_id"), + }, + ) + ) + return helper.dict_to_camel_case(cur.fetchone()) diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py index 6c04ecab8..cda66b29c 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim_helpers.py @@ -41,31 +41,35 @@ def filter_attributes( resource: dict[str, Any], include_list: list[str] ) -> dict[str, Any]: result = {} - for attr in include_list: - parts = attr.split(".", 1) + + # Group include paths by top-level key + includes_by_key = {} + for path in include_list: + parts = path.split(".", 1) key = parts[0] + rest = parts[1] if len(parts) == 2 else None + includes_by_key.setdefault(key, []).append(rest) + + for key, subpaths in includes_by_key.items(): if key not in resource: continue - if len(parts) == 1: - # top‑level attr - result[key] = resource[key] + value = resource[key] + if all(p is None for p in subpaths): + result[key] = value else: - # nested attr - sub = resource[key] - rest = parts[1] - if isinstance(sub, dict): - filtered = filter_attributes(sub, [rest]) + nested_paths = [p for p in subpaths if p is not None] + if isinstance(value, dict): + filtered = filter_attributes(value, nested_paths) if filtered: - result.setdefault(key, {}).update(filtered) - elif isinstance(sub, list): - # apply to each element + result[key] = filtered + elif isinstance(value, list): new_list = [] - for item in sub: + for item in value: if isinstance(item, dict): - f = filter_attributes(item, [rest]) - if f: - new_list.append(f) + filtered_item = filter_attributes(item, nested_paths) + if filtered_item: + new_list.append(filtered_item) if new_list: result[key] = new_list return result diff --git a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql index caf4e7467..96fb5a23b 100644 --- a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -122,6 +122,16 @@ CREATE TABLE public.roles service_role bool NOT NULL DEFAULT FALSE ); +CREATE TABLE public.groups +( + group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id integer NOT NULL REFERENCES public.tenants (tenant_id) ON DELETE CASCADE, + external_id text, + name text NOT NULL, + created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + updated_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc') +); + CREATE TYPE user_role AS ENUM ('owner','admin','member','service'); CREATE TABLE public.users @@ -151,7 +161,8 @@ CREATE TABLE public.users origin text NULL DEFAULT NULL, role_id integer REFERENCES public.roles (role_id) ON DELETE SET NULL, internal_id text NULL DEFAULT NULL, - service_account bool NOT NULL DEFAULT FALSE + service_account bool NOT NULL DEFAULT FALSE, + group_id integer REFERENCES public.groups (group_id) ON DELETE SET NULL ); CREATE INDEX users_tenant_id_deleted_at_N_idx ON public.users (tenant_id) WHERE deleted_at ISNULL; CREATE INDEX users_name_gin_idx ON public.users USING GIN (name gin_trgm_ops); From b0531ef223a37451deafb8ab035068617df5c20d Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 23 Apr 2025 08:46:18 +0200 Subject: [PATCH 18/34] at users.updated_at, handle queries with multiple attributes, remove dead code --- ee/api/chalicelib/core/users.py | 25 +++++++++++++---- ee/api/routers/scim.py | 27 ++++++++++--------- ee/api/routers/scim_groups.py | 20 ++++++++++---- ee/api/routers/scim_helpers.py | 20 +++++++------- .../db/init_dbs/postgresql/init_schema.sql | 1 + 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index f7e084b8a..173819092 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -350,6 +350,23 @@ def get(user_id, tenant_id): return helper.dict_to_camel_case(r) +def count_total_scim_users(tenant_id: int) -> int: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT COUNT(*) + FROM public.users + WHERE + users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + """, + {"tenant_id": tenant_id}, + ) + ) + return cur.fetchone()["count"] + + def get_scim_users_paginated(start_index, tenant_id, count=None): with pg_client.PostgresClient() as cur: cur.execute( @@ -444,8 +461,7 @@ def create_scim_user( def restore_scim_user( - userId: int, - tenantId: int, + tenant_id: int, email: str, name: str = "", internal_id: str | None = None, @@ -469,7 +485,7 @@ def restore_scim_user( api_key = default, jwt_iat = NULL, weekly_report = default - WHERE users.user_id = %(user_id)s + WHERE users.email = %(email)s RETURNING * ) SELECT @@ -478,8 +494,7 @@ def restore_scim_user( FROM u LEFT JOIN public.roles USING (role_id); """, { - "tenant_id": tenantId, - "user_id": userId, + "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index c50ea41a5..230e09ddf 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -270,8 +270,7 @@ def _serialize_db_user_to_scim_user(db_user: dict[str, Any]) -> dict[str, Any]: "meta": { "resourceType": "User", "created": db_user["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), - # todo(jon): we currently don't keep track of this in the db - "lastModified": db_user["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), + "lastModified": db_user["updatedAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), "location": f"Users/{db_user['userId']}", }, "userName": db_user["email"], @@ -322,6 +321,7 @@ RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "max_items_per_page": 10, "schema_id": "urn:ietf:params:scim:schemas:core:2.0:User", "db_to_scim_serializer": _serialize_db_user_to_scim_user, + "count_total_resources": users.count_total_scim_users, "get_paginated_resources": users.get_scim_users_paginated, "get_unique_resource": users.get_scim_user_by_id, "parse_post_payload": _parse_scim_user_input, @@ -336,11 +336,13 @@ RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "max_items_per_page": 10, "schema_id": "urn:ietf:params:scim:schemas:core:2.0:Group", "db_to_scim_serializer": _serialize_db_group_to_scim_group, + "count_total_resources": scim_groups.count_total_resources, "get_paginated_resources": scim_groups.get_resources_paginated, "get_unique_resource": scim_groups.get_resource_by_id, "parse_post_payload": _parse_scim_group_input, "get_resource_by_unique_values": scim_groups.get_existing_resource_by_unique_values_from_all_resources, - "restore_resource": scim_groups.restore_resource, + # note(jon): we're not soft deleting groups, so we don't need this + "restore_resource": None, "create_resource": scim_groups.create_resource, "delete_resource": scim_groups.delete_resource, "parse_put_payload": _parse_scim_group_input, @@ -360,8 +362,8 @@ async def get_resources( tenant_id=Depends(auth_required), requested_start_index: int = Query(1, alias="startIndex"), requested_items_per_page: int | None = Query(None, alias="count"), - attributes: list[str] | None = Query(None), - excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"), + attributes: str | None = Query(None), + excluded_attributes: str | None = Query(None, alias="excludedAttributes"), ): resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] start_index = max(1, requested_start_index) @@ -369,12 +371,12 @@ async def get_resources( items_per_page = min( max(0, requested_items_per_page or max_items_per_page), max_items_per_page ) - # todo(jon): this might not be the most efficient thing to do. could be better to just do a count. - # but this is the fastest thing at the moment just to test that it's working - total_resources = resource_config["get_paginated_resources"](1, tenant_id) + total_resources = resource_config["count_total_resources"](tenant_id) db_resources = resource_config["get_paginated_resources"]( start_index, tenant_id, items_per_page ) + attributes = scim_helpers.convert_query_str_to_list(attributes) + excluded_attributes = scim_helpers.convert_query_str_to_list(excluded_attributes) scim_resources = [ _serialize_db_resource_to_scim_resource_with_attribute_awareness( db_resource, @@ -388,7 +390,7 @@ async def get_resources( return JSONResponse( status_code=200, content={ - "totalResults": len(total_resources), + "totalResults": total_resources, "startIndex": start_index, "itemsPerPage": len(scim_resources), "Resources": scim_resources, @@ -446,10 +448,9 @@ async def create_resource( if existing_db_resource and existing_db_resource.get("deletedAt") is None: return _uniqueness_error_response() if existing_db_resource and existing_db_resource.get("deletedAt") is not None: - # todo(jon): not a super elegant solution overwriting the existing db resource. - # maybe we should try something else. - existing_db_resource.update(db_payload) - db_resource = resource_config["restore_resource"](**existing_db_resource) + db_resource = resource_config["restore_resource"]( + tenant_id=tenant_id, **db_payload + ) else: db_resource = resource_config["create_resource"]( tenant_id=tenant_id, diff --git a/ee/api/routers/scim_groups.py b/ee/api/routers/scim_groups.py index d80bf818d..743d53512 100644 --- a/ee/api/routers/scim_groups.py +++ b/ee/api/routers/scim_groups.py @@ -3,6 +3,21 @@ from typing import Any from chalicelib.utils import helper, pg_client +def count_total_resources(tenant_id: int) -> int: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT COUNT(*) + FROM public.groups + WHERE groups.tenant_id = %(tenant_id)s + """, + {"tenant_id": tenant_id}, + ) + ) + return cur.fetchone()["count"] + + def get_resources_paginated( offset_one_indexed: int, tenant_id: int, limit: int | None = None ) -> list[dict[str, Any]]: @@ -66,11 +81,6 @@ def get_existing_resource_by_unique_values_from_all_resources( return None -def restore_resource(**kwargs: dict[str, Any]) -> dict[str, Any] | None: - # note(jon): we're not soft deleting groups, so we don't need this - return None - - def create_resource( name: str, tenant_id: int, **kwargs: dict[str, Any] ) -> dict[str, Any]: diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py index cda66b29c..477becc6c 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim_helpers.py @@ -2,6 +2,12 @@ from typing import Any from copy import deepcopy +def convert_query_str_to_list(query_str: str | None) -> list[str]: + if query_str is None: + return None + return query_str.split(",") + + def get_all_attribute_names(schema: dict[str, Any]) -> list[str]: result = [] @@ -102,14 +108,10 @@ def exclude_attributes( elif isinstance(value, list): new_list = [] for item in value: - if isinstance(item, dict): - new_item = exclude_attributes(item, subs) - new_list.append(new_item) - else: - new_list.append(item) + # note(jon): `item` should always be a dict here + new_item = exclude_attributes(item, subs) + new_list.append(new_item) new_resource[key] = new_list - else: - new_resource[key] = value else: # No exclusion for this key: copy safely if isinstance(value, (dict, list)): @@ -153,8 +155,4 @@ def filter_mutable_attributes( ) # If it matches, no change is needed (already set) - else: - # Unknown mutability: default to safe behavior (ignore) - continue - return valid_changes diff --git a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql index 96fb5a23b..521079621 100644 --- a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -142,6 +142,7 @@ CREATE TABLE public.users role user_role NOT NULL DEFAULT 'member', name text NOT NULL, created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + updated_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), deleted_at timestamp without time zone NULL DEFAULT NULL, api_key text UNIQUE DEFAULT generate_api_key(20) NOT NULL, jwt_iat timestamp without time zone NULL DEFAULT NULL, From 54b8ccb39c49d9a7c459386a8edbdf5d1dc3e974 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 23 Apr 2025 15:21:45 +0200 Subject: [PATCH 19/34] add patch endpoint for users --- ee/api/chalicelib/core/users.py | 43 +++++++++++++- .../fixtures/service_provider_config.json | 2 +- ee/api/routers/scim.py | 56 ++++++++++++++++++- 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 173819092..b1b014735 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -2,9 +2,11 @@ import json import logging import secrets from typing import Optional +from datetime import datetime from decouple import config from fastapi import BackgroundTasks, HTTPException +from psycopg2.extensions import AsIs from psycopg2.extras import Json from pydantic import BaseModel, model_validator from starlette import status @@ -482,6 +484,7 @@ def restore_scim_user( role_id = %(role_id)s, deleted_at = NULL, created_at = default, + updated_at = default, api_key = default, jwt_iat = NULL, weekly_report = default @@ -523,7 +526,8 @@ def update_scim_user( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role_id = %(role_id)s + role_id = %(role_id)s, + updated_at = default WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -548,6 +552,39 @@ def update_scim_user( return helper.dict_to_camel_case(cur.fetchone()) +def patch_scim_user( + user_id: int, + tenant_id: int, + **kwargs, +): + with pg_client.PostgresClient() as cur: + set_fragments = [] + kwargs["updated_at"] = datetime.now() + for k, v in kwargs.items(): + fragment = cur.mogrify( + "%s = %s", + (AsIs(k), v), + ).decode("utf-8") + set_fragments.append(fragment) + set_clause = ", ".join(set_fragments) + query = f""" + WITH u AS ( + UPDATE public.users + SET {set_clause} + WHERE + users.user_id = {user_id} + AND users.tenant_id = {tenant_id} + AND users.deleted_at IS NULL + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id);""" + cur.execute(query) + return helper.dict_to_camel_case(cur.fetchone()) + + def generate_new_api_key(user_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -1224,7 +1261,9 @@ def soft_delete_scim_user_by_id(user_id, tenant_id): cur.mogrify( """ UPDATE public.users - SET deleted_at = NULL + SET + deleted_at = NULL, + updated_at = default WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s diff --git a/ee/api/routers/fixtures/service_provider_config.json b/ee/api/routers/fixtures/service_provider_config.json index ebb37bec4..dbcbff942 100644 --- a/ee/api/routers/fixtures/service_provider_config.json +++ b/ee/api/routers/fixtures/service_provider_config.json @@ -3,7 +3,7 @@ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" ], "patch": { - "supported": false + "supported": true }, "bulk": { "supported": false, diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index 230e09ddf..a00822969 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -2,6 +2,7 @@ from copy import deepcopy import logging from typing import Any, Callable from enum import Enum +from datetime import datetime from decouple import config from fastapi import Depends, HTTPException, Header, Query, Response, Request @@ -263,6 +264,15 @@ def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, An return result +def _parse_user_patch_operations(data: dict[str, Any]) -> dict[str, Any]: + result = {} + operations = data["Operations"] + for operation in operations: + if operation["op"] == "replace" and "active" in operation["value"]: + result["deleted_at"] = None if operation["value"]["active"] is True else datetime.now() + return result + + def _serialize_db_user_to_scim_user(db_user: dict[str, Any]) -> dict[str, Any]: return { "id": str(db_user["userId"]), @@ -331,6 +341,8 @@ RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "delete_resource": users.soft_delete_scim_user_by_id, "parse_put_payload": _parse_scim_user_input, "update_resource": users.update_scim_user, + "parse_patch_operations": _parse_user_patch_operations, + "patch_resource": users.patch_scim_user, }, "Groups": { "max_items_per_page": 10, @@ -430,6 +442,7 @@ class PostResourceType(str, Enum): GROUPS = "Groups" + @public_app.post("/{resource_type}") async def create_resource( resource_type: PostResourceType, @@ -492,7 +505,7 @@ class PutResourceType(str, Enum): @public_app.put("/{resource_type}/{resource_id}") -async def update_resource( +async def put_resource( resource_type: PutResourceType, resource_id: str, r: Request, @@ -539,3 +552,44 @@ async def update_resource( return _uniqueness_error_response() except Exception as e: return _internal_server_error_response(str(e)) + + +class PatchResourceType(str, Enum): + USERS = "Users" + + +@public_app.patch("/{resource_type}/{resource_id}") +async def patch_resource( + resource_type: PatchResourceType, + resource_id: str, + r: Request, + tenant_id=Depends(auth_required), +): + resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] + db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) + if not db_resource: + return _not_found_error_response(resource_id) + current_scim_resource = ( + _serialize_db_resource_to_scim_resource_with_attribute_awareness( + db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + ) + ) + payload = await r.json() + parsed_payload = resource_config["parse_patch_operations"](payload) + # note(jon): we don't need to handle uniqueness contraints and etc. like in PUT + # because we are only covering the User resource and the field `active` + updated_db_resource = resource_config["patch_resource"]( + resource_id, + tenant_id, + **parsed_payload, + ) + updated_scim_resource = ( + _serialize_db_resource_to_scim_resource_with_attribute_awareness( + updated_db_resource, + resource_config["schema_id"], + resource_config["db_to_scim_serializer"], + ) + ) + return JSONResponse(status_code=200, content=updated_scim_resource) From 23d696b407e29a2e4c58fa2e0aacf8440727be76 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 24 Apr 2025 10:41:06 +0200 Subject: [PATCH 20/34] add patch endpoint for groups --- ee/api/chalicelib/core/users.py | 8 +- ee/api/routers/scim.py | 73 ++++++++-- ee/api/routers/scim_groups.py | 234 ++++++++++++++++-------------- ee/api/routers/scim_helpers.py | 243 ++++++++++++++++++++++++++++++++ 4 files changed, 436 insertions(+), 122 deletions(-) diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index b1b014735..2c63faab1 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -483,8 +483,8 @@ def restore_scim_user( internal_id = %(internal_id)s, role_id = %(role_id)s, deleted_at = NULL, - created_at = default, - updated_at = default, + created_at = now(), + updated_at = now(), api_key = default, jwt_iat = NULL, weekly_report = default @@ -527,7 +527,7 @@ def update_scim_user( name = %(name)s, internal_id = %(internal_id)s, role_id = %(role_id)s, - updated_at = default + updated_at = now() WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -582,7 +582,7 @@ def patch_scim_user( roles.name as role_name FROM u LEFT JOIN public.roles USING (role_id);""" cur.execute(query) - return helper.dict_to_camel_case(cur.fetchone()) + return helper.dict_to_camel_case(cur.fetchone()) def generate_new_api_key(user_id): diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py index a00822969..024fde933 100644 --- a/ee/api/routers/scim.py +++ b/ee/api/routers/scim.py @@ -254,22 +254,48 @@ def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, An if "userType" in data: role = roles.get_role_by_name(tenant_id, data["userType"]) role_id = role["roleId"] if role else None + name = data.get("name", {}).get("formatted") + if not name: + name = " ".join( + [ + x + for x in [ + data.get("name", {}).get("honorificPrefix"), + data.get("name", {}).get("givenName"), + data.get("name", {}).get("middleName"), + data.get("name", {}).get("familyName"), + data.get("name", {}).get("honorificSuffix"), + ] + if x + ] + ) result = { "email": data["userName"], "internal_id": data.get("externalId"), - "name": data.get("name", {}).get("formatted") or data.get("displayName"), + "name": name, "role_id": role_id, } result = {k: v for k, v in result.items() if v is not None} return result -def _parse_user_patch_operations(data: dict[str, Any]) -> dict[str, Any]: +def _parse_user_patch_payload(data: dict[str, Any], tenant_id: str) -> dict[str, Any]: result = {} - operations = data["Operations"] - for operation in operations: - if operation["op"] == "replace" and "active" in operation["value"]: - result["deleted_at"] = None if operation["value"]["active"] is True else datetime.now() + if "userType" in data: + role = roles.get_role_by_name(tenant_id, data["userType"]) + result["role_id"] = role["roleId"] if role else None + if "name" in data: + # note(jon): we're currently not handling the case where the client + # send patches of individual name components (e.g. name.middleName) + name = data.get("name", {}).get("formatted") + if name: + result["name"] = name + if "userName" in data: + result["email"] = data["userName"] + if "externalId" in data: + result["internal_id"] = data["externalId"] + if "active" in data: + result["deleted_at"] = None if data["active"] else datetime.now() return result @@ -326,6 +352,18 @@ def _parse_scim_group_input(data: dict[str, Any], tenant_id: int) -> dict[str, A } +def _parse_scim_group_patch(data: dict[str, Any], tenant_id: int) -> dict[str, Any]: + result = {} + if "displayName" in data: + result["name"] = data["displayName"] + if "externalId" in data: + result["external_id"] = data["externalId"] + if "members" in data: + members = data["members"] or [] + result["user_ids"] = [int(member["value"]) for member in members] + return result + + RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "Users": { "max_items_per_page": 10, @@ -341,7 +379,7 @@ RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "delete_resource": users.soft_delete_scim_user_by_id, "parse_put_payload": _parse_scim_user_input, "update_resource": users.update_scim_user, - "parse_patch_operations": _parse_user_patch_operations, + "parse_patch_payload": _parse_user_patch_payload, "patch_resource": users.patch_scim_user, }, "Groups": { @@ -359,6 +397,8 @@ RESOURCE_TYPE_TO_RESOURCE_CONFIG = { "delete_resource": scim_groups.delete_resource, "parse_put_payload": _parse_scim_group_input, "update_resource": scim_groups.update_resource, + "parse_patch_payload": _parse_scim_group_patch, + "patch_resource": scim_groups.patch_resource, }, } @@ -442,7 +482,6 @@ class PostResourceType(str, Enum): GROUPS = "Groups" - @public_app.post("/{resource_type}") async def create_resource( resource_type: PostResourceType, @@ -556,6 +595,7 @@ async def put_resource( class PatchResourceType(str, Enum): USERS = "Users" + GROUPS = "Groups" @public_app.patch("/{resource_type}/{resource_id}") @@ -577,13 +617,22 @@ async def patch_resource( ) ) payload = await r.json() - parsed_payload = resource_config["parse_patch_operations"](payload) - # note(jon): we don't need to handle uniqueness contraints and etc. like in PUT - # because we are only covering the User resource and the field `active` + _, changes = scim_helpers.apply_scim_patch( + payload["Operations"], + current_scim_resource, + SCHEMA_IDS_TO_SCHEMA_DETAILS[resource_config["schema_id"]], + ) + reformatted_scim_changes = { + k: new_value for k, (old_value, new_value) in changes.items() + } + db_changes = resource_config["parse_patch_payload"]( + reformatted_scim_changes, + tenant_id, + ) updated_db_resource = resource_config["patch_resource"]( resource_id, tenant_id, - **parsed_payload, + **db_changes, ) updated_scim_resource = ( _serialize_db_resource_to_scim_resource_with_attribute_awareness( diff --git a/ee/api/routers/scim_groups.py b/ee/api/routers/scim_groups.py index 743d53512..a9ef352b8 100644 --- a/ee/api/routers/scim_groups.py +++ b/ee/api/routers/scim_groups.py @@ -1,4 +1,6 @@ from typing import Any +from datetime import datetime +from psycopg2.extensions import AsIs from chalicelib.utils import helper, pg_client @@ -82,65 +84,57 @@ def get_existing_resource_by_unique_values_from_all_resources( def create_resource( - name: str, tenant_id: int, **kwargs: dict[str, Any] + name: str, + tenant_id: int, + user_ids: list[str] | None = None, + **kwargs: dict[str, Any], ) -> dict[str, Any]: 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_ids = user_ids or [] + user_id_fragments = [ + cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids + ] + user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" cur.execute( - cur.mogrify( - """ - WITH g AS( - INSERT INTO public.groups - (tenant_id, name, external_id) - VALUES (%(tenant_id)s, %(name)s, %(external_id)s) + f""" + WITH + g AS ( + INSERT INTO public.groups ({column_clause}) + VALUES ({value_clause}) + RETURNING * + ), + linked_users AS ( + UPDATE public.users + SET + group_id = g.group_id, + updated_at = now() + FROM g + WHERE + users.user_id = ANY({user_id_clause}) + AND users.deleted_at IS NULL + AND users.tenant_id = {tenant_id} RETURNING * ) - SELECT g.group_id - FROM g; - """, - { - "tenant_id": tenant_id, - "name": name, - "external_id": kwargs.get("external_id"), - }, - ) - ) - group_id = cur.fetchone()["group_id"] - user_ids = kwargs.get("user_ids", []) - if user_ids: - cur.execute( - cur.mogrify( - """ - UPDATE public.users - SET group_id = %s - WHERE users.user_id = ANY(%s) - """, - (group_id, user_ids), - ) - ) - cur.execute( - cur.mogrify( - """ - SELECT - groups.*, - users_data.array as users - FROM public.groups - LEFT JOIN LATERAL ( - SELECT json_agg(users) AS array - FROM public.users - WHERE users.group_id = %(group_id)s - ) users_data ON true - WHERE - groups.group_id = %(group_id)s - AND groups.tenant_id = %(tenant_id)s - LIMIT 1; - """, - { - "group_id": group_id, - "tenant_id": tenant_id, - "name": name, - "external_id": kwargs.get("external_id"), - }, - ) + SELECT + g.*, + COALESCE(users_data.array, '[]') as users + FROM g + LEFT JOIN LATERAL ( + SELECT json_agg(lu) AS array + FROM linked_users AS lu + ) users_data ON true + LIMIT 1; + """ ) return helper.dict_to_camel_case(cur.fetchone()) @@ -158,64 +152,92 @@ def delete_resource(group_id: int, tenant_id: int) -> None: ) -def update_resource( - group_id: int, tenant_id: int, name: str, **kwargs: dict[str, Any] +def _update_resource_sql( + group_id: int, + tenant_id: int, + user_ids: list[int] | 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) + user_ids = user_ids or [] + user_id_fragments = [ + cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids + ] + user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" cur.execute( - cur.mogrify( - """ - UPDATE public.users - SET group_id = null - WHERE users.group_id = %(group_id)s; - """, - {"group_id": group_id}, - ) - ) - user_ids = kwargs.get("user_ids", []) - if user_ids: - cur.execute( - cur.mogrify( - """ - UPDATE public.users - SET group_id = %s - WHERE users.user_id = ANY(%s); - """, - (group_id, user_ids), - ) - ) - cur.execute( - cur.mogrify( - """ - WITH g AS ( + f""" + WITH + g AS ( UPDATE public.groups - SET - tenant_id = %(tenant_id)s, - name = %(name)s, - external_id = %(external_id)s, - updated_at = default + SET {set_clause} WHERE - groups.group_id = %(group_id)s - AND groups.tenant_id = %(tenant_id)s + groups.group_id = {group_id} + AND groups.tenant_id = {tenant_id} + RETURNING * + ), + unlinked_users AS ( + UPDATE public.users + SET + group_id = null, + updated_at = now() + WHERE + users.group_id = {group_id} + AND users.user_id <> ALL({user_id_clause}) + AND users.deleted_at IS NULL + AND users.tenant_id = {tenant_id} + ), + linked_users AS ( + UPDATE public.users + SET + group_id = {group_id}, + updated_at = now() + WHERE + users.user_id = ANY({user_id_clause}) + AND users.deleted_at IS NULL + AND users.tenant_id = {tenant_id} RETURNING * ) - SELECT - g.*, - users_data.array as users - FROM g - LEFT JOIN LATERAL ( - SELECT json_agg(users) AS array - FROM public.users - WHERE users.group_id = g.group_id - ) users_data ON true - LIMIT 1; - """, - { - "group_id": group_id, - "tenant_id": tenant_id, - "name": name, - "external_id": kwargs.get("external_id"), - }, - ) + SELECT + g.*, + COALESCE(users_data.array, '[]') as users + FROM g + LEFT JOIN LATERAL ( + SELECT json_agg(lu) AS array + FROM linked_users AS lu + ) users_data ON true + LIMIT 1; + """ ) return helper.dict_to_camel_case(cur.fetchone()) + + +def update_resource( + group_id: int, + tenant_id: int, + name: str, + **kwargs: dict[str, Any], +) -> dict[str, Any]: + return _update_resource_sql( + group_id=group_id, + tenant_id=tenant_id, + name=name, + **kwargs, + ) + + +def patch_resource( + group_id: int, + tenant_id: int, + **kwargs: dict[str, Any], +): + return _update_resource_sql( + group_id=group_id, + tenant_id=tenant_id, + **kwargs, + ) diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim_helpers.py index 477becc6c..b57ec1356 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim_helpers.py @@ -1,5 +1,6 @@ from typing import Any from copy import deepcopy +import re def convert_query_str_to_list(query_str: str | None) -> list[str]: @@ -156,3 +157,245 @@ def filter_mutable_attributes( # 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 From a5ddf786d47ecd005d6a1343f33a24a31602d4b3 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 24 Apr 2025 10:44:57 +0200 Subject: [PATCH 21/34] add group to resource_type --- ee/api/routers/fixtures/resource_type.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/ee/api/routers/fixtures/resource_type.json b/ee/api/routers/fixtures/resource_type.json index 284266c4e..1c56daebe 100644 --- a/ee/api/routers/fixtures/resource_type.json +++ b/ee/api/routers/fixtures/resource_type.json @@ -15,5 +15,22 @@ "location": "ResourceType/User" }, "schemaExtensions": [] + }, + { + "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": [] } ] From eccb753c3c7805024a414899a8ac56e5d66029bc Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 24 Apr 2025 11:19:21 +0200 Subject: [PATCH 22/34] restructure files --- ee/api/app.py | 2 +- ee/api/chalicelib/core/users.py | 268 -------- ee/api/routers/scim.py | 644 ------------------ ee/api/routers/scim/__init__.py | 0 ee/api/routers/scim/api.py | 461 +++++++++++++ ee/api/routers/scim/constants.py | 33 + .../{ => scim}/fixtures/group_schema.json | 7 +- .../{ => scim}/fixtures/resource_type.json | 0 .../fixtures/resource_type_schema.json | 89 ++- .../{ => scim}/fixtures/schema_schema.json | 91 ++- .../fixtures/service_provider_config.json | 0 .../service_provider_config_schema.json | 5 +- .../{ => scim}/fixtures/user_schema.json | 5 +- .../{scim_groups.py => scim/groups.py} | 125 +++- .../{scim_helpers.py => scim/helpers.py} | 107 +-- ee/api/routers/scim/resource_config.py | 78 +++ ee/api/routers/scim/users.py | 395 +++++++++++ ee/api/routers/scim_constants.py | 22 - 18 files changed, 1290 insertions(+), 1042 deletions(-) delete mode 100644 ee/api/routers/scim.py create mode 100644 ee/api/routers/scim/__init__.py create mode 100644 ee/api/routers/scim/api.py create mode 100644 ee/api/routers/scim/constants.py rename ee/api/routers/{ => scim}/fixtures/group_schema.json (97%) rename ee/api/routers/{ => scim}/fixtures/resource_type.json (100%) rename ee/api/routers/{ => scim}/fixtures/resource_type_schema.json (50%) rename ee/api/routers/{ => scim}/fixtures/schema_schema.json (77%) rename ee/api/routers/{ => scim}/fixtures/service_provider_config.json (100%) rename ee/api/routers/{ => scim}/fixtures/service_provider_config_schema.json (97%) rename ee/api/routers/{ => scim}/fixtures/user_schema.json (99%) rename ee/api/routers/{scim_groups.py => scim/groups.py} (66%) rename ee/api/routers/{scim_helpers.py => scim/helpers.py} (81%) create mode 100644 ee/api/routers/scim/resource_config.py create mode 100644 ee/api/routers/scim/users.py delete mode 100644 ee/api/routers/scim_constants.py diff --git a/ee/api/app.py b/ee/api/app.py index a9d9c59cd..672285032 100644 --- a/ee/api/app.py +++ b/ee/api/app.py @@ -26,7 +26,7 @@ from routers.subs import v1_api_ee if config("ENABLE_SSO", cast=bool, default=True): from routers import saml - from routers import scim + from routers.scim import api as scim loglevel = config("LOGLEVEL", default=logging.WARNING) print(f">Loglevel set to: {loglevel}") diff --git a/ee/api/chalicelib/core/users.py b/ee/api/chalicelib/core/users.py index 2c63faab1..d80907bad 100644 --- a/ee/api/chalicelib/core/users.py +++ b/ee/api/chalicelib/core/users.py @@ -2,11 +2,9 @@ import json import logging import secrets from typing import Optional -from datetime import datetime from decouple import config from fastapi import BackgroundTasks, HTTPException -from psycopg2.extensions import AsIs from psycopg2.extras import Json from pydantic import BaseModel, model_validator from starlette import status @@ -352,239 +350,6 @@ def get(user_id, tenant_id): return helper.dict_to_camel_case(r) -def count_total_scim_users(tenant_id: int) -> int: - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT COUNT(*) - FROM public.users - WHERE - users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - """, - {"tenant_id": tenant_id}, - ) - ) - return cur.fetchone()["count"] - - -def get_scim_users_paginated(start_index, tenant_id, count=None): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT - users.*, - roles.name AS role_name - FROM public.users - LEFT JOIN public.roles USING (role_id) - WHERE - users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - LIMIT %(limit)s - OFFSET %(offset)s; - """, - {"offset": start_index - 1, "limit": count, "tenant_id": tenant_id}, - ) - ) - r = cur.fetchall() - return helper.list_to_camel_case(r) - - -def get_scim_user_by_id(user_id, tenant_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT - users.*, - roles.name AS role_name - FROM public.users - LEFT JOIN public.roles USING (role_id) - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - LIMIT 1; - """, - { - "user_id": user_id, - "tenant_id": tenant_id, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - -def create_scim_user( - email: str, - tenant_id: int, - name: str = "", - internal_id: str | None = None, - role_id: int | None = None, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - INSERT INTO public.users ( - tenant_id, - email, - name, - internal_id, - role_id - ) - VALUES ( - %(tenant_id)s, - %(email)s, - %(name)s, - %(internal_id)s, - %(role_id)s - ) - RETURNING * - ) - SELECT - u.*, - roles.name as role_name - FROM u LEFT JOIN public.roles USING (role_id); - """, - { - "tenant_id": tenant_id, - "email": email, - "name": name, - "internal_id": internal_id, - "role_id": role_id, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - -def restore_scim_user( - tenant_id: int, - email: str, - name: str = "", - internal_id: str | None = None, - role_id: int | None = None, - **kwargs, -): - with pg_client.PostgresClient() as 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, - role_id = %(role_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.name as role_name - FROM u LEFT JOIN public.roles USING (role_id); - """, - { - "tenant_id": tenant_id, - "email": email, - "name": name, - "internal_id": internal_id, - "role_id": role_id, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - -def update_scim_user( - user_id: int, - tenant_id: int, - email: str, - name: str = "", - internal_id: str | None = None, - role_id: int | None = None, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - UPDATE public.users - SET - email = %(email)s, - name = %(name)s, - internal_id = %(internal_id)s, - role_id = %(role_id)s, - updated_at = now() - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - RETURNING * - ) - SELECT - u.*, - roles.name as role_name - FROM u LEFT JOIN public.roles USING (role_id); - """, - { - "tenant_id": tenant_id, - "user_id": user_id, - "email": email, - "name": name, - "internal_id": internal_id, - "role_id": role_id, - }, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - -def patch_scim_user( - user_id: int, - tenant_id: int, - **kwargs, -): - with pg_client.PostgresClient() as cur: - set_fragments = [] - kwargs["updated_at"] = datetime.now() - for k, v in kwargs.items(): - fragment = cur.mogrify( - "%s = %s", - (AsIs(k), v), - ).decode("utf-8") - set_fragments.append(fragment) - set_clause = ", ".join(set_fragments) - query = f""" - WITH u AS ( - UPDATE public.users - SET {set_clause} - WHERE - users.user_id = {user_id} - AND users.tenant_id = {tenant_id} - AND users.deleted_at IS NULL - RETURNING * - ) - SELECT - u.*, - roles.name as role_name - FROM u LEFT JOIN public.roles USING (role_id);""" - cur.execute(query) - return helper.dict_to_camel_case(cur.fetchone()) - - def generate_new_api_key(user_id): with pg_client.PostgresClient() as cur: cur.execute( @@ -695,21 +460,6 @@ def edit_member( return {"data": user} -def get_existing_scim_user_by_unique_values_from_all_users(email: str, **kwargs): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT * - FROM public.users - WHERE users.email = %(email)s - """, - {"email": email}, - ) - ) - return helper.dict_to_camel_case(cur.fetchone()) - - def get_by_email_only(email): with pg_client.PostgresClient() as cur: cur.execute( @@ -1255,24 +1005,6 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id= return helper.dict_to_camel_case(cur.fetchone()) -def soft_delete_scim_user_by_id(user_id, tenant_id): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - UPDATE public.users - SET - deleted_at = NULL, - updated_at = default - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - """, - {"user_id": user_id, "tenant_id": tenant_id}, - ) - ) - - def __hard_delete_user(user_id): with pg_client.PostgresClient() as cur: query = cur.mogrify( diff --git a/ee/api/routers/scim.py b/ee/api/routers/scim.py deleted file mode 100644 index 024fde933..000000000 --- a/ee/api/routers/scim.py +++ /dev/null @@ -1,644 +0,0 @@ -from copy import deepcopy -import logging -from typing import Any, Callable -from enum import Enum -from datetime import datetime - -from decouple import config -from fastapi import Depends, HTTPException, Header, Query, Response, Request -from fastapi.responses import JSONResponse -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel -from psycopg2 import errors - -from chalicelib.core import users, roles, tenants -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 RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG -from routers import scim_helpers, scim_groups - - -logger = logging.getLogger(__name__) - -public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") - - -@public_app.post("/token") -async def post_token( - host: str = Header(..., alias="Host"), - form_data: OAuth2PasswordRequestForm = Depends(), -): - subdomain = host.split(".")[0] - - # Missing authentication part, to add - if form_data.username != config("SCIM_USER") or form_data.password != config( - "SCIM_PASSWORD" - ): - raise HTTPException(status_code=401, detail="Invalid credentials") - - tenant = tenants.get_by_name(subdomain) - access_token, refresh_token = create_tokens(tenant_id=tenant["tenantId"]) - - return { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "Bearer", - } - - -class RefreshRequest(BaseModel): - refresh_token: str - - -@public_app.post("/refresh") -async def post_refresh(r: RefreshRequest): - payload = verify_refresh_token(r.refresh_token) - new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) - return {"access_token": new_access_token, "token_type": "Bearer"} - - -RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS = { - resource_type_detail["id"]: resource_type_detail - for resource_type_detail in RESOURCE_TYPES -} - - -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", - }, - ) - - -@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], - ) - - -SCHEMA_IDS_TO_SCHEMA_DETAILS = { - schema_detail["id"]: schema_detail for schema_detail in SCHEMAS -} - - -@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, - ) - - -# 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) - - -def _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource: dict[str, Any], - schema_id: str, - serialize_db_resource_to_scim_resource: Callable[[dict[str, Any]], dict[str, Any]], - attributes: list[str] | None = None, - excluded_attributes: list[str] | None = None, -) -> dict[str, Any]: - schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id] - all_attributes = scim_helpers.get_all_attribute_names(schema) - attributes = attributes or all_attributes - always_returned_attributes = ( - scim_helpers.get_all_attribute_names_where_returned_is_always(schema) - ) - included_attributes = list(set(attributes).union(set(always_returned_attributes))) - excluded_attributes = excluded_attributes or [] - excluded_attributes = list( - set(excluded_attributes).difference(set(always_returned_attributes)) - ) - scim_resource = serialize_db_resource_to_scim_resource(db_resource) - scim_resource = scim_helpers.filter_attributes(scim_resource, included_attributes) - scim_resource = scim_helpers.exclude_attributes(scim_resource, excluded_attributes) - return scim_resource - - -def _parse_scim_user_input(data: dict[str, Any], tenant_id: str) -> dict[str, Any]: - role_id = None - if "userType" in data: - role = roles.get_role_by_name(tenant_id, data["userType"]) - role_id = role["roleId"] if role else None - name = data.get("name", {}).get("formatted") - if not name: - name = " ".join( - [ - x - for x in [ - data.get("name", {}).get("honorificPrefix"), - data.get("name", {}).get("givenName"), - data.get("name", {}).get("middleName"), - data.get("name", {}).get("familyName"), - data.get("name", {}).get("honorificSuffix"), - ] - if x - ] - ) - result = { - "email": data["userName"], - "internal_id": data.get("externalId"), - "name": name, - "role_id": role_id, - } - result = {k: v for k, v in result.items() if v is not None} - return result - - -def _parse_user_patch_payload(data: dict[str, Any], tenant_id: str) -> dict[str, Any]: - result = {} - if "userType" in data: - role = roles.get_role_by_name(tenant_id, data["userType"]) - result["role_id"] = role["roleId"] if role else None - if "name" in data: - # note(jon): we're currently not handling the case where the client - # send patches of individual name components (e.g. name.middleName) - name = data.get("name", {}).get("formatted") - if name: - result["name"] = name - if "userName" in data: - result["email"] = data["userName"] - if "externalId" in data: - result["internal_id"] = data["externalId"] - if "active" in data: - result["deleted_at"] = None if data["active"] else datetime.now() - return result - - -def _serialize_db_user_to_scim_user(db_user: dict[str, Any]) -> dict[str, Any]: - return { - "id": str(db_user["userId"]), - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "meta": { - "resourceType": "User", - "created": db_user["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), - "lastModified": db_user["updatedAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), - "location": f"Users/{db_user['userId']}", - }, - "userName": db_user["email"], - "externalId": db_user["internalId"], - "name": { - "formatted": db_user["name"], - }, - "displayName": db_user["name"] or db_user["email"], - "userType": db_user.get("roleName"), - "active": db_user["deletedAt"] is None, - } - - -def _serialize_db_group_to_scim_group(db_resource: dict[str, Any]) -> dict[str, Any]: - members = db_resource["users"] or [] - return { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "id": str(db_resource["groupId"]), - "externalId": db_resource["externalId"], - "meta": { - "resourceType": "Group", - "created": db_resource["createdAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), - "lastModified": db_resource["updatedAt"].strftime("%Y-%m-%dT%H:%M:%SZ"), - "location": f"Groups/{db_resource['groupId']}", - }, - "displayName": db_resource["name"], - "members": [ - { - "value": str(member["userId"]), - "$ref": f"Users/{member['userId']}", - "type": "User", - } - for member in members - ], - } - - -def _parse_scim_group_input(data: dict[str, Any], tenant_id: int) -> dict[str, Any]: - return { - "name": data["displayName"], - "external_id": data.get("externalId"), - "user_ids": [int(member["value"]) for member in data.get("members", [])], - } - - -def _parse_scim_group_patch(data: dict[str, Any], tenant_id: int) -> dict[str, Any]: - result = {} - if "displayName" in data: - result["name"] = data["displayName"] - if "externalId" in data: - result["external_id"] = data["externalId"] - if "members" in data: - members = data["members"] or [] - result["user_ids"] = [int(member["value"]) for member in members] - return result - - -RESOURCE_TYPE_TO_RESOURCE_CONFIG = { - "Users": { - "max_items_per_page": 10, - "schema_id": "urn:ietf:params:scim:schemas:core:2.0:User", - "db_to_scim_serializer": _serialize_db_user_to_scim_user, - "count_total_resources": users.count_total_scim_users, - "get_paginated_resources": users.get_scim_users_paginated, - "get_unique_resource": users.get_scim_user_by_id, - "parse_post_payload": _parse_scim_user_input, - "get_resource_by_unique_values": users.get_existing_scim_user_by_unique_values_from_all_users, - "restore_resource": users.restore_scim_user, - "create_resource": users.create_scim_user, - "delete_resource": users.soft_delete_scim_user_by_id, - "parse_put_payload": _parse_scim_user_input, - "update_resource": users.update_scim_user, - "parse_patch_payload": _parse_user_patch_payload, - "patch_resource": users.patch_scim_user, - }, - "Groups": { - "max_items_per_page": 10, - "schema_id": "urn:ietf:params:scim:schemas:core:2.0:Group", - "db_to_scim_serializer": _serialize_db_group_to_scim_group, - "count_total_resources": scim_groups.count_total_resources, - "get_paginated_resources": scim_groups.get_resources_paginated, - "get_unique_resource": scim_groups.get_resource_by_id, - "parse_post_payload": _parse_scim_group_input, - "get_resource_by_unique_values": scim_groups.get_existing_resource_by_unique_values_from_all_resources, - # note(jon): we're not soft deleting groups, so we don't need this - "restore_resource": None, - "create_resource": scim_groups.create_resource, - "delete_resource": scim_groups.delete_resource, - "parse_put_payload": _parse_scim_group_input, - "update_resource": scim_groups.update_resource, - "parse_patch_payload": _parse_scim_group_patch, - "patch_resource": scim_groups.patch_resource, - }, -} - - -class ListResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.get("/{resource_type}") -async def get_resources( - resource_type: ListResourceType, - tenant_id=Depends(auth_required), - requested_start_index: 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"), -): - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - start_index = max(1, requested_start_index) - max_items_per_page = resource_config["max_items_per_page"] - items_per_page = min( - max(0, requested_items_per_page or max_items_per_page), max_items_per_page - ) - total_resources = resource_config["count_total_resources"](tenant_id) - db_resources = resource_config["get_paginated_resources"]( - start_index, tenant_id, items_per_page - ) - attributes = scim_helpers.convert_query_str_to_list(attributes) - excluded_attributes = scim_helpers.convert_query_str_to_list(excluded_attributes) - scim_resources = [ - _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - attributes, - excluded_attributes, - ) - for db_resource in db_resources - ] - return JSONResponse( - status_code=200, - content={ - "totalResults": total_resources, - "startIndex": start_index, - "itemsPerPage": len(scim_resources), - "Resources": scim_resources, - }, - ) - - -class GetResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.get("/{resource_type}/{resource_id}") -async def get_resource( - resource_type: GetResourceType, - resource_id: int, - 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] - db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) - if not db_resource: - return _not_found_error_response(resource_id) - scim_resource = _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - attributes, - excluded_attributes, - ) - return JSONResponse(status_code=200, content=scim_resource) - - -class PostResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.post("/{resource_type}") -async def create_resource( - resource_type: PostResourceType, - r: Request, - tenant_id=Depends(auth_required), -): - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - scim_payload = await r.json() - try: - db_payload = resource_config["parse_post_payload"](scim_payload, tenant_id) - except KeyError: - return _invalid_value_error_response() - existing_db_resource = resource_config["get_resource_by_unique_values"]( - **db_payload - ) - if existing_db_resource and existing_db_resource.get("deletedAt") is None: - return _uniqueness_error_response() - if existing_db_resource and existing_db_resource.get("deletedAt") is not None: - db_resource = resource_config["restore_resource"]( - tenant_id=tenant_id, **db_payload - ) - else: - db_resource = resource_config["create_resource"]( - tenant_id=tenant_id, - **db_payload, - ) - scim_resource = _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - ) - response = JSONResponse(status_code=201, content=scim_resource) - response.headers["Location"] = scim_resource["meta"]["location"] - return response - - -class DeleteResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.delete("/{resource_type}/{resource_id}") -async def delete_resource( - resource_type: DeleteResourceType, - resource_id: str, - tenant_id=Depends(auth_required), -): - # note(jon): this can be a soft or a hard delete - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) - if not db_resource: - return _not_found_error_response(resource_id) - resource_config["delete_resource"](resource_id, tenant_id) - return Response(status_code=204, content="") - - -class PutResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.put("/{resource_type}/{resource_id}") -async def put_resource( - resource_type: PutResourceType, - resource_id: str, - r: Request, - tenant_id=Depends(auth_required), -): - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) - if not db_resource: - return _not_found_error_response(resource_id) - current_scim_resource = ( - _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - ) - ) - requested_scim_changes = await r.json() - schema = SCHEMA_IDS_TO_SCHEMA_DETAILS[resource_config["schema_id"]] - try: - valid_mutable_scim_changes = scim_helpers.filter_mutable_attributes( - schema, requested_scim_changes, current_scim_resource - ) - except ValueError: - return _mutability_error_response() - valid_mutable_db_changes = resource_config["parse_put_payload"]( - valid_mutable_scim_changes, - tenant_id, - ) - try: - updated_db_resource = resource_config["update_resource"]( - resource_id, - tenant_id, - **valid_mutable_db_changes, - ) - updated_scim_resource = ( - _serialize_db_resource_to_scim_resource_with_attribute_awareness( - updated_db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - ) - ) - return JSONResponse(status_code=200, content=updated_scim_resource) - except errors.UniqueViolation: - return _uniqueness_error_response() - except Exception as e: - return _internal_server_error_response(str(e)) - - -class PatchResourceType(str, Enum): - USERS = "Users" - GROUPS = "Groups" - - -@public_app.patch("/{resource_type}/{resource_id}") -async def patch_resource( - resource_type: PatchResourceType, - resource_id: str, - r: Request, - tenant_id=Depends(auth_required), -): - resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] - db_resource = resource_config["get_unique_resource"](resource_id, tenant_id) - if not db_resource: - return _not_found_error_response(resource_id) - current_scim_resource = ( - _serialize_db_resource_to_scim_resource_with_attribute_awareness( - db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - ) - ) - payload = await r.json() - _, changes = scim_helpers.apply_scim_patch( - payload["Operations"], - current_scim_resource, - SCHEMA_IDS_TO_SCHEMA_DETAILS[resource_config["schema_id"]], - ) - reformatted_scim_changes = { - k: new_value for k, (old_value, new_value) in changes.items() - } - db_changes = resource_config["parse_patch_payload"]( - reformatted_scim_changes, - tenant_id, - ) - updated_db_resource = resource_config["patch_resource"]( - resource_id, - tenant_id, - **db_changes, - ) - updated_scim_resource = ( - _serialize_db_resource_to_scim_resource_with_attribute_awareness( - updated_db_resource, - resource_config["schema_id"], - resource_config["db_to_scim_serializer"], - ) - ) - return JSONResponse(status_code=200, content=updated_scim_resource) diff --git a/ee/api/routers/scim/__init__.py b/ee/api/routers/scim/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py new file mode 100644 index 000000000..2607566cb --- /dev/null +++ b/ee/api/routers/scim/api.py @@ -0,0 +1,461 @@ +import logging +from copy import deepcopy +from enum import Enum + +from decouple import config +from fastapi import Depends, HTTPException, Header, Query, Response, Request +from fastapi.responses import JSONResponse +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel +from psycopg2 import errors + +from chalicelib.core import roles, tenants +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, +) +from routers.scim import helpers, groups, users +from routers.scim.resource_config import ResourceConfig +from routers.scim import resource_config as api_helper + + +logger = logging.getLogger(__name__) + +public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") + + +@public_app.post("/token") +async def post_token( + host: str = Header(..., alias="Host"), + form_data: OAuth2PasswordRequestForm = Depends(), +): + subdomain = host.split(".")[0] + + # Missing authentication part, to add + if form_data.username != config("SCIM_USER") or form_data.password != config( + "SCIM_PASSWORD" + ): + raise HTTPException(status_code=401, detail="Invalid credentials") + + tenant = tenants.get_by_name(subdomain) + access_token, refresh_token = create_tokens(tenant_id=tenant["tenantId"]) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "Bearer", + } + + +class RefreshRequest(BaseModel): + refresh_token: str + + +@public_app.post("/refresh") +async def post_refresh(r: RefreshRequest): + payload = verify_refresh_token(r.refresh_token) + new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) + return {"access_token": new_access_token, "token_type": "Bearer"} + + +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( + schema_id="urn:ietf:params:scim:schemas:core:2.0: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, +) +group_config = ResourceConfig( + schema_id="urn:ietf:params:scim:schemas:core:2.0: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=groups.get_provider_resource_from_unique_fields, + 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, +) + +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"), +): + config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type] + total_resources = config.get_active_resource_count(tenant_id) + 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) + 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: 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: 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/constants.py b/ee/api/routers/scim/constants.py new file mode 100644 index 000000000..74dd19705 --- /dev/null +++ b/ee/api/routers/scim/constants.py @@ -0,0 +1,33 @@ +# 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")), + ], + 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/fixtures/group_schema.json b/ee/api/routers/scim/fixtures/group_schema.json similarity index 97% rename from ee/api/routers/fixtures/group_schema.json rename to ee/api/routers/scim/fixtures/group_schema.json index 1a56a3cdf..ddb030b92 100644 --- a/ee/api/routers/fixtures/group_schema.json +++ b/ee/api/routers/scim/fixtures/group_schema.json @@ -2,6 +2,7 @@ "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", @@ -97,7 +98,7 @@ "uniqueness": "none" } ] - }, + }, { "name": "displayName", "type": "string", @@ -159,7 +160,7 @@ "meta": { "resourceType": "Schema", "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Group", - "created": "2025-04-17T15:48:00", - "lastModified": "2025-04-17T15:48:00" + "created": "2025-04-17T15:48:00Z", + "lastModified": "2025-04-17T15:48:00Z" } } diff --git a/ee/api/routers/fixtures/resource_type.json b/ee/api/routers/scim/fixtures/resource_type.json similarity index 100% rename from ee/api/routers/fixtures/resource_type.json rename to ee/api/routers/scim/fixtures/resource_type.json diff --git a/ee/api/routers/fixtures/resource_type_schema.json b/ee/api/routers/scim/fixtures/resource_type_schema.json similarity index 50% rename from ee/api/routers/fixtures/resource_type_schema.json rename to ee/api/routers/scim/fixtures/resource_type_schema.json index 040fd071c..ac53aefea 100644 --- a/ee/api/routers/fixtures/resource_type_schema.json +++ b/ee/api/routers/scim/fixtures/resource_type_schema.json @@ -2,7 +2,19 @@ "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", @@ -14,6 +26,79 @@ "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", @@ -96,7 +181,7 @@ "meta": { "resourceType": "Schema", "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:ResourceType", - "created": "2025-04-17T15:48:00", - "lastModified": "2025-04-17T15:48:00" + "created": "2025-04-17T15:48:00Z", + "lastModified": "2025-04-17T15:48:00Z" } } diff --git a/ee/api/routers/fixtures/schema_schema.json b/ee/api/routers/scim/fixtures/schema_schema.json similarity index 77% rename from ee/api/routers/fixtures/schema_schema.json rename to ee/api/routers/scim/fixtures/schema_schema.json index 4099700f3..231cbde54 100644 --- a/ee/api/routers/fixtures/schema_schema.json +++ b/ee/api/routers/scim/fixtures/schema_schema.json @@ -2,7 +2,19 @@ "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", @@ -14,6 +26,79 @@ "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", @@ -172,7 +257,7 @@ "required": false, "mutability": "readOnly", "returned": "default", - "subAttributes": [ + "subAttribtes": [ { "name": "name", "type": "string", @@ -298,7 +383,7 @@ "meta": { "resourceType": "Schema", "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:Schema", - "created": "2025-04-17T15:48:00", - "lastModified": "2025-04-17T15:48:00" + "created": "2025-04-17T15:48:00Z", + "lastModified": "2025-04-17T15:48:00Z" } } diff --git a/ee/api/routers/fixtures/service_provider_config.json b/ee/api/routers/scim/fixtures/service_provider_config.json similarity index 100% rename from ee/api/routers/fixtures/service_provider_config.json rename to ee/api/routers/scim/fixtures/service_provider_config.json diff --git a/ee/api/routers/fixtures/service_provider_config_schema.json b/ee/api/routers/scim/fixtures/service_provider_config_schema.json similarity index 97% rename from ee/api/routers/fixtures/service_provider_config_schema.json rename to ee/api/routers/scim/fixtures/service_provider_config_schema.json index c17ca5e18..2a90e8de4 100644 --- a/ee/api/routers/fixtures/service_provider_config_schema.json +++ b/ee/api/routers/scim/fixtures/service_provider_config_schema.json @@ -2,6 +2,7 @@ "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", @@ -206,7 +207,7 @@ "meta": { "resourceType": "Schema", "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", - "created": "2025-04-17T15:48:00", - "lastModified": "2025-04-17T15:48:00" + "created": "2025-04-17T15:48:00Z", + "lastModified": "2025-04-17T15:48:00Z" } } diff --git a/ee/api/routers/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json similarity index 99% rename from ee/api/routers/fixtures/user_schema.json rename to ee/api/routers/scim/fixtures/user_schema.json index 736694c91..c80a084c5 100644 --- a/ee/api/routers/fixtures/user_schema.json +++ b/ee/api/routers/scim/fixtures/user_schema.json @@ -2,6 +2,7 @@ "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", @@ -380,7 +381,7 @@ "meta": { "resourceType": "Schema", "location": "/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", - "created": "2025-04-17T15:48:00", - "lastModified": "2025-04-17T15:48:00" + "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 similarity index 66% rename from ee/api/routers/scim_groups.py rename to ee/api/routers/scim/groups.py index a9ef352b8..433dbc75b 100644 --- a/ee/api/routers/scim_groups.py +++ b/ee/api/routers/scim/groups.py @@ -2,10 +2,59 @@ from typing import Any from datetime import datetime from psycopg2.extensions import AsIs -from chalicelib.utils import helper, pg_client +from chalicelib.utils import pg_client +from routers.scim.resource_config import ( + ProviderResource, + ClientResource, + ResourceId, + ClientInput, + ProviderInput, +) -def count_total_resources(tenant_id: int) -> int: +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 "externalId" in client_input: + result["external_id"] = client_input["externalId"] + if "members" in client_input: + members = client_input["members"] or [] + result["user_ids"] = [int(member["value"]) for member in members] + return result + + +def convert_provider_resource_to_client_resource( + provider_resource: ProviderResource, +) -> ClientResource: + members = provider_resource["users"] or [] + return { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": str(provider_resource["group_id"]), + "externalId": provider_resource["external_id"], + "meta": { + "resourceType": "Group", + "created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"), + "lastModified": provider_resource["updated_at"].strftime( + "%Y-%m-%dT%H:%M:%SZ" + ), + "location": f"Groups/{provider_resource['group_id']}", + }, + "displayName": provider_resource["name"], + "members": [ + { + "value": str(member["user_id"]), + "$ref": f"Users/{member['user_id']}", + "type": "User", + } + for member in members + ], + } + + +def get_active_resource_count(tenant_id: int) -> int: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -20,9 +69,9 @@ def count_total_resources(tenant_id: int) -> int: return cur.fetchone()["count"] -def get_resources_paginated( - offset_one_indexed: int, tenant_id: int, limit: int | None = None -) -> list[dict[str, Any]]: +def get_provider_resource_chunk( + offset: int, tenant_id: int, limit: int +) -> list[ProviderResource]: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -41,16 +90,18 @@ def get_resources_paginated( OFFSET %(offset)s; """, { - "offset": offset_one_indexed - 1, + "offset": offset, "limit": limit, "tenant_id": tenant_id, }, ) ) - return helper.list_to_camel_case(cur.fetchall()) + return cur.fetchall() -def get_resource_by_id(group_id: int, tenant_id: int) -> dict[str, Any]: +def get_provider_resource( + resource_id: ResourceId, tenant_id: int +) -> ProviderResource | None: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -69,26 +120,50 @@ def get_resource_by_id(group_id: int, tenant_id: int) -> dict[str, Any]: AND groups.group_id = %(group_id)s LIMIT 1; """, - {"group_id": group_id, "tenant_id": tenant_id}, + {"group_id": resource_id, "tenant_id": tenant_id}, ) ) - return helper.dict_to_camel_case(cur.fetchone()) + return cur.fetchone() -def get_existing_resource_by_unique_values_from_all_resources( - **kwargs, -) -> dict[str, Any] | None: +def get_provider_resource_from_unique_fields( + **kwargs: dict[str, Any], +) -> ProviderResource | None: # note(jon): we do not really use this for groups as we don't have unique values outside # of the primary key return None -def create_resource( +def convert_client_resource_creation_input_to_provider_resource_creation_input( + tenant_id: int, client_input: ClientInput +) -> ProviderInput: + return { + "name": client_input["displayName"], + "external_id": client_input.get("externalId"), + "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"], + "external_id": client_input.get("externalId"), + "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], -) -> dict[str, Any]: +) -> ProviderResource: with pg_client.PostgresClient() as cur: kwargs["name"] = name kwargs["tenant_id"] = tenant_id @@ -136,10 +211,10 @@ def create_resource( LIMIT 1; """ ) - return helper.dict_to_camel_case(cur.fetchone()) + return cur.fetchone() -def delete_resource(group_id: int, tenant_id: int) -> None: +def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: with pg_client.PostgresClient() as cur: cur.execute( cur.mogrify( @@ -148,7 +223,7 @@ def delete_resource(group_id: int, tenant_id: int) -> None: WHERE groups.group_id = %(group_id)s AND groups.tenant_id = %(tenant_id)s; """ ), - {"tenant_id": tenant_id, "group_id": group_id}, + {"tenant_id": tenant_id, "group_id": resource_id}, ) @@ -214,30 +289,30 @@ def _update_resource_sql( LIMIT 1; """ ) - return helper.dict_to_camel_case(cur.fetchone()) + return cur.fetchone() -def update_resource( - group_id: int, +def rewrite_provider_resource( + resource_id: int, tenant_id: int, name: str, **kwargs: dict[str, Any], ) -> dict[str, Any]: return _update_resource_sql( - group_id=group_id, + group_id=resource_id, tenant_id=tenant_id, name=name, **kwargs, ) -def patch_resource( - group_id: int, +def update_provider_resource( + resource_id: int, tenant_id: int, **kwargs: dict[str, Any], ): return _update_resource_sql( - group_id=group_id, + group_id=resource_id, tenant_id=tenant_id, **kwargs, ) diff --git a/ee/api/routers/scim_helpers.py b/ee/api/routers/scim/helpers.py similarity index 81% rename from ee/api/routers/scim_helpers.py rename to ee/api/routers/scim/helpers.py index b57ec1356..a94806d14 100644 --- a/ee/api/routers/scim_helpers.py +++ b/ee/api/routers/scim/helpers.py @@ -45,83 +45,50 @@ def get_all_attribute_names_where_returned_is_always( def filter_attributes( - resource: dict[str, Any], include_list: list[str] + obj: dict[str, Any], + attributes_query_str: str | None, + excluded_attributes_query_str: str | None, + schema: dict[str, Any], ) -> dict[str, Any]: - result = {} + all_attributes = get_all_attribute_names(schema) + always_returned_attributes = get_all_attribute_names_where_returned_is_always( + schema + ) + 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) - # Group include paths by top-level key - includes_by_key = {} - for path in include_list: - parts = path.split(".", 1) - key = parts[0] - rest = parts[1] if len(parts) == 2 else None - includes_by_key.setdefault(key, []).append(rest) + include_tree = {} + for path in include_paths: + parts = path.split(".") + node = include_tree + for part in parts: + node = node.setdefault(part, {}) - for key, subpaths in includes_by_key.items(): - if key not in resource: - continue + 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 - value = resource[key] - if all(p is None for p in subpaths): - result[key] = value - else: - nested_paths = [p for p in subpaths if p is not None] - if isinstance(value, dict): - filtered = filter_attributes(value, nested_paths) - if filtered: - result[key] = filtered - elif isinstance(value, list): - new_list = [] - for item in value: - if isinstance(item, dict): - filtered_item = filter_attributes(item, nested_paths) - if filtered_item: - new_list.append(filtered_item) - if new_list: - result[key] = new_list + result = _recurse(obj, include_tree) return result -def exclude_attributes( - resource: dict[str, Any], exclude_list: list[str] -) -> dict[str, Any]: - exclude_map = {} - for attr in exclude_list: - parts = attr.split(".", 1) - key = parts[0] - # rest is empty string for top-level exclusion - rest = parts[1] if len(parts) == 2 else "" - exclude_map.setdefault(key, []).append(rest) - - new_resource = {} - for key, value in resource.items(): - if key in exclude_map: - subs = exclude_map[key] - # If any attr has no rest, exclude entire key - if "" in subs: - continue - # Exclude nested attributes - if isinstance(value, dict): - new_sub = exclude_attributes(value, subs) - if not new_sub: - continue - new_resource[key] = new_sub - elif isinstance(value, list): - new_list = [] - for item in value: - # note(jon): `item` should always be a dict here - new_item = exclude_attributes(item, subs) - new_list.append(new_item) - new_resource[key] = new_list - else: - # No exclusion for this key: copy safely - if isinstance(value, (dict, list)): - new_resource[key] = deepcopy(value) - else: - new_resource[key] = value - return new_resource - - def filter_mutable_attributes( schema: dict[str, Any], requested_changes: dict[str, Any], diff --git a/ee/api/routers/scim/resource_config.py b/ee/api/routers/scim/resource_config.py new file mode 100644 index 000000000..afae5eed6 --- /dev/null +++ b/ee/api/routers/scim/resource_config.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +from typing import Any, Callable + +from routers.scim.constants import ( + SCHEMA_IDS_TO_SCHEMA_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: + schema_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] + + +def get_schema(config: ResourceConfig) -> Schema: + return SCHEMA_IDS_TO_SCHEMA_DETAILS[config.schema_id] + + +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 new file mode 100644 index 000000000..98e9b3ded --- /dev/null +++ b/ee/api/routers/scim/users.py @@ -0,0 +1,395 @@ +from typing import Any +from datetime import datetime +from psycopg2.extensions import AsIs + +from chalicelib.utils import pg_client +from chalicelib.core import roles +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 "userType" in client_input: + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + result["role_id"] = role["roleId"] if role else None + 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() + return result + + +def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( + tenant_id: int, client_input: ClientInput +) -> ProviderInput: + role_id = None + if "userType" in client_input: + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + role_id = role["roleId"] if role else None + name = client_input.get("name", {}).get("formatted") + if not name: + 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 + ] + ) + result = { + "email": client_input["userName"], + "internal_id": client_input.get("externalId"), + "name": name, + "role_id": role_id, + } + 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: + role_id = None + if "userType" in client_input: + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + role_id = role["roleId"] if role else None + name = client_input.get("name", {}).get("formatted") + if not name: + 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 + ] + ) + result = { + "email": client_input["userName"], + "internal_id": client_input.get("externalId"), + "name": name, + "role_id": role_id, + } + result = {k: v for k, v in result.items() if v is not None} + return result + + +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 = default + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + """, + {"user_id": resource_id, "tenant_id": tenant_id}, + ) + ) + + +def convert_provider_resource_to_client_resource( + provider_resource: ProviderResource, +) -> ClientResource: + return { + "id": str(provider_resource["user_id"]), + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "meta": { + "resourceType": "User", + "created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"), + "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"], + "name": { + "formatted": provider_resource["name"], + }, + "displayName": provider_resource["name"] or provider_resource["email"], + "userType": provider_resource.get("role_name"), + "active": provider_resource["deleted_at"] is None, + } + + +def get_active_resource_count(tenant_id: int) -> int: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT COUNT(*) + FROM public.users + WHERE + users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + """, + {"tenant_id": tenant_id}, + ) + ) + return cur.fetchone()["count"] + + +def get_provider_resource_chunk( + offset: int, tenant_id: int, limit: int +) -> list[ProviderResource]: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT + users.*, + roles.name AS role_name + FROM public.users + LEFT JOIN public.roles USING (role_id) + WHERE + users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + LIMIT %(limit)s + OFFSET %(offset)s; + """, + {"offset": offset, "limit": limit, "tenant_id": tenant_id}, + ) + ) + return cur.fetchall() + + +def get_provider_resource( + resource_id: ResourceId, tenant_id: int +) -> ProviderResource | None: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT + users.*, + roles.name AS role_name + FROM public.users + LEFT JOIN public.roles USING (role_id) + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + LIMIT 1; + """, + { + "user_id": resource_id, + "tenant_id": tenant_id, + }, + ) + ) + return cur.fetchone() + + +def create_provider_resource( + email: str, + tenant_id: int, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, +) -> ProviderResource: + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH u AS ( + INSERT INTO public.users ( + tenant_id, + email, + name, + internal_id, + role_id + ) + VALUES ( + %(tenant_id)s, + %(email)s, + %(name)s, + %(internal_id)s, + %(role_id)s + ) + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return cur.fetchone() + + +def restore_provider_resource( + tenant_id: int, + email: str, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, + **kwargs: dict[str, Any], +) -> ProviderResource: + with pg_client.PostgresClient() as 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, + role_id = %(role_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.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return cur.fetchone() + + +def rewrite_provider_resource( + resource_id: int, + tenant_id: int, + email: str, + name: str = "", + internal_id: str | None = None, + role_id: int | None = None, +): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + WITH u AS ( + UPDATE public.users + SET + email = %(email)s, + name = %(name)s, + internal_id = %(internal_id)s, + role_id = %(role_id)s, + updated_at = now() + WHERE + users.user_id = %(user_id)s + AND users.tenant_id = %(tenant_id)s + AND users.deleted_at IS NULL + RETURNING * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id); + """, + { + "tenant_id": tenant_id, + "user_id": resource_id, + "email": email, + "name": name, + "internal_id": internal_id, + "role_id": role_id, + }, + ) + ) + return cur.fetchone() + + +def update_provider_resource( + resource_id: int, + tenant_id: int, + **kwargs, +): + with pg_client.PostgresClient() as cur: + set_fragments = [] + kwargs["updated_at"] = datetime.now() + for k, v in kwargs.items(): + fragment = cur.mogrify( + "%s = %s", + (AsIs(k), v), + ).decode("utf-8") + set_fragments.append(fragment) + set_clause = ", ".join(set_fragments) + query = f""" + WITH u AS ( + 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 * + ) + SELECT + u.*, + roles.name as role_name + FROM u LEFT JOIN public.roles USING (role_id);""" + cur.execute(query) + return cur.fetchone() diff --git a/ee/api/routers/scim_constants.py b/ee/api/routers/scim_constants.py deleted file mode 100644 index 4b52b1a8e..000000000 --- a/ee/api/routers/scim_constants.py +++ /dev/null @@ -1,22 +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/fixtures/service_provider_config_schema.json", "r")), - json.load(open("routers/fixtures/resource_type_schema.json", "r")), - json.load(open("routers/fixtures/schema_schema.json", "r")), - json.load(open("routers/fixtures/user_schema.json", "r")), - json.load(open("routers/fixtures/group_schema.json", "r")), - ], - key=lambda x: x["id"], -) - -SERVICE_PROVIDER_CONFIG = json.load( - open("routers/fixtures/service_provider_config.json", "r") -) - -RESOURCE_TYPES = sorted( - json.load(open("routers/fixtures/resource_type.json", "r")), - key=lambda x: x["id"], -) From 7af24f59c3ecb100557960a8551fd0827cff32d2 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Mon, 28 Apr 2025 11:14:32 +0200 Subject: [PATCH 23/34] add many-to-many relationship between groups and users --- ee/api/routers/scim/groups.py | 101 +++++++++--------- ee/api/routers/scim/users.py | 84 +++++++++++++-- .../db/init_dbs/postgresql/init_schema.sql | 11 +- 3 files changed, 133 insertions(+), 63 deletions(-) diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index 433dbc75b..774e0815b 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -78,13 +78,16 @@ def get_provider_resource_chunk( """ SELECT groups.*, - users_data.array as users + COALESCE( + ( + SELECT json_agg(users) + FROM public.user_group + JOIN public.users USING (user_id) + WHERE user_group.group_id = groups.group_id + ), + '[]' + ) AS users FROM public.groups - LEFT JOIN LATERAL ( - SELECT json_agg(users) AS array - FROM public.users - WHERE users.group_id = groups.group_id - ) users_data ON true WHERE groups.tenant_id = %(tenant_id)s LIMIT %(limit)s OFFSET %(offset)s; @@ -108,13 +111,16 @@ def get_provider_resource( """ SELECT groups.*, - users_data.array as users + COALESCE( + ( + SELECT json_agg(users) + FROM public.user_group + JOIN public.users USING (user_id) + WHERE user_group.group_id = groups.group_id + ), + '[]' + ) AS users FROM public.groups - LEFT JOIN LATERAL ( - SELECT json_agg(users) AS array - FROM public.users - WHERE users.group_id = groups.group_id - ) users_data ON true WHERE groups.tenant_id = %(tenant_id)s AND groups.group_id = %(group_id)s @@ -188,26 +194,24 @@ def create_provider_resource( VALUES ({value_clause}) RETURNING * ), - linked_users AS ( - UPDATE public.users - SET - group_id = g.group_id, - updated_at = now() + ugs AS ( + INSERT INTO public.user_group (user_id, group_id) + SELECT users.user_id, g.group_id FROM g - WHERE - users.user_id = ANY({user_id_clause}) - AND users.deleted_at IS NULL - AND users.tenant_id = {tenant_id} + JOIN public.users ON users.user_id = ANY({user_id_clause}) RETURNING * ) SELECT g.*, - COALESCE(users_data.array, '[]') as users + COALESCE( + ( + SELECT json_agg(users) + FROM ugs + JOIN public.users USING (user_id) + ), + '[]' + ) AS users FROM g - LEFT JOIN LATERAL ( - SELECT json_agg(lu) AS array - FROM linked_users AS lu - ) users_data ON true LIMIT 1; """ ) @@ -245,6 +249,12 @@ def _update_resource_sql( cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids ] user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" + cur.execute( + f""" + DELETE FROM public.user_group + WHERE user_group.group_id = {group_id} + """ + ) cur.execute( f""" WITH @@ -256,36 +266,25 @@ def _update_resource_sql( AND groups.tenant_id = {tenant_id} RETURNING * ), - unlinked_users AS ( - UPDATE public.users - SET - group_id = null, - updated_at = now() - WHERE - users.group_id = {group_id} - AND users.user_id <> ALL({user_id_clause}) - AND users.deleted_at IS NULL - AND users.tenant_id = {tenant_id} - ), - linked_users AS ( - UPDATE public.users - SET - group_id = {group_id}, - updated_at = now() - WHERE - users.user_id = ANY({user_id_clause}) - AND users.deleted_at IS NULL - AND users.tenant_id = {tenant_id} + linked_user_group AS ( + INSERT INTO public.user_group (user_id, group_id) + SELECT users.user_id, g.group_id + FROM g + JOIN public.users ON users.user_id = ANY({user_id_clause}) + WHERE users.deleted_at IS NULL AND users.tenant_id = {tenant_id} RETURNING * ) SELECT g.*, - COALESCE(users_data.array, '[]') as users + COALESCE( + ( + SELECT json_agg(users) + FROM linked_user_group + JOIN public.users USING (user_id) + ), + '[]' + ) AS users FROM g - LEFT JOIN LATERAL ( - SELECT json_agg(lu) AS array - FROM linked_users AS lu - ) users_data ON true LIMIT 1; """ ) diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 98e9b3ded..2444f3f07 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -156,6 +156,13 @@ def convert_provider_resource_to_client_resource( "displayName": provider_resource["name"] or provider_resource["email"], "userType": provider_resource.get("role_name"), "active": provider_resource["deleted_at"] is None, + "groups": [ + { + "value": str(group["group_id"]), + "$ref": f"Groups/{group['group_id']}", + } + for group in provider_resource["groups"] + ], } @@ -185,7 +192,16 @@ def get_provider_resource_chunk( """ SELECT users.*, - roles.name AS role_name + roles.name AS role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = users.user_id + ), + '[]' + ) AS groups FROM public.users LEFT JOIN public.roles USING (role_id) WHERE @@ -209,7 +225,16 @@ def get_provider_resource( """ SELECT users.*, - roles.name AS role_name + roles.name AS role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = users.user_id + ), + '[]' + ) AS groups FROM public.users LEFT JOIN public.roles USING (role_id) WHERE @@ -257,8 +282,18 @@ def create_provider_resource( ) SELECT u.*, - roles.name as role_name - FROM u LEFT JOIN public.roles USING (role_id); + roles.name as role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = u.user_id + ), + '[]' + ) AS groups + FROM u + LEFT JOIN public.roles USING (role_id) """, { "tenant_id": tenant_id, @@ -303,7 +338,16 @@ def restore_provider_resource( ) SELECT u.*, - roles.name as role_name + roles.name as role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = u.user_id + ), + '[]' + ) AS groups FROM u LEFT JOIN public.roles USING (role_id); """, { @@ -346,7 +390,16 @@ def rewrite_provider_resource( ) SELECT u.*, - roles.name as role_name + roles.name as role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = u.user_id + ), + '[]' + ) AS groups FROM u LEFT JOIN public.roles USING (role_id); """, { @@ -377,7 +430,8 @@ def update_provider_resource( ).decode("utf-8") set_fragments.append(fragment) set_clause = ", ".join(set_fragments) - query = f""" + cur.execute( + f""" WITH u AS ( UPDATE public.users SET {set_clause} @@ -389,7 +443,17 @@ def update_provider_resource( ) SELECT u.*, - roles.name as role_name - FROM u LEFT JOIN public.roles USING (role_id);""" - cur.execute(query) + roles.name as role_name, + COALESCE( + ( + SELECT json_agg(groups) + FROM public.user_group + JOIN public.groups USING (group_id) + WHERE user_group.user_id = u.user_id + ), + '[]' + ) AS groups + FROM u LEFT JOIN public.roles USING (role_id) + """ + ) return cur.fetchone() diff --git a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql index 521079621..a62a34cbb 100644 --- a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -162,12 +162,19 @@ CREATE TABLE public.users origin text NULL DEFAULT NULL, role_id integer REFERENCES public.roles (role_id) ON DELETE SET NULL, internal_id text NULL DEFAULT NULL, - service_account bool NOT NULL DEFAULT FALSE, - group_id integer REFERENCES public.groups (group_id) ON DELETE SET NULL + service_account bool NOT NULL DEFAULT FALSE ); CREATE INDEX users_tenant_id_deleted_at_N_idx ON public.users (tenant_id) WHERE deleted_at ISNULL; CREATE INDEX users_name_gin_idx ON public.users USING GIN (name gin_trgm_ops); +CREATE TABLE public.user_group +( + user_group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id integer REFERENCES public.users (user_id) ON DELETE CASCADE, + group_id integer REFERENCES public.groups (group_id) ON DELETE CASCADE, + UNIQUE (user_id, group_id) +); + CREATE TABLE public.basic_authentication ( user_id integer NOT NULL REFERENCES public.users (user_id) ON DELETE CASCADE, From c37b1e79d40233761f8dccffff51a88945726142 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Mon, 28 Apr 2025 14:20:09 +0200 Subject: [PATCH 24/34] update mapping for userType and role --- ee/api/routers/scim/api.py | 13 +--- ee/api/routers/scim/fixtures/user_schema.json | 3 +- ee/api/routers/scim/users.py | 60 ++++++++----------- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index 2607566cb..e9914c251 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -1,5 +1,4 @@ import logging -from copy import deepcopy from enum import Enum from decouple import config @@ -9,7 +8,7 @@ from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel from psycopg2 import errors -from chalicelib.core import roles, tenants +from chalicelib.core import tenants from chalicelib.utils.scim_auth import ( auth_optional, auth_required, @@ -204,17 +203,9 @@ async def get_schemas(filter_param: str | None = Query(None, alias="filter")): 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, + content=SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id], ) diff --git a/ee/api/routers/scim/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json index c80a084c5..d2838513e 100644 --- a/ee/api/routers/scim/fixtures/user_schema.json +++ b/ee/api/routers/scim/fixtures/user_schema.json @@ -182,7 +182,8 @@ "caseExact": false, "mutability": "readWrite", "returned": "default", - "uniqueness": "none" + "uniqueness": "none", + "canonicalValues": ["owner", "admin", "member", "service"] }, { "name": "preferredLanguage", diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 2444f3f07..50aa0f0cd 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -3,7 +3,6 @@ from datetime import datetime from psycopg2.extensions import AsIs from chalicelib.utils import pg_client -from chalicelib.core import roles from routers.scim.resource_config import ( ProviderResource, ClientResource, @@ -18,8 +17,10 @@ def convert_client_resource_update_input_to_provider_resource_update_input( ) -> ProviderInput: result = {} if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - result["role_id"] = role["roleId"] if role else None + role = "member" + if client_input["userType"] in {"owner", "admin", "member", "service"}: + role = client_input["userType"] + result["role"] = role 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) @@ -38,10 +39,10 @@ def convert_client_resource_update_input_to_provider_resource_update_input( def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role_id = None + role = None if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - role_id = role["roleId"] if role else None + if client_input["userType"] in {"owner", "admin", "member", "service"}: + role = client_input["userType"] name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -61,7 +62,7 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role_id": role_id, + "role": role, } result = {k: v for k, v in result.items() if v is not None} return result @@ -70,10 +71,10 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( def convert_client_resource_creation_input_to_provider_resource_creation_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role_id = None + role = None if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - role_id = role["roleId"] if role else None + if client_input["userType"] in {"owner", "admin", "member", "service"}: + role = client_input["userType"] name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -93,7 +94,7 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role_id": role_id, + "role": role, } result = {k: v for k, v in result.items() if v is not None} return result @@ -154,7 +155,7 @@ def convert_provider_resource_to_client_resource( "formatted": provider_resource["name"], }, "displayName": provider_resource["name"] or provider_resource["email"], - "userType": provider_resource.get("role_name"), + "userType": provider_resource.get("role"), "active": provider_resource["deleted_at"] is None, "groups": [ { @@ -192,7 +193,6 @@ def get_provider_resource_chunk( """ SELECT users.*, - roles.name AS role_name, COALESCE( ( SELECT json_agg(groups) @@ -203,7 +203,6 @@ def get_provider_resource_chunk( '[]' ) AS groups FROM public.users - LEFT JOIN public.roles USING (role_id) WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL @@ -225,7 +224,6 @@ def get_provider_resource( """ SELECT users.*, - roles.name AS role_name, COALESCE( ( SELECT json_agg(groups) @@ -236,7 +234,6 @@ def get_provider_resource( '[]' ) AS groups FROM public.users - LEFT JOIN public.roles USING (role_id) WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -257,7 +254,7 @@ def create_provider_resource( tenant_id: int, name: str = "", internal_id: str | None = None, - role_id: int | None = None, + role: str = "member", ) -> ProviderResource: with pg_client.PostgresClient() as cur: cur.execute( @@ -269,20 +266,19 @@ def create_provider_resource( email, name, internal_id, - role_id + role ) VALUES ( %(tenant_id)s, %(email)s, %(name)s, %(internal_id)s, - %(role_id)s + %(role)s ) RETURNING * ) SELECT u.*, - roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -293,14 +289,13 @@ def create_provider_resource( '[]' ) AS groups FROM u - LEFT JOIN public.roles USING (role_id) """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, + "role": role, }, ) ) @@ -311,8 +306,8 @@ def restore_provider_resource( tenant_id: int, email: str, name: str = "", + role: str = "member", internal_id: str | None = None, - role_id: int | None = None, **kwargs: dict[str, Any], ) -> ProviderResource: with pg_client.PostgresClient() as cur: @@ -326,7 +321,7 @@ def restore_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role_id = %(role_id)s, + role = %(role)s, deleted_at = NULL, created_at = now(), updated_at = now(), @@ -338,7 +333,6 @@ def restore_provider_resource( ) SELECT u.*, - roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -348,14 +342,14 @@ def restore_provider_resource( ), '[]' ) AS groups - FROM u LEFT JOIN public.roles USING (role_id); + FROM u """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, + "role": role, }, ) ) @@ -368,7 +362,7 @@ def rewrite_provider_resource( email: str, name: str = "", internal_id: str | None = None, - role_id: int | None = None, + role: str = "member", ): with pg_client.PostgresClient() as cur: cur.execute( @@ -380,7 +374,7 @@ def rewrite_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role_id = %(role_id)s, + role = %(role)s, updated_at = now() WHERE users.user_id = %(user_id)s @@ -390,7 +384,6 @@ def rewrite_provider_resource( ) SELECT u.*, - roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -400,7 +393,7 @@ def rewrite_provider_resource( ), '[]' ) AS groups - FROM u LEFT JOIN public.roles USING (role_id); + FROM u """, { "tenant_id": tenant_id, @@ -408,7 +401,7 @@ def rewrite_provider_resource( "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, + "role": role, }, ) ) @@ -443,7 +436,6 @@ def update_provider_resource( ) SELECT u.*, - roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -453,7 +445,7 @@ def update_provider_resource( ), '[]' ) AS groups - FROM u LEFT JOIN public.roles USING (role_id) + FROM u """ ) return cur.fetchone() From d3a9a508923833f9c17b535153b184674d994325 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Mon, 28 Apr 2025 16:16:08 +0200 Subject: [PATCH 25/34] Revert "update mapping for userType and role" This reverts commit 245dc68fcf189cef86bba760a9b415a10746b6fc. --- ee/api/routers/scim/api.py | 13 +++- ee/api/routers/scim/fixtures/user_schema.json | 3 +- ee/api/routers/scim/users.py | 60 +++++++++++-------- 3 files changed, 46 insertions(+), 30 deletions(-) diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index e9914c251..2607566cb 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -1,4 +1,5 @@ import logging +from copy import deepcopy from enum import Enum from decouple import config @@ -8,7 +9,7 @@ from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel from psycopg2 import errors -from chalicelib.core import tenants +from chalicelib.core import roles, tenants from chalicelib.utils.scim_auth import ( auth_optional, auth_required, @@ -203,9 +204,17 @@ async def get_schemas(filter_param: str | None = Query(None, alias="filter")): 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_IDS_TO_SCHEMA_DETAILS[schema_id], + content=schema, ) diff --git a/ee/api/routers/scim/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json index d2838513e..c80a084c5 100644 --- a/ee/api/routers/scim/fixtures/user_schema.json +++ b/ee/api/routers/scim/fixtures/user_schema.json @@ -182,8 +182,7 @@ "caseExact": false, "mutability": "readWrite", "returned": "default", - "uniqueness": "none", - "canonicalValues": ["owner", "admin", "member", "service"] + "uniqueness": "none" }, { "name": "preferredLanguage", diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 50aa0f0cd..2444f3f07 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -3,6 +3,7 @@ from datetime import datetime from psycopg2.extensions import AsIs from chalicelib.utils import pg_client +from chalicelib.core import roles from routers.scim.resource_config import ( ProviderResource, ClientResource, @@ -17,10 +18,8 @@ def convert_client_resource_update_input_to_provider_resource_update_input( ) -> ProviderInput: result = {} if "userType" in client_input: - role = "member" - if client_input["userType"] in {"owner", "admin", "member", "service"}: - role = client_input["userType"] - result["role"] = role + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + result["role_id"] = role["roleId"] if role else None 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) @@ -39,10 +38,10 @@ def convert_client_resource_update_input_to_provider_resource_update_input( def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role = None + role_id = None if "userType" in client_input: - if client_input["userType"] in {"owner", "admin", "member", "service"}: - role = client_input["userType"] + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + role_id = role["roleId"] if role else None name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -62,7 +61,7 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role": role, + "role_id": role_id, } result = {k: v for k, v in result.items() if v is not None} return result @@ -71,10 +70,10 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( def convert_client_resource_creation_input_to_provider_resource_creation_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role = None + role_id = None if "userType" in client_input: - if client_input["userType"] in {"owner", "admin", "member", "service"}: - role = client_input["userType"] + role = roles.get_role_by_name(tenant_id, client_input["userType"]) + role_id = role["roleId"] if role else None name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -94,7 +93,7 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role": role, + "role_id": role_id, } result = {k: v for k, v in result.items() if v is not None} return result @@ -155,7 +154,7 @@ def convert_provider_resource_to_client_resource( "formatted": provider_resource["name"], }, "displayName": provider_resource["name"] or provider_resource["email"], - "userType": provider_resource.get("role"), + "userType": provider_resource.get("role_name"), "active": provider_resource["deleted_at"] is None, "groups": [ { @@ -193,6 +192,7 @@ def get_provider_resource_chunk( """ SELECT users.*, + roles.name AS role_name, COALESCE( ( SELECT json_agg(groups) @@ -203,6 +203,7 @@ def get_provider_resource_chunk( '[]' ) AS groups FROM public.users + LEFT JOIN public.roles USING (role_id) WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL @@ -224,6 +225,7 @@ def get_provider_resource( """ SELECT users.*, + roles.name AS role_name, COALESCE( ( SELECT json_agg(groups) @@ -234,6 +236,7 @@ def get_provider_resource( '[]' ) AS groups FROM public.users + LEFT JOIN public.roles USING (role_id) WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -254,7 +257,7 @@ def create_provider_resource( tenant_id: int, name: str = "", internal_id: str | None = None, - role: str = "member", + role_id: int | None = None, ) -> ProviderResource: with pg_client.PostgresClient() as cur: cur.execute( @@ -266,19 +269,20 @@ def create_provider_resource( email, name, internal_id, - role + role_id ) VALUES ( %(tenant_id)s, %(email)s, %(name)s, %(internal_id)s, - %(role)s + %(role_id)s ) RETURNING * ) SELECT u.*, + roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -289,13 +293,14 @@ def create_provider_resource( '[]' ) AS groups FROM u + LEFT JOIN public.roles USING (role_id) """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role": role, + "role_id": role_id, }, ) ) @@ -306,8 +311,8 @@ def restore_provider_resource( tenant_id: int, email: str, name: str = "", - role: str = "member", internal_id: str | None = None, + role_id: int | None = None, **kwargs: dict[str, Any], ) -> ProviderResource: with pg_client.PostgresClient() as cur: @@ -321,7 +326,7 @@ def restore_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role = %(role)s, + role_id = %(role_id)s, deleted_at = NULL, created_at = now(), updated_at = now(), @@ -333,6 +338,7 @@ def restore_provider_resource( ) SELECT u.*, + roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -342,14 +348,14 @@ def restore_provider_resource( ), '[]' ) AS groups - FROM u + FROM u LEFT JOIN public.roles USING (role_id); """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role": role, + "role_id": role_id, }, ) ) @@ -362,7 +368,7 @@ def rewrite_provider_resource( email: str, name: str = "", internal_id: str | None = None, - role: str = "member", + role_id: int | None = None, ): with pg_client.PostgresClient() as cur: cur.execute( @@ -374,7 +380,7 @@ def rewrite_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role = %(role)s, + role_id = %(role_id)s, updated_at = now() WHERE users.user_id = %(user_id)s @@ -384,6 +390,7 @@ def rewrite_provider_resource( ) SELECT u.*, + roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -393,7 +400,7 @@ def rewrite_provider_resource( ), '[]' ) AS groups - FROM u + FROM u LEFT JOIN public.roles USING (role_id); """, { "tenant_id": tenant_id, @@ -401,7 +408,7 @@ def rewrite_provider_resource( "email": email, "name": name, "internal_id": internal_id, - "role": role, + "role_id": role_id, }, ) ) @@ -436,6 +443,7 @@ def update_provider_resource( ) SELECT u.*, + roles.name as role_name, COALESCE( ( SELECT json_agg(groups) @@ -445,7 +453,7 @@ def update_provider_resource( ), '[]' ) AS groups - FROM u + FROM u LEFT JOIN public.roles USING (role_id) """ ) return cur.fetchone() From 8bdbf7ef95d1ca200968a4c46e8ae42c57e84195 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 29 Apr 2025 17:13:28 +0200 Subject: [PATCH 26/34] map groups to roles instead of having separate groups table --- ee/api/routers/scim/groups.py | 148 ++++++++++-------- ee/api/routers/scim/users.py | 133 +++------------- .../db/init_dbs/postgresql/init_schema.sql | 19 +-- 3 files changed, 101 insertions(+), 199 deletions(-) diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index 774e0815b..53c6389ff 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -18,8 +18,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input( result = {} if "displayName" in client_input: result["name"] = client_input["displayName"] - if "externalId" in client_input: - result["external_id"] = client_input["externalId"] if "members" in client_input: members = client_input["members"] or [] result["user_ids"] = [int(member["value"]) for member in members] @@ -32,15 +30,14 @@ def convert_provider_resource_to_client_resource( members = provider_resource["users"] or [] return { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "id": str(provider_resource["group_id"]), - "externalId": provider_resource["external_id"], + "id": str(provider_resource["role_id"]), "meta": { "resourceType": "Group", "created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"), "lastModified": provider_resource["updated_at"].strftime( "%Y-%m-%dT%H:%M:%SZ" ), - "location": f"Groups/{provider_resource['group_id']}", + "location": f"Groups/{provider_resource['role_id']}", }, "displayName": provider_resource["name"], "members": [ @@ -60,8 +57,10 @@ def get_active_resource_count(tenant_id: int) -> int: cur.mogrify( """ SELECT COUNT(*) - FROM public.groups - WHERE groups.tenant_id = %(tenant_id)s + FROM public.roles + WHERE + roles.tenant_id = %(tenant_id)s + AND roles.deleted_at IS NULL """, {"tenant_id": tenant_id}, ) @@ -77,18 +76,19 @@ def get_provider_resource_chunk( cur.mogrify( """ SELECT - groups.*, + roles.*, COALESCE( ( SELECT json_agg(users) - FROM public.user_group - JOIN public.users USING (user_id) - WHERE user_group.group_id = groups.group_id + FROM public.users + WHERE users.role_id = roles.role_id ), '[]' ) AS users - FROM public.groups - WHERE groups.tenant_id = %(tenant_id)s + FROM public.roles + WHERE + roles.tenant_id = %(tenant_id)s + AND roles.deleted_at IS NULL LIMIT %(limit)s OFFSET %(offset)s; """, @@ -110,23 +110,23 @@ def get_provider_resource( cur.mogrify( """ SELECT - groups.*, + roles.*, COALESCE( ( SELECT json_agg(users) - FROM public.user_group - JOIN public.users USING (user_id) - WHERE user_group.group_id = groups.group_id + FROM public.users + WHERE users.role_id = roles.role_id ), '[]' ) AS users - FROM public.groups + FROM public.roles WHERE - groups.tenant_id = %(tenant_id)s - AND groups.group_id = %(group_id)s + roles.tenant_id = %(tenant_id)s + AND roles.role_id = %(resource_id)s + AND roles.deleted_at IS NULL LIMIT 1; """, - {"group_id": resource_id, "tenant_id": tenant_id}, + {"resource_id": resource_id, "tenant_id": tenant_id}, ) ) return cur.fetchone() @@ -135,7 +135,7 @@ def get_provider_resource( def get_provider_resource_from_unique_fields( **kwargs: dict[str, Any], ) -> ProviderResource | None: - # note(jon): we do not really use this for groups as we don't have unique values outside + # note(jon): we do not really use this for scim.groups (openreplay.roles) as we don't have unique values outside # of the primary key return None @@ -145,7 +145,6 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( ) -> ProviderInput: return { "name": client_input["displayName"], - "external_id": client_input.get("externalId"), "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], @@ -157,7 +156,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( ) -> ProviderInput: return { "name": client_input["displayName"], - "external_id": client_input.get("externalId"), "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], @@ -189,50 +187,37 @@ def create_provider_resource( cur.execute( f""" WITH - g AS ( - INSERT INTO public.groups ({column_clause}) + r AS ( + INSERT INTO public.roles ({column_clause}) VALUES ({value_clause}) RETURNING * ), - ugs AS ( - INSERT INTO public.user_group (user_id, group_id) - SELECT users.user_id, g.group_id - FROM g - JOIN public.users ON users.user_id = ANY({user_id_clause}) + linked_users AS ( + UPDATE public.users + SET + updated_at = now(), + role_id = (SELECT r.role_id FROM r) + WHERE users.user_id = ANY({user_id_clause}) RETURNING * ) SELECT - g.*, + r.*, COALESCE( ( - SELECT json_agg(users) - FROM ugs - JOIN public.users USING (user_id) + SELECT json_agg(linked_users.*) + FROM linked_users ), '[]' ) AS users - FROM g + FROM r LIMIT 1; """ ) return cur.fetchone() -def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - DELETE FROM public.groups - WHERE groups.group_id = %(group_id)s AND groups.tenant_id = %(tenant_id)s; - """ - ), - {"tenant_id": tenant_id, "group_id": resource_id}, - ) - - def _update_resource_sql( - group_id: int, + resource_id: int, tenant_id: int, user_ids: list[int] | None = None, **kwargs: dict[str, Any], @@ -251,46 +236,71 @@ def _update_resource_sql( user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" cur.execute( f""" - DELETE FROM public.user_group - WHERE user_group.group_id = {group_id} + UPDATE public.users + SET role_id = NULL + WHERE users.role_id = {resource_id} """ ) cur.execute( f""" WITH - g AS ( - UPDATE public.groups + r AS ( + UPDATE public.roles SET {set_clause} WHERE - groups.group_id = {group_id} - AND groups.tenant_id = {tenant_id} + roles.role_id = {resource_id} + AND roles.tenant_id = {tenant_id} + AND roles.deleted_at IS NULL RETURNING * ), - linked_user_group AS ( - INSERT INTO public.user_group (user_id, group_id) - SELECT users.user_id, g.group_id - FROM g - JOIN public.users ON users.user_id = ANY({user_id_clause}) - WHERE users.deleted_at IS NULL AND users.tenant_id = {tenant_id} + linked_users AS ( + UPDATE public.users + SET + updated_at = now(), + role_id = {resource_id} + WHERE users.user_id = ANY({user_id_clause}) RETURNING * ) SELECT - g.*, + r.*, COALESCE( ( - SELECT json_agg(users) - FROM linked_user_group - JOIN public.users USING (user_id) + SELECT json_agg(linked_users.*) + FROM linked_users ), '[]' ) AS users - FROM g + FROM r 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 restore_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, + created_at=datetime.now(), + deleted_at=None, + **kwargs, + ) + + def rewrite_provider_resource( resource_id: int, tenant_id: int, @@ -298,7 +308,7 @@ def rewrite_provider_resource( **kwargs: dict[str, Any], ) -> dict[str, Any]: return _update_resource_sql( - group_id=resource_id, + resource_id=resource_id, tenant_id=tenant_id, name=name, **kwargs, @@ -311,7 +321,7 @@ def update_provider_resource( **kwargs: dict[str, Any], ): return _update_resource_sql( - group_id=resource_id, + resource_id=resource_id, tenant_id=tenant_id, **kwargs, ) diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 2444f3f07..ccd17e4df 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -17,9 +17,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: result = {} - if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - result["role_id"] = role["roleId"] if role else None 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) @@ -38,10 +35,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input( def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role_id = None - if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - role_id = role["roleId"] if role else None name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -61,7 +54,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role_id": role_id, } result = {k: v for k, v in result.items() if v is not None} return result @@ -70,10 +62,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( def convert_client_resource_creation_input_to_provider_resource_creation_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - role_id = None - if "userType" in client_input: - role = roles.get_role_by_name(tenant_id, client_input["userType"]) - role_id = role["roleId"] if role else None name = client_input.get("name", {}).get("formatted") if not name: name = " ".join( @@ -93,7 +81,6 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "email": client_input["userName"], "internal_id": client_input.get("externalId"), "name": name, - "role_id": role_id, } result = {k: v for k, v in result.items() if v is not None} return result @@ -124,7 +111,7 @@ def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: UPDATE public.users SET deleted_at = NULL, - updated_at = default + updated_at = now() WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -137,6 +124,14 @@ def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: def convert_provider_resource_to_client_resource( provider_resource: ProviderResource, ) -> ClientResource: + groups = [] + if provider_resource["role_id"]: + groups.append( + { + "value": str(provider_resource["role_id"]), + "$ref": f"Groups/{provider_resource['role_id']}", + } + ) return { "id": str(provider_resource["user_id"]), "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -154,15 +149,8 @@ def convert_provider_resource_to_client_resource( "formatted": provider_resource["name"], }, "displayName": provider_resource["name"] or provider_resource["email"], - "userType": provider_resource.get("role_name"), "active": provider_resource["deleted_at"] is None, - "groups": [ - { - "value": str(group["group_id"]), - "$ref": f"Groups/{group['group_id']}", - } - for group in provider_resource["groups"] - ], + "groups": groups, } @@ -190,20 +178,8 @@ def get_provider_resource_chunk( cur.execute( cur.mogrify( """ - SELECT - users.*, - roles.name AS role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = users.user_id - ), - '[]' - ) AS groups + SELECT * FROM public.users - LEFT JOIN public.roles USING (role_id) WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL @@ -223,20 +199,8 @@ def get_provider_resource( cur.execute( cur.mogrify( """ - SELECT - users.*, - roles.name AS role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = users.user_id - ), - '[]' - ) AS groups + SELECT * FROM public.users - LEFT JOIN public.roles USING (role_id) WHERE users.user_id = %(user_id)s AND users.tenant_id = %(tenant_id)s @@ -257,7 +221,6 @@ def create_provider_resource( tenant_id: int, name: str = "", internal_id: str | None = None, - role_id: int | None = None, ) -> ProviderResource: with pg_client.PostgresClient() as cur: cur.execute( @@ -268,39 +231,24 @@ def create_provider_resource( tenant_id, email, name, - internal_id, - role_id + internal_id ) VALUES ( %(tenant_id)s, %(email)s, %(name)s, - %(internal_id)s, - %(role_id)s + %(internal_id)s ) RETURNING * ) - SELECT - u.*, - roles.name as role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = u.user_id - ), - '[]' - ) AS groups + SELECT * FROM u - LEFT JOIN public.roles USING (role_id) """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, }, ) ) @@ -312,7 +260,6 @@ def restore_provider_resource( email: str, name: str = "", internal_id: str | None = None, - role_id: int | None = None, **kwargs: dict[str, Any], ) -> ProviderResource: with pg_client.PostgresClient() as cur: @@ -326,7 +273,6 @@ def restore_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role_id = %(role_id)s, deleted_at = NULL, created_at = now(), updated_at = now(), @@ -336,26 +282,14 @@ def restore_provider_resource( WHERE users.email = %(email)s RETURNING * ) - SELECT - u.*, - roles.name as role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = u.user_id - ), - '[]' - ) AS groups - FROM u LEFT JOIN public.roles USING (role_id); + SELECT * + FROM u """, { "tenant_id": tenant_id, "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, }, ) ) @@ -368,7 +302,6 @@ def rewrite_provider_resource( email: str, name: str = "", internal_id: str | None = None, - role_id: int | None = None, ): with pg_client.PostgresClient() as cur: cur.execute( @@ -380,7 +313,6 @@ def rewrite_provider_resource( email = %(email)s, name = %(name)s, internal_id = %(internal_id)s, - role_id = %(role_id)s, updated_at = now() WHERE users.user_id = %(user_id)s @@ -388,19 +320,8 @@ def rewrite_provider_resource( AND users.deleted_at IS NULL RETURNING * ) - SELECT - u.*, - roles.name as role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = u.user_id - ), - '[]' - ) AS groups - FROM u LEFT JOIN public.roles USING (role_id); + SELECT * + FROM u """, { "tenant_id": tenant_id, @@ -408,7 +329,6 @@ def rewrite_provider_resource( "email": email, "name": name, "internal_id": internal_id, - "role_id": role_id, }, ) ) @@ -441,19 +361,8 @@ def update_provider_resource( AND users.deleted_at IS NULL RETURNING * ) - SELECT - u.*, - roles.name as role_name, - COALESCE( - ( - SELECT json_agg(groups) - FROM public.user_group - JOIN public.groups USING (group_id) - WHERE user_group.user_id = u.user_id - ), - '[]' - ) AS groups - FROM u LEFT JOIN public.roles USING (role_id) + SELECT * + FROM u """ ) return cur.fetchone() diff --git a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql index a62a34cbb..dc231c2a0 100644 --- a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -118,20 +118,11 @@ CREATE TABLE public.roles protected bool NOT NULL DEFAULT FALSE, all_projects bool NOT NULL DEFAULT TRUE, created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()), + updated_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()), deleted_at timestamp NULL DEFAULT NULL, service_role bool NOT NULL DEFAULT FALSE ); -CREATE TABLE public.groups -( - group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, - tenant_id integer NOT NULL REFERENCES public.tenants (tenant_id) ON DELETE CASCADE, - external_id text, - name text NOT NULL, - created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), - updated_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc') -); - CREATE TYPE user_role AS ENUM ('owner','admin','member','service'); CREATE TABLE public.users @@ -167,14 +158,6 @@ CREATE TABLE public.users CREATE INDEX users_tenant_id_deleted_at_N_idx ON public.users (tenant_id) WHERE deleted_at ISNULL; CREATE INDEX users_name_gin_idx ON public.users USING GIN (name gin_trgm_ops); -CREATE TABLE public.user_group -( - user_group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, - user_id integer REFERENCES public.users (user_id) ON DELETE CASCADE, - group_id integer REFERENCES public.groups (group_id) ON DELETE CASCADE, - UNIQUE (user_id, group_id) -); - CREATE TABLE public.basic_authentication ( user_id integer NOT NULL REFERENCES public.users (user_id) ON DELETE CASCADE, From c02f52b413c435302252a0bd997c18a0b66d5544 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 2 May 2025 14:20:30 +0200 Subject: [PATCH 27/34] allow for local development redirect --- ee/api/chalicelib/utils/SAML2_helper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ee/api/chalicelib/utils/SAML2_helper.py b/ee/api/chalicelib/utils/SAML2_helper.py index cbfcccaab..6e1972c1e 100644 --- a/ee/api/chalicelib/utils/SAML2_helper.py +++ b/ee/api/chalicelib/utils/SAML2_helper.py @@ -156,7 +156,8 @@ def get_landing_URL(query_params: dict = None, redirect_to_link2=False): else: return config("sso_landing_override") + query_params - return config("SITE_URL") + config("sso_landing", default="/login") + query_params + base_url = config("SITE_URLx") if config("LOCAL_DEV") else config("SITE_URL") + return base_url + config("sso_landing", default="/login") + query_params environ["hastSAML2"] = str(is_saml2_available()) From 891a0e31c145edd8b5964c55aeeaf64bf18fd031 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Tue, 6 May 2025 14:15:31 +0200 Subject: [PATCH 28/34] fix users.name error and handle role/project interactions --- ee/api/routers/scim/api.py | 6 +- .../routers/scim/fixtures/group_schema.json | 23 ++ ee/api/routers/scim/groups.py | 260 +++++++++--------- ee/api/routers/scim/users.py | 57 ++-- 4 files changed, 184 insertions(+), 162 deletions(-) diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index 2607566cb..affe33946 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -243,7 +243,7 @@ group_config = ResourceConfig( 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=groups.get_provider_resource_from_unique_fields, + 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, @@ -382,7 +382,7 @@ async def delete_resource( @public_app.put("/{resource_type}/{resource_id}") async def put_resource( resource_type: SCIMResource, - resource_id: str, + resource_id: int | str, r: Request, tenant_id=Depends(auth_required), attributes: list[str] | None = Query(None), @@ -424,7 +424,7 @@ async def put_resource( @public_app.patch("/{resource_type}/{resource_id}") async def patch_resource( resource_type: SCIMResource, - resource_id: str, + resource_id: int | str, r: Request, tenant_id=Depends(auth_required), attributes: list[str] | None = Query(None), diff --git a/ee/api/routers/scim/fixtures/group_schema.json b/ee/api/routers/scim/fixtures/group_schema.json index ddb030b92..b31dd6dd1 100644 --- a/ee/api/routers/scim/fixtures/group_schema.json +++ b/ee/api/routers/scim/fixtures/group_schema.json @@ -110,6 +110,29 @@ "returned": "default", "uniqueness": "none" }, + { + "name": "projectKeys", + "type": "complex", + "multiValued": true, + "description": "A list of project keys associated with the group.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": false, + "description": "The unique project key.", + "required": true, + "mutability": "immutable", + "returned": "default", + "caseExact": true, + "uniqueness": "none" + } + ] + }, { "name": "members", "type": "complex", diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index 53c6389ff..cc113eb16 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -21,6 +21,8 @@ def convert_client_resource_update_input_to_provider_resource_update_input( if "members" in client_input: members = client_input["members"] or [] result["user_ids"] = [int(member["value"]) for member in members] + if "projectKeys" in client_input: + result["project_keys"] = [item["value"] for item in client_input["projectKeys"]] return result @@ -48,6 +50,9 @@ def convert_provider_resource_to_client_resource( } for member in members ], + "projectKeys": [ + {"value": project_key} for project_key in provider_resource["project_keys"] + ], } @@ -68,37 +73,45 @@ def get_active_resource_count(tenant_id: int) -> int: return cur.fetchone()["count"] +def _main_select_query(tenant_id: int, resource_id: int | 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}") + where_clause = " AND ".join(where_and_clauses) + return f""" + SELECT + roles.*, + COALESCE( + ( + SELECT json_agg(users) + FROM public.users + WHERE users.role_id = roles.role_id + ), + '[]' + ) AS users, + 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.roles + WHERE {where_clause} + """ + + def get_provider_resource_chunk( offset: int, tenant_id: int, limit: int ) -> list[ProviderResource]: + query = _main_select_query(tenant_id) with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT - roles.*, - COALESCE( - ( - SELECT json_agg(users) - FROM public.users - WHERE users.role_id = roles.role_id - ), - '[]' - ) AS users - FROM public.roles - WHERE - roles.tenant_id = %(tenant_id)s - AND roles.deleted_at IS NULL - LIMIT %(limit)s - OFFSET %(offset)s; - """, - { - "offset": offset, - "limit": limit, - "tenant_id": tenant_id, - }, - ) - ) + cur.execute(f"{query} LIMIT {limit} OFFSET {offset}") return cur.fetchall() @@ -106,40 +119,10 @@ def get_provider_resource( resource_id: ResourceId, tenant_id: int ) -> ProviderResource | None: with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - SELECT - roles.*, - COALESCE( - ( - SELECT json_agg(users) - FROM public.users - WHERE users.role_id = roles.role_id - ), - '[]' - ) AS users - FROM public.roles - WHERE - roles.tenant_id = %(tenant_id)s - AND roles.role_id = %(resource_id)s - AND roles.deleted_at IS NULL - LIMIT 1; - """, - {"resource_id": resource_id, "tenant_id": tenant_id}, - ) - ) + cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1") return cur.fetchone() -def get_provider_resource_from_unique_fields( - **kwargs: dict[str, Any], -) -> ProviderResource | None: - # note(jon): we do not really use this for scim.groups (openreplay.roles) as we don't have unique values outside - # of the primary key - return None - - def convert_client_resource_creation_input_to_provider_resource_creation_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: @@ -148,6 +131,7 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], + "project_keys": [item["value"] for item in client_input.get("projectKeys", [])], } @@ -159,6 +143,7 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], + "project_keys": [item["value"] for item in client_input.get("projectKeys", [])], } @@ -166,6 +151,7 @@ def create_provider_resource( name: str, tenant_id: int, user_ids: list[str] | None = None, + project_keys: list[str] | None = None, **kwargs: dict[str, Any], ) -> ProviderResource: with pg_client.PostgresClient() as cur: @@ -184,35 +170,42 @@ def create_provider_resource( cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids ] user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" + project_keys = project_keys or [] + project_key_fragments = [ + cur.mogrify("%s", (project_key,)).decode("utf-8") + for project_key in project_keys + ] + project_key_clause = f"ARRAY[{', '.join(project_key_fragments)}]::varchar[]" cur.execute( f""" - WITH - r AS ( - INSERT INTO public.roles ({column_clause}) - VALUES ({value_clause}) - RETURNING * - ), - linked_users AS ( - UPDATE public.users - SET - updated_at = now(), - role_id = (SELECT r.role_id FROM r) - WHERE users.user_id = ANY({user_id_clause}) - RETURNING * - ) - SELECT - r.*, - COALESCE( - ( - SELECT json_agg(linked_users.*) - FROM linked_users - ), - '[]' - ) AS users - FROM r - LIMIT 1; + 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""" + WITH ps AS ( + SELECT * + FROM public.projects + WHERE projects.project_key = ANY({project_key_clause}) + ) + INSERT INTO public.roles_projects (role_id, project_id) + SELECT {role_id}, ps.project_id + FROM ps + """ + ) + cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1") return cur.fetchone() @@ -220,6 +213,7 @@ def _update_resource_sql( resource_id: int, tenant_id: int, user_ids: list[int] | None = None, + project_keys: list[str] | None = None, **kwargs: dict[str, Any], ) -> dict[str, Any]: with pg_client.PostgresClient() as cur: @@ -234,46 +228,68 @@ def _update_resource_sql( cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids ] user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" + project_keys = project_keys or [] + project_key_fragments = [ + cur.mogrify("%s", (project_key,)).decode("utf-8") + for project_key in project_keys + ] + project_key_clause = f"ARRAY[{', '.join(project_key_fragments)}]::varchar[]" cur.execute( f""" UPDATE public.users - SET role_id = NULL - WHERE users.role_id = {resource_id} + SET + updated_at = now(), + role_id = NULL + WHERE + users.role_id = {resource_id} + AND users.user_id != ALL({user_id_clause}) + RETURNING * """ ) cur.execute( f""" - WITH - r AS ( - UPDATE public.roles - SET {set_clause} - WHERE - roles.role_id = {resource_id} - AND roles.tenant_id = {tenant_id} - AND roles.deleted_at IS NULL - RETURNING * - ), - linked_users AS ( - UPDATE public.users - SET - updated_at = now(), - role_id = {resource_id} - WHERE users.user_id = ANY({user_id_clause}) - RETURNING * - ) - SELECT - r.*, - COALESCE( - ( - SELECT json_agg(linked_users.*) - FROM linked_users - ), - '[]' - ) AS users - FROM r - LIMIT 1; + UPDATE public.users + SET + updated_at = now(), + role_id = {resource_id} + WHERE + (users.role_id != {resource_id} OR users.role_id IS NULL) + AND users.user_id = ANY({user_id_clause}) + 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 = {resource_id} + AND projects.project_key != ALL({project_key_clause}) + """ + ) + cur.execute( + f""" + INSERT INTO public.roles_projects (role_id, project_id) + SELECT {resource_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 + """ + ) + cur.execute( + f""" + UPDATE public.roles + SET {set_clause} + 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() @@ -285,22 +301,6 @@ def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None: ) -def restore_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, - created_at=datetime.now(), - deleted_at=None, - **kwargs, - ) - - def rewrite_provider_resource( resource_id: int, tenant_id: int, diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index ccd17e4df..1a7fd1d17 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -3,7 +3,6 @@ from datetime import datetime from psycopg2.extensions import AsIs from chalicelib.utils import pg_client -from chalicelib.core import roles from routers.scim.resource_config import ( ProviderResource, ClientResource, @@ -35,21 +34,21 @@ def convert_client_resource_update_input_to_provider_resource_update_input( def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - name = client_input.get("name", {}).get("formatted") - if not name: - 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 + 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"), @@ -62,21 +61,21 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( def convert_client_resource_creation_input_to_provider_resource_creation_input( tenant_id: int, client_input: ClientInput ) -> ProviderInput: - name = client_input.get("name", {}).get("formatted") - if not name: - 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 + 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"), From 920fdd345585a4267f1f41b17493286d170351e6 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 7 May 2025 11:54:01 +0200 Subject: [PATCH 29/34] add filtering --- ee/api/routers/scim/api.py | 10 +- .../fixtures/service_provider_config.json | 4 +- ee/api/routers/scim/groups.py | 38 +++--- ee/api/routers/scim/helpers.py | 115 ++++++++++++++++++ ee/api/routers/scim/resource_config.py | 1 + ee/api/routers/scim/users.py | 56 +++++---- 6 files changed, 182 insertions(+), 42 deletions(-) diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index affe33946..2508fc7bf 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -234,6 +234,7 @@ user_config = ResourceConfig( 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( schema_id="urn:ietf:params:scim:schemas:core:2.0:Group", @@ -251,6 +252,7 @@ group_config = ResourceConfig( 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] = { @@ -272,15 +274,19 @@ async def get_resources( 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] - total_resources = config.get_active_resource_count(tenant_id) + 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) + 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 diff --git a/ee/api/routers/scim/fixtures/service_provider_config.json b/ee/api/routers/scim/fixtures/service_provider_config.json index dbcbff942..38a5079ae 100644 --- a/ee/api/routers/scim/fixtures/service_provider_config.json +++ b/ee/api/routers/scim/fixtures/service_provider_config.json @@ -11,8 +11,8 @@ "maxPayloadSize": 0 }, "filter": { - "supported": false, - "maxResults": 0 + "supported": true, + "maxResults": 10 }, "changePassword": { "supported": false diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index cc113eb16..09d36b231 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -56,30 +56,36 @@ def convert_provider_resource_to_client_resource( } -def get_active_resource_count(tenant_id: int) -> int: +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) with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify( - """ - SELECT COUNT(*) - FROM public.roles - WHERE - roles.tenant_id = %(tenant_id)s - AND roles.deleted_at IS NULL - """, - {"tenant_id": tenant_id}, - ) + f""" + SELECT COUNT(*) + FROM public.roles + WHERE {where_clause} + """ ) return cur.fetchone()["count"] -def _main_select_query(tenant_id: int, resource_id: int | None = None) -> str: +def _main_select_query( + tenant_id: int, resource_id: int | None = None, filter_clause: 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 @@ -107,14 +113,18 @@ def _main_select_query(tenant_id: int, resource_id: int | None = None) -> str: def get_provider_resource_chunk( - offset: int, tenant_id: int, limit: int + offset: int, tenant_id: int, limit: int, filter_clause: str | None = None ) -> list[ProviderResource]: - query = _main_select_query(tenant_id) + 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: diff --git a/ee/api/routers/scim/helpers.py b/ee/api/routers/scim/helpers.py index a94806d14..ebf5f1b67 100644 --- a/ee/api/routers/scim/helpers.py +++ b/ee/api/routers/scim/helpers.py @@ -366,3 +366,118 @@ def remove_by_path(doc, tokens): 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() diff --git a/ee/api/routers/scim/resource_config.py b/ee/api/routers/scim/resource_config.py index afae5eed6..f89877f65 100644 --- a/ee/api/routers/scim/resource_config.py +++ b/ee/api/routers/scim/resource_config.py @@ -40,6 +40,7 @@ class ResourceConfig: [int, ClientInput], ProviderInput ] update_provider_resource: Callable[..., ProviderResource] + filter_attribute_mapping: Callable[None, dict[str, str]] def get_schema(config: ResourceConfig) -> Schema: diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 1a7fd1d17..e1d67b58e 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -85,6 +85,10 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( 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: @@ -153,40 +157,44 @@ def convert_provider_resource_to_client_resource( } -def get_active_resource_count(tenant_id: int) -> int: +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) with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify( - """ - SELECT COUNT(*) - FROM public.users - WHERE - users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - """, - {"tenant_id": tenant_id}, - ) + f""" + SELECT COUNT(*) + FROM public.users + WHERE {where_clause} + """ ) return cur.fetchone()["count"] def get_provider_resource_chunk( - offset: int, tenant_id: int, limit: int + 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) with pg_client.PostgresClient() as cur: cur.execute( - cur.mogrify( - """ - SELECT * - FROM public.users - WHERE - users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - LIMIT %(limit)s - OFFSET %(offset)s; - """, - {"offset": offset, "limit": limit, "tenant_id": tenant_id}, - ) + f""" + SELECT * + FROM public.users + WHERE {where_clause} + LIMIT {limit} + OFFSET {offset}; + """ ) return cur.fetchall() From 7dd7389d3bbe6f238210780c2d3ad621d7fac4e6 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Wed, 14 May 2025 15:00:52 +0200 Subject: [PATCH 30/34] handle permissions and project keys --- .../routers/scim/fixtures/group_schema.json | 23 --- ee/api/routers/scim/fixtures/user_schema.json | 24 ++- ee/api/routers/scim/groups.py | 67 +------ ee/api/routers/scim/helpers.py | 14 +- ee/api/routers/scim/users.py | 187 ++++++++++++------ ee/api/schemas/schemas_ee.py | 8 + 6 files changed, 167 insertions(+), 156 deletions(-) diff --git a/ee/api/routers/scim/fixtures/group_schema.json b/ee/api/routers/scim/fixtures/group_schema.json index b31dd6dd1..ddb030b92 100644 --- a/ee/api/routers/scim/fixtures/group_schema.json +++ b/ee/api/routers/scim/fixtures/group_schema.json @@ -110,29 +110,6 @@ "returned": "default", "uniqueness": "none" }, - { - "name": "projectKeys", - "type": "complex", - "multiValued": true, - "description": "A list of project keys associated with the group.", - "required": false, - "caseExact": false, - "mutability": "readWrite", - "returned": "default", - "subAttributes": [ - { - "name": "value", - "type": "string", - "multiValued": false, - "description": "The unique project key.", - "required": true, - "mutability": "immutable", - "returned": "default", - "caseExact": true, - "uniqueness": "none" - } - ] - }, { "name": "members", "type": "complex", diff --git a/ee/api/routers/scim/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json index c80a084c5..528c4a69a 100644 --- a/ee/api/routers/scim/fixtures/user_schema.json +++ b/ee/api/routers/scim/fixtures/user_schema.json @@ -334,18 +334,14 @@ }, { "name": "entitlements", - "type": "complex", + "type": "string", "multiValued": true, "description": "Entitlements granted to the user.", "required": false, + "caseExact": true, + "canonicalValues": ["SESSION_REPLAY", "METRICS", "ASSIST_LIVE", "ASSIST_CALL", "SPOT_PUBLIC"], "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" } - ] + "returned": "default" }, { "name": "roles", @@ -376,7 +372,17 @@ { "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" } ] - } + }, + { + "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", diff --git a/ee/api/routers/scim/groups.py b/ee/api/routers/scim/groups.py index 09d36b231..8eb9447db 100644 --- a/ee/api/routers/scim/groups.py +++ b/ee/api/routers/scim/groups.py @@ -3,6 +3,7 @@ from datetime import datetime from psycopg2.extensions import AsIs from chalicelib.utils import pg_client +from routers.scim import helpers from routers.scim.resource_config import ( ProviderResource, ClientResource, @@ -21,8 +22,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input( if "members" in client_input: members = client_input["members"] or [] result["user_ids"] = [int(member["value"]) for member in members] - if "projectKeys" in client_input: - result["project_keys"] = [item["value"] for item in client_input["projectKeys"]] return result @@ -50,9 +49,6 @@ def convert_provider_resource_to_client_resource( } for member in members ], - "projectKeys": [ - {"value": project_key} for project_key in provider_resource["project_keys"] - ], } @@ -141,7 +137,6 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], - "project_keys": [item["value"] for item in client_input.get("projectKeys", [])], } @@ -153,7 +148,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "user_ids": [ int(member["value"]) for member in client_input.get("members", []) ], - "project_keys": [item["value"] for item in client_input.get("projectKeys", [])], } @@ -161,7 +155,6 @@ def create_provider_resource( name: str, tenant_id: int, user_ids: list[str] | None = None, - project_keys: list[str] | None = None, **kwargs: dict[str, Any], ) -> ProviderResource: with pg_client.PostgresClient() as cur: @@ -175,17 +168,7 @@ def create_provider_resource( cur.mogrify("%s", (v,)).decode("utf-8") for v in kwargs.values() ] value_clause = ", ".join(value_fragments) - user_ids = user_ids or [] - user_id_fragments = [ - cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids - ] - user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" - project_keys = project_keys or [] - project_key_fragments = [ - cur.mogrify("%s", (project_key,)).decode("utf-8") - for project_key in project_keys - ] - project_key_clause = f"ARRAY[{', '.join(project_key_fragments)}]::varchar[]" + user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur) cur.execute( f""" INSERT INTO public.roles ({column_clause}) @@ -203,18 +186,6 @@ def create_provider_resource( WHERE users.user_id = ANY({user_id_clause}) """ ) - cur.execute( - f""" - WITH ps AS ( - SELECT * - FROM public.projects - WHERE projects.project_key = ANY({project_key_clause}) - ) - INSERT INTO public.roles_projects (role_id, project_id) - SELECT {role_id}, ps.project_id - FROM ps - """ - ) cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1") return cur.fetchone() @@ -223,7 +194,6 @@ def _update_resource_sql( resource_id: int, tenant_id: int, user_ids: list[int] | None = None, - project_keys: list[str] | None = None, **kwargs: dict[str, Any], ) -> dict[str, Any]: with pg_client.PostgresClient() as cur: @@ -233,17 +203,7 @@ def _update_resource_sql( for k, v in kwargs.items() ] set_clause = ", ".join(set_fragments) - user_ids = user_ids or [] - user_id_fragments = [ - cur.mogrify("%s", (user_id,)).decode("utf-8") for user_id in user_ids - ] - user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" - project_keys = project_keys or [] - project_key_fragments = [ - cur.mogrify("%s", (project_key,)).decode("utf-8") - for project_key in project_keys - ] - project_key_clause = f"ARRAY[{', '.join(project_key_fragments)}]::varchar[]" + user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur) cur.execute( f""" UPDATE public.users @@ -268,27 +228,6 @@ def _update_resource_sql( 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 = {resource_id} - AND projects.project_key != ALL({project_key_clause}) - """ - ) - cur.execute( - f""" - INSERT INTO public.roles_projects (role_id, project_id) - SELECT {resource_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 - """ - ) cur.execute( f""" UPDATE public.roles diff --git a/ee/api/routers/scim/helpers.py b/ee/api/routers/scim/helpers.py index ebf5f1b67..bb6c56fec 100644 --- a/ee/api/routers/scim/helpers.py +++ b/ee/api/routers/scim/helpers.py @@ -1,6 +1,18 @@ -from typing import Any +from typing import Any, Literal from copy import deepcopy import re +from chalicelib.utils import pg_client + + +def safe_mogrify_array( + items: list[Any] | None, + array_type: Literal["varchar", "int"], + cursor: pg_client.PostgresClient, +) -> str: + items = items or [] + fragments = [cursor.mogrify("%s", (item,)).decode("utf-8") for item in items] + result = f"ARRAY[{', '.join(fragments)}]::{array_type}[]" + return result def convert_query_str_to_list(query_str: str | None) -> list[str]: diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index e1d67b58e..14639a083 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -1,6 +1,7 @@ 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 ( @@ -10,6 +11,11 @@ from routers.scim.resource_config import ( ClientInput, ProviderInput, ) +from schemas.schemas_ee import ValidIdentityProviderPermissions + + +def _is_valid_permission_for_identity_provider(permission: str) -> bool: + return ValidIdentityProviderPermissions.has_value(permission) def convert_client_resource_update_input_to_provider_resource_update_input( @@ -28,6 +34,14 @@ def convert_client_resource_update_input_to_provider_resource_update_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 @@ -53,6 +67,12 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( "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 @@ -80,6 +100,12 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input( "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 @@ -223,11 +249,57 @@ def get_provider_resource( return cur.fetchone() +def _update_role_projects_and_permissions( + role_id: int | None, + 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 + 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: with pg_client.PostgresClient() as cur: cur.execute( @@ -259,7 +331,11 @@ def create_provider_resource( }, ) ) - return cur.fetchone() + user = cur.fetchone() + _update_role_projects_and_permissions( + user["role_id"], project_keys, permissions, cur + ) + return user def restore_provider_resource( @@ -267,6 +343,8 @@ def restore_provider_resource( 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: with pg_client.PostgresClient() as cur: @@ -300,7 +378,42 @@ def restore_provider_resource( }, ) ) - return cur.fetchone() + user = cur.fetchone() + _update_role_projects_and_permissions( + user["role_id"], project_keys, permissions, cur + ) + return user + + +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( @@ -309,37 +422,18 @@ def rewrite_provider_resource( email: str, name: str = "", internal_id: str | None = None, -): - with pg_client.PostgresClient() as cur: - cur.execute( - cur.mogrify( - """ - WITH u AS ( - UPDATE public.users - SET - email = %(email)s, - name = %(name)s, - internal_id = %(internal_id)s, - updated_at = now() - WHERE - users.user_id = %(user_id)s - AND users.tenant_id = %(tenant_id)s - AND users.deleted_at IS NULL - RETURNING * - ) - SELECT * - FROM u - """, - { - "tenant_id": tenant_id, - "user_id": resource_id, - "email": email, - "name": name, - "internal_id": internal_id, - }, - ) - ) - return cur.fetchone() + 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_provider_resource( @@ -347,29 +441,4 @@ def update_provider_resource( tenant_id: int, **kwargs, ): - with pg_client.PostgresClient() as cur: - set_fragments = [] - kwargs["updated_at"] = datetime.now() - for k, v in kwargs.items(): - fragment = cur.mogrify( - "%s = %s", - (AsIs(k), v), - ).decode("utf-8") - set_fragments.append(fragment) - set_clause = ", ".join(set_fragments) - cur.execute( - f""" - WITH u AS ( - 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 * - ) - SELECT * - FROM u - """ - ) - return cur.fetchone() + return _update_resource_sql(resource_id, tenant_id, **kwargs) diff --git a/ee/api/schemas/schemas_ee.py b/ee/api/schemas/schemas_ee.py index 394f88859..bde5c14d0 100644 --- a/ee/api/schemas/schemas_ee.py +++ b/ee/api/schemas/schemas_ee.py @@ -28,6 +28,14 @@ class ServicePermissions(str, Enum): READ_NOTES = "SERVICE_READ_NOTES" +class ValidIdentityProviderPermissions(str, Enum): + SESSION_REPLAY = "SESSION_REPLAY" + METRICS = "METRICS" + ASSIST_LIVE = "ASSIST_LIVE" + ASSIST_CALL = "ASSIST_CALL" + SPOT_PUBLIC = "SPOT_PUBLIC" + + class CurrentContext(schemas.CurrentContext): permissions: List[Union[Permissions, ServicePermissions]] = Field(...) service_account: bool = Field(default=False) From 34bbde13c1f5a0bb19ad0081712f0a1f7e87cbda Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Thu, 15 May 2025 18:01:33 +0200 Subject: [PATCH 31/34] update token generation for automatic refresh with okta --- ee/api/chalicelib/utils/scim_auth.py | 19 +-- ee/api/routers/scim/api.py | 129 ++++++++++++++---- ee/api/routers/scim/users.py | 2 + .../db/init_dbs/postgresql/init_schema.sql | 10 ++ 4 files changed, 115 insertions(+), 45 deletions(-) diff --git a/ee/api/chalicelib/utils/scim_auth.py b/ee/api/chalicelib/utils/scim_auth.py index a8deaa136..c31dcd058 100644 --- a/ee/api/chalicelib/utils/scim_auth.py +++ b/ee/api/chalicelib/utils/scim_auth.py @@ -13,24 +13,9 @@ REFRESH_SECRET_KEY = config("SCIM_REFRESH_SECRET_KEY") ALGORITHM = config("SCIM_JWT_ALGORITHM") ACCESS_TOKEN_EXPIRE_SECONDS = int(config("SCIM_ACCESS_TOKEN_EXPIRE_SECONDS")) REFRESH_TOKEN_EXPIRE_SECONDS = int(config("SCIM_REFRESH_TOKEN_EXPIRE_SECONDS")) -AUDIENCE = "okta_client" +AUDIENCE = config("SCIM_AUDIENCE") ISSUER = (config("JWT_ISSUER"),) -# Simulated Okta Client Credentials -# OKTA_CLIENT_ID = "okta-client" -# OKTA_CLIENT_SECRET = "okta-secret" - -# class TokenRequest(BaseModel): -# client_id: str -# client_secret: str - -# async def authenticate_client(token_request: TokenRequest): -# """Validate Okta Client Credentials and issue JWT""" -# if token_request.client_id != OKTA_CLIENT_ID or token_request.client_secret != OKTA_CLIENT_SECRET: -# raise HTTPException(status_code=401, detail="Invalid client credentials") - -# return {"access_token": create_jwt(), "token_type": "bearer"} - def create_tokens(tenant_id): curr_time = time.time() @@ -48,7 +33,7 @@ def create_tokens(tenant_id): refresh_payload.update({"exp": curr_time + REFRESH_TOKEN_EXPIRE_SECONDS}) refresh_token = jwt.encode(refresh_payload, REFRESH_SECRET_KEY, algorithm=ALGORITHM) - return access_token, refresh_token + return access_token, refresh_token, ACCESS_TOKEN_EXPIRE_SECONDS def verify_access_token(token: str): diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index 2508fc7bf..07482f10c 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -1,15 +1,15 @@ import logging from copy import deepcopy from enum import Enum +from urllib.parse import urlencode -from decouple import config -from fastapi import Depends, HTTPException, Header, Query, Response, Request -from fastapi.responses import JSONResponse -from fastapi.security import OAuth2PasswordRequestForm -from pydantic import BaseModel +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, tenants +from chalicelib.core import roles from chalicelib.utils.scim_auth import ( auth_optional, auth_required, @@ -33,37 +33,110 @@ public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2") @public_app.post("/token") -async def post_token( - host: str = Header(..., alias="Host"), - form_data: OAuth2PasswordRequestForm = Depends(), -): - subdomain = host.split(".")[0] +async def post_token(r: Request): + form = await r.form() - # Missing authentication part, to add - if form_data.username != config("SCIM_USER") or form_data.password != config( - "SCIM_PASSWORD" - ): - raise HTTPException(status_code=401, detail="Invalid credentials") + client_id = form.get("client_id") + client_secret = form.get("client_secret") + with pg_client.PostgresClient() as cur: + try: + cur.execute( + cur.mogrify( + """ + SELECT tenant_id + FROM public.tenants + WHERE tenant_id=%(tenant_id)s AND tenant_key=%(tenant_key)s + """, + {"tenant_id": int(client_id), "tenant_key": client_secret}, + ) + ) + except ValueError: + raise HTTPException(status_code=401, detail="Invalid credentials") - tenant = tenants.get_by_name(subdomain) - access_token, refresh_token = create_tokens(tenant_id=tenant["tenantId"]) + tenant = cur.fetchone() + if not tenant: + raise HTTPException(status_code=401, detail="Invalid credentials") + + grant_type = form.get("grant_type") + if grant_type == "refresh_token": + refresh_token = form.get("refresh_token") + verify_refresh_token(refresh_token) + else: + code = form.get("code") + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + SELECT * + FROM public.scim_auth_codes + WHERE auth_code=%(auth_code)s AND tenant_id=%(tenant_id)s AND used IS FALSE + """, + {"auth_code": code, "tenant_id": int(client_id)}, + ) + ) + if cur.fetchone() is None: + raise HTTPException( + status_code=401, detail="Invalid code/client_id pair" + ) + cur.execute( + cur.mogrify( + """ + UPDATE public.scim_auth_codes + SET used=TRUE + WHERE auth_code=%(auth_code)s AND tenant_id=%(tenant_id)s AND used IS FALSE + """, + {"auth_code": code, "tenant_id": int(client_id)}, + ) + ) + + access_token, refresh_token, expires_in = create_tokens( + tenant_id=tenant["tenant_id"] + ) return { "access_token": access_token, - "refresh_token": refresh_token, "token_type": "Bearer", + "expires_in": expires_in, + "refresh_token": refresh_token, } -class RefreshRequest(BaseModel): - refresh_token: str - - -@public_app.post("/refresh") -async def post_refresh(r: RefreshRequest): - payload = verify_refresh_token(r.refresh_token) - new_access_token, _ = create_tokens(tenant_id=payload["tenant_id"]) - return {"access_token": new_access_token, "token_type": "Bearer"} +# note(jon): this might be specific to okta. if so, we should probably put specify that in the endpoint +@public_app.get("/authorize") +async def get_authorize( + r: Request, + response_type: str, + client_id: str, + redirect_uri: str, + state: str | None = None, +): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify( + """ + UPDATE public.scim_auth_codes + SET used=TRUE + WHERE tenant_id=%(tenant_id)s + """, + {"tenant_id": int(client_id)}, + ) + ) + cur.execute( + cur.mogrify( + """ + INSERT INTO public.scim_auth_codes (tenant_id) + VALUES (%(tenant_id)s) + RETURNING auth_code + """, + {"tenant_id": int(client_id)}, + ) + ) + code = cur.fetchone()["auth_code"] + params = {"code": code} + if state: + params["state"] = state + url = f"{redirect_uri}?{urlencode(params)}" + return RedirectResponse(url) def _not_found_error_response(resource_id: int): diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 14639a083..9e8623188 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -255,6 +255,8 @@ def _update_role_projects_and_permissions( 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) diff --git a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql index dc231c2a0..f0ea95b84 100644 --- a/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql +++ b/ee/scripts/schema/db/init_dbs/postgresql/init_schema.sql @@ -108,6 +108,16 @@ CREATE TABLE public.tenants ); +CREATE TABLE public.scim_auth_codes +( + auth_code_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id integer NOT NULL REFERENCES public.tenants (tenant_id) ON DELETE CASCADE, + auth_code text NOT NULL UNIQUE DEFAULT generate_api_key(20), + created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), + used bool NOT NULL DEFAULT FALSE +); + + CREATE TABLE public.roles ( role_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY, From 167d94b7633127b726d447984aac9560ed82feb0 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Mon, 19 May 2025 13:05:49 +0200 Subject: [PATCH 32/34] add openreplay user extension and ui friendly permission values --- ee/api/routers/scim/api.py | 4 +-- ee/api/routers/scim/constants.py | 3 ++ .../open_replay_user_extension_schema.json | 35 +++++++++++++++++++ .../routers/scim/fixtures/resource_type.json | 7 +++- ee/api/routers/scim/fixtures/user_schema.json | 24 +++++-------- ee/api/routers/scim/resource_config.py | 17 +++++++-- ee/api/routers/scim/users.py | 19 ++++++++-- ee/api/schemas/schemas_ee.py | 8 ----- 8 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json diff --git a/ee/api/routers/scim/api.py b/ee/api/routers/scim/api.py index 07482f10c..6ce194a4e 100644 --- a/ee/api/routers/scim/api.py +++ b/ee/api/routers/scim/api.py @@ -292,7 +292,7 @@ async def get_schema(schema_id: str, tenant_id=Depends(auth_required)): user_config = ResourceConfig( - schema_id="urn:ietf:params:scim:schemas:core:2.0:User", + 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, @@ -310,7 +310,7 @@ user_config = ResourceConfig( filter_attribute_mapping=users.filter_attribute_mapping, ) group_config = ResourceConfig( - schema_id="urn:ietf:params:scim:schemas:core:2.0:Group", + 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, diff --git a/ee/api/routers/scim/constants.py b/ee/api/routers/scim/constants.py index 74dd19705..9642c471f 100644 --- a/ee/api/routers/scim/constants.py +++ b/ee/api/routers/scim/constants.py @@ -10,6 +10,9 @@ SCHEMAS = sorted( 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"], ) 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 new file mode 100644 index 000000000..b38eeefa7 --- /dev/null +++ b/ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json @@ -0,0 +1,35 @@ +{ + "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 index 1c56daebe..3879665b1 100644 --- a/ee/api/routers/scim/fixtures/resource_type.json +++ b/ee/api/routers/scim/fixtures/resource_type.json @@ -14,7 +14,12 @@ "lastModified": "2025-04-16T08:37:00Z", "location": "ResourceType/User" }, - "schemaExtensions": [] + "schemaExtensions": [ + { + "schema": "urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", + "required": true + } + ] }, { "schemas": [ diff --git a/ee/api/routers/scim/fixtures/user_schema.json b/ee/api/routers/scim/fixtures/user_schema.json index 528c4a69a..c80a084c5 100644 --- a/ee/api/routers/scim/fixtures/user_schema.json +++ b/ee/api/routers/scim/fixtures/user_schema.json @@ -334,14 +334,18 @@ }, { "name": "entitlements", - "type": "string", + "type": "complex", "multiValued": true, "description": "Entitlements granted to the user.", "required": false, - "caseExact": true, - "canonicalValues": ["SESSION_REPLAY", "METRICS", "ASSIST_LIVE", "ASSIST_CALL", "SPOT_PUBLIC"], "mutability": "readWrite", - "returned": "default" + "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", @@ -372,17 +376,7 @@ { "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" } ] - }, - { - "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", diff --git a/ee/api/routers/scim/resource_config.py b/ee/api/routers/scim/resource_config.py index f89877f65..84bbf57f1 100644 --- a/ee/api/routers/scim/resource_config.py +++ b/ee/api/routers/scim/resource_config.py @@ -3,6 +3,7 @@ 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 @@ -17,7 +18,7 @@ ProviderInput = dict[str, Any] @dataclass class ResourceConfig: - schema_id: str + resource_type_id: str max_chunk_size: int get_active_resource_count: Callable[[int], int] convert_provider_resource_to_client_resource: Callable[ @@ -44,7 +45,19 @@ class ResourceConfig: def get_schema(config: ResourceConfig) -> Schema: - return SCHEMA_IDS_TO_SCHEMA_DETAILS[config.schema_id] + 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( diff --git a/ee/api/routers/scim/users.py b/ee/api/routers/scim/users.py index 9e8623188..41072f5b5 100644 --- a/ee/api/routers/scim/users.py +++ b/ee/api/routers/scim/users.py @@ -11,11 +11,21 @@ from routers.scim.resource_config import ( ClientInput, ProviderInput, ) -from schemas.schemas_ee import ValidIdentityProviderPermissions +from schemas.schemas_ee import Permissions def _is_valid_permission_for_identity_provider(permission: str) -> bool: - return ValidIdentityProviderPermissions.has_value(permission) + 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( @@ -163,7 +173,10 @@ def convert_provider_resource_to_client_resource( ) return { "id": str(provider_resource["user_id"]), - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User", + ], "meta": { "resourceType": "User", "created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"), diff --git a/ee/api/schemas/schemas_ee.py b/ee/api/schemas/schemas_ee.py index bde5c14d0..394f88859 100644 --- a/ee/api/schemas/schemas_ee.py +++ b/ee/api/schemas/schemas_ee.py @@ -28,14 +28,6 @@ class ServicePermissions(str, Enum): READ_NOTES = "SERVICE_READ_NOTES" -class ValidIdentityProviderPermissions(str, Enum): - SESSION_REPLAY = "SESSION_REPLAY" - METRICS = "METRICS" - ASSIST_LIVE = "ASSIST_LIVE" - ASSIST_CALL = "ASSIST_CALL" - SPOT_PUBLIC = "SPOT_PUBLIC" - - class CurrentContext(schemas.CurrentContext): permissions: List[Union[Permissions, ServicePermissions]] = Field(...) service_account: bool = Field(default=False) From 203dcbbb256eadf9fd70085281fb932cc54b79d9 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Fri, 30 May 2025 14:19:31 +0200 Subject: [PATCH 33/34] remove unnecessary line in gitignore --- ee/api/.gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/ee/api/.gitignore b/ee/api/.gitignore index 7140a891d..80beeee41 100644 --- a/ee/api/.gitignore +++ b/ee/api/.gitignore @@ -283,4 +283,3 @@ Pipfile.lock /chalicelib/utils/contextual_validators.py /routers/subs/product_analytics.py /schemas/product_analytics.py -/ee/bin/* From 7324283c2904ae533da777b27b2c72c33f718916 Mon Sep 17 00:00:00 2001 From: Jonathan Griffin Date: Mon, 2 Jun 2025 16:20:30 +0200 Subject: [PATCH 34/34] make compatible with scim2_server --- ee/api/Pipfile | 2 + ee/api/app.py | 61 +- ee/api/chalicelib/utils/SAML2_helper.py | 61 +- ee/api/routers/scim/api.py | 464 ++------------ ee/api/routers/scim/backends.py | 203 ++++++ ee/api/routers/scim/constants.py | 36 -- .../scim/fixtures/custom_resource_types.json | 36 ++ .../routers/scim/fixtures/custom_schemas.json | 32 + .../routers/scim/fixtures/group_schema.json | 166 ----- .../open_replay_user_extension_schema.json | 35 -- .../routers/scim/fixtures/resource_type.json | 41 -- .../scim/fixtures/resource_type_schema.json | 187 ------ .../routers/scim/fixtures/schema_schema.json | 389 ------------ .../fixtures/service_provider_config.json | 41 -- .../service_provider_config_schema.json | 213 ------- ee/api/routers/scim/fixtures/user_schema.json | 387 ------------ ee/api/routers/scim/groups.py | 237 +++---- ee/api/routers/scim/helpers.py | 495 +-------------- ee/api/routers/scim/postgres_resource.py | 14 + ee/api/routers/scim/providers.py | 280 +++++++++ ee/api/routers/scim/resource_config.py | 92 --- ee/api/routers/scim/users.py | 592 ++++++++---------- 22 files changed, 1048 insertions(+), 3016 deletions(-) create mode 100644 ee/api/routers/scim/backends.py delete mode 100644 ee/api/routers/scim/constants.py create mode 100644 ee/api/routers/scim/fixtures/custom_resource_types.json create mode 100644 ee/api/routers/scim/fixtures/custom_schemas.json delete mode 100644 ee/api/routers/scim/fixtures/group_schema.json delete mode 100644 ee/api/routers/scim/fixtures/open_replay_user_extension_schema.json delete mode 100644 ee/api/routers/scim/fixtures/resource_type.json delete mode 100644 ee/api/routers/scim/fixtures/resource_type_schema.json delete mode 100644 ee/api/routers/scim/fixtures/schema_schema.json delete mode 100644 ee/api/routers/scim/fixtures/service_provider_config.json delete mode 100644 ee/api/routers/scim/fixtures/service_provider_config_schema.json delete mode 100644 ee/api/routers/scim/fixtures/user_schema.json create mode 100644 ee/api/routers/scim/postgres_resource.py create mode 100644 ee/api/routers/scim/providers.py delete mode 100644 ee/api/routers/scim/resource_config.py 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)