make compatible with scim2_server

This commit is contained in:
Jonathan Griffin 2025-06-02 16:20:30 +02:00
parent 203dcbbb25
commit 7324283c29
22 changed files with 1048 additions and 3016 deletions

View file

@ -26,6 +26,8 @@ xmlsec = "==1.3.14"
python-multipart = "==0.0.20" python-multipart = "==0.0.20"
redis = "==6.1.0" redis = "==6.1.0"
azure-storage-blob = "==12.25.1" azure-storage-blob = "==12.25.1"
scim2-server = "*"
scim2-models = "*"
[dev-packages] [dev-packages]

View file

@ -9,6 +9,7 @@ from decouple import config
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.wsgi import WSGIMiddleware
from psycopg import AsyncConnection from psycopg import AsyncConnection
from psycopg.rows import dict_row from psycopg.rows import dict_row
from starlette import status 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 crons import core_crons, ee_crons, core_dynamic_crons
from routers import core, core_dynamic from routers import core, core_dynamic
from routers import ee 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 from routers.subs import v1_api_ee
if config("ENABLE_SSO", cast=bool, default=True): if config("ENABLE_SSO", cast=bool, default=True):
@ -34,7 +43,6 @@ logging.basicConfig(level=loglevel)
class ORPYAsyncConnection(AsyncConnection): class ORPYAsyncConnection(AsyncConnection):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, row_factory=dict_row, **kwargs) super().__init__(*args, row_factory=dict_row, **kwargs)
@ -43,7 +51,7 @@ class ORPYAsyncConnection(AsyncConnection):
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
logging.info(">>>>> starting up <<<<<") logging.info(">>>>> starting up <<<<<")
ap_logger = logging.getLogger('apscheduler') ap_logger = logging.getLogger("apscheduler")
ap_logger.setLevel(loglevel) ap_logger.setLevel(loglevel)
app.schedule = AsyncIOScheduler() app.schedule = AsyncIOScheduler()
@ -53,12 +61,23 @@ async def lifespan(app: FastAPI):
await events_queue.init() await events_queue.init()
app.schedule.start() 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) app.schedule.add_job(id=job["func"].__name__, **job)
ap_logger.info(">Scheduled jobs:") ap_logger.info(">Scheduled jobs:")
for job in app.schedule.get_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 = { database = {
"host": config("pg_host", default="localhost"), "host": config("pg_host", default="localhost"),
@ -69,9 +88,12 @@ async def lifespan(app: FastAPI):
"application_name": "AIO" + config("APP_NAME", default="PY"), "application_name": "AIO" + config("APP_NAME", default="PY"),
} }
database = psycopg_pool.AsyncConnectionPool(kwargs=database, connection_class=ORPYAsyncConnection, database = psycopg_pool.AsyncConnectionPool(
kwargs=database,
connection_class=ORPYAsyncConnection,
min_size=config("PG_AIO_MINCONN", cast=int, default=1), min_size=config("PG_AIO_MINCONN", cast=int, default=1),
max_size=config("PG_AIO_MAXCONN", cast=int, default=5), ) max_size=config("PG_AIO_MAXCONN", cast=int, default=5),
)
app.state.postgresql = database app.state.postgresql = database
# App listening # App listening
@ -86,16 +108,24 @@ async def lifespan(app: FastAPI):
await pg_client.terminate() await pg_client.terminate()
app = FastAPI(root_path=config("root_path", default="/api"), docs_url=config("docs_url", default=""), app = FastAPI(
redoc_url=config("redoc_url", default=""), lifespan=lifespan) 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.add_middleware(GZipMiddleware, minimum_size=1000)
@app.middleware('http') @app.middleware("http")
async def or_middleware(request: Request, call_next): async def or_middleware(request: Request, call_next):
from chalicelib.core import unlock from chalicelib.core import unlock
if not unlock.is_valid(): 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: if helper.TRACK_TIME:
now = time.time() now = time.time()
@ -110,8 +140,10 @@ async def or_middleware(request: Request, call_next):
now = time.time() - now now = time.time() - now
if now > 2: if now > 2:
now = round(now, 2) now = round(now, 2)
logging.warning(f"Execution time: {now} s for {request.method}: {request.url.path}") logging.warning(
response.headers["x-robots-tag"] = 'noindex, nofollow' f"Execution time: {now} s for {request.method}: {request.url.path}"
)
response.headers["x-robots-tag"] = "noindex, nofollow"
return response return response
@ -162,3 +194,4 @@ if config("ENABLE_SSO", cast=bool, default=True):
app.include_router(scim.public_app) app.include_router(scim.public_app)
app.include_router(scim.app) app.include_router(scim.app)
app.include_router(scim.app_apikey) app.include_router(scim.app_apikey)
app.mount("/sso/scim/v2", WSGIMiddleware(scim.scim_app))

View file

@ -23,20 +23,18 @@ SAML2 = {
"entityId": config("SITE_URL") + API_PREFIX + "/sso/saml2/metadata/", "entityId": config("SITE_URL") + API_PREFIX + "/sso/saml2/metadata/",
"assertionConsumerService": { "assertionConsumerService": {
"url": config("SITE_URL") + API_PREFIX + "/sso/saml2/acs/", "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": { "singleLogoutService": {
"url": config("SITE_URL") + API_PREFIX + "/sso/saml2/sls/", "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", "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
"x509cert": config("sp_crt", default=""), "x509cert": config("sp_crt", default=""),
"privateKey": config("sp_key", default=""), "privateKey": config("sp_key", default=""),
}, },
"security": { "security": {"requestedAuthnContext": False},
"requestedAuthnContext": False "idp": None,
},
"idp": None
} }
# in case tenantKey is included in the URL # 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") print("SAML2_MD_URL provided, getting IdP metadata config")
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser 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") idp = idp_data.get("idp")
if SAML2["idp"] is None: if SAML2["idp"] is None:
if len(config("idp_entityId", default="")) > 0 \ if (
and len(config("idp_sso_url", default="")) > 0 \ len(config("idp_entityId", default="")) > 0
and len(config("idp_x509cert", default="")) > 0: and len(config("idp_sso_url", default="")) > 0
and len(config("idp_x509cert", default="")) > 0
):
idp = { idp = {
"entityId": config("idp_entityId"), "entityId": config("idp_entityId"),
"singleSignOnService": { "singleSignOnService": {
"url": config("idp_sso_url"), "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: if len(config("idp_sls_url", default="")) > 0:
idp["singleLogoutService"] = { idp["singleLogoutService"] = {
"url": config("idp_sls_url"), "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: if idp is None:
@ -106,8 +108,8 @@ async def prepare_request(request: Request):
session = {} session = {}
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields # If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
headers = request.headers headers = request.headers
proto = headers.get('x-forwarded-proto', 'http') proto = headers.get("x-forwarded-proto", "http")
url_data = urlparse('%s://%s' % (proto, headers['host'])) url_data = urlparse("%s://%s" % (proto, headers["host"]))
path = request.url.path path = request.url.path
site_url = urlparse(config("SITE_URL")) site_url = urlparse(config("SITE_URL"))
# to support custom port without changing IDP config # to support custom port without changing IDP config
@ -117,21 +119,21 @@ async def prepare_request(request: Request):
# add / to /acs # add / to /acs
if not path.endswith("/"): if not path.endswith("/"):
path = path + '/' path = path + "/"
if len(API_PREFIX) > 0 and not path.startswith(API_PREFIX): if len(API_PREFIX) > 0 and not path.startswith(API_PREFIX):
path = API_PREFIX + path path = API_PREFIX + path
return { return {
'https': 'on' if proto == 'https' else 'off', "https": "on" if proto == "https" else "off",
'http_host': request.headers['host'] + host_suffix, "http_host": request.headers["host"] + host_suffix,
'server_port': url_data.port, "server_port": url_data.port,
'script_name': path, "script_name": path,
'get_data': request.args.copy(), "get_data": request.args.copy(),
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144 # Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
# 'lowercase_urlencoding': True, # 'lowercase_urlencoding': True,
'post_data': request.form.copy(), "post_data": request.form.copy(),
'cookie': {"session": session}, "cookie": {"session": session},
'request': request "request": request,
} }
@ -140,8 +142,11 @@ def is_saml2_available():
def get_saml2_provider(): def get_saml2_provider():
return config("idp_name", default="saml2") if is_saml2_available() and len( return (
config("idp_name", default="saml2")) > 0 else None 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): 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 redirect_to_link2:
if len(config("sso_landing_override", default="")) == 0: 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: else:
return config("sso_landing_override") + query_params return config("sso_landing_override") + query_params

View file

@ -1,33 +1,58 @@
import logging from scim2_server import utils
from copy import deepcopy
from enum import Enum
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 urllib.parse import urlencode
from chalicelib.utils import pg_client from chalicelib.utils import pg_client
from fastapi import Depends, HTTPException, Query, Response, Request from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import RedirectResponse
from psycopg2 import errors
from chalicelib.core import roles
from chalicelib.utils.scim_auth import ( from chalicelib.utils.scim_auth import (
auth_optional,
auth_required,
create_tokens, create_tokens,
verify_refresh_token, verify_refresh_token,
) )
from routers.base import get_routers
from routers.scim.constants import (
SERVICE_PROVIDER_CONFIG, b = PostgresBackend()
RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS, b.register_postgres_resource(
SCHEMA_IDS_TO_SCHEMA_DETAILS, "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") public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2")
@ -137,404 +162,3 @@ async def get_authorize(
params["state"] = state params["state"] = state
url = f"{redirect_uri}?{urlencode(params)}" url = f"{redirect_uri}?{urlencode(params)}"
return RedirectResponse(url) 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)

View file

@ -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

View file

@ -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
}

View file

@ -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"
}
}]

View file

@ -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"
}
}
]

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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": []
}
]

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -4,30 +4,14 @@ from psycopg2.extensions import AsIs
from chalicelib.utils import pg_client from chalicelib.utils import pg_client
from routers.scim import helpers from routers.scim import helpers
from routers.scim.resource_config import (
ProviderResource,
ClientResource,
ResourceId,
ClientInput,
ProviderInput,
)
from scim2_models import Error, Resource
def convert_client_resource_update_input_to_provider_resource_update_input( from scim2_server.utils import SCIMException
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
def convert_provider_resource_to_client_resource( def convert_provider_resource_to_client_resource(
provider_resource: ProviderResource, provider_resource: dict,
) -> ClientResource: ) -> dict:
members = provider_resource["users"] or [] members = provider_resource["users"] or []
return { return {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "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( "lastModified": provider_resource["updated_at"].strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
), ),
"location": f"Groups/{provider_resource['role_id']}",
}, },
"displayName": provider_resource["name"], "displayName": provider_resource["name"],
"members": [ "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: def query_resources(tenant_id: int) -> list[dict]:
where_and_clauses = [ query = _main_select_query(tenant_id)
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: 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( cur.execute(
f""" f"""
SELECT COUNT(*) UPDATE public.users
FROM public.roles SET
WHERE {where_clause} 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( def update_resource(tenant_id: int, resource: Resource) -> dict | None:
tenant_id: int, resource_id: int | None = None, filter_clause: str | None = None item = _update_resource_sql(
) -> str: 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 = [ where_and_clauses = [
f"roles.tenant_id = {tenant_id}", f"roles.tenant_id = {tenant_id}",
"roles.deleted_at IS NULL", "roles.deleted_at IS NULL",
] ]
if resource_id is not None: if resource_id is not None:
where_and_clauses.append(f"roles.role_id = {resource_id}") 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) where_clause = " AND ".join(where_and_clauses)
return f""" return f"""
SELECT 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( def _update_resource_sql(
resource_id: int, resource_id: int,
tenant_id: int, tenant_id: int,
@ -235,42 +197,7 @@ def _update_resource_sql(
WHERE WHERE
roles.role_id = {resource_id} roles.role_id = {resource_id}
AND roles.tenant_id = {tenant_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") cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1")
return cur.fetchone() 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,
)

View file

@ -1,7 +1,8 @@
from typing import Any, Literal from typing import Any, Literal
from copy import deepcopy
import re
from chalicelib.utils import pg_client from chalicelib.utils import pg_client
from scim2_models import Schema, Resource, ResourceType
import os
import json
def safe_mogrify_array( def safe_mogrify_array(
@ -15,481 +16,29 @@ def safe_mogrify_array(
return result return result
def convert_query_str_to_list(query_str: str | None) -> list[str]: def load_json_resource(json_name: str) -> dict:
if query_str is None: with open(json_name) as f:
return None return json.load(f)
return query_str.split(",")
def get_all_attribute_names(schema: dict[str, Any]) -> list[str]: def load_scim_resource(
result = [] json_name: str, type_: type[Resource]
) -> dict[str, type[Resource]]:
def _walk(attrs, prefix=None): ret = {}
for attr in attrs: definitions = load_json_resource(json_name)
name = attr["name"] for d in definitions:
path = f"{prefix}.{name}" if prefix else name model = type_.model_validate(d)
result.append(path) ret[model.id] = model
if attr["type"] == "complex": return ret
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( def load_custom_schemas() -> dict[str, Schema]:
schema: dict[str, Any], json_name = os.path.join("routers", "scim", "fixtures", "custom_schemas.json")
) -> list[str]: return load_scim_resource(json_name, Schema)
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( def load_custom_resource_types() -> dict[str, ResourceType]:
obj: dict[str, Any], json_name = os.path.join(
attributes_query_str: str | None, "routers", "scim", "fixtures", "custom_resource_types.json"
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
) )
included_attributes = convert_query_str_to_list(attributes_query_str) return load_scim_resource(json_name, ResourceType)
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()

View file

@ -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]

View file

@ -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))

View file

@ -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

View file

@ -1,168 +1,12 @@
from typing import Any
from datetime import datetime
from psycopg2.extensions import AsIs
from routers.scim import helpers from routers.scim import helpers
from chalicelib.utils import pg_client from chalicelib.utils import pg_client
from routers.scim.resource_config import ( from scim2_models import Resource
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},
)
)
def convert_provider_resource_to_client_resource( def convert_provider_resource_to_client_resource(
provider_resource: ProviderResource, provider_resource: dict,
) -> ClientResource: ) -> dict:
groups = [] groups = []
if provider_resource["role_id"]: if provider_resource["role_id"]:
groups.append( groups.append(
@ -175,7 +19,8 @@ def convert_provider_resource_to_client_resource(
"id": str(provider_resource["user_id"]), "id": str(provider_resource["user_id"]),
"schemas": [ "schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User", "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": { "meta": {
"resourceType": "User", "resourceType": "User",
@ -183,7 +28,6 @@ def convert_provider_resource_to_client_resource(
"lastModified": provider_resource["updated_at"].strftime( "lastModified": provider_resource["updated_at"].strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
), ),
"location": f"Users/{provider_resource['user_id']}",
}, },
"userName": provider_resource["email"], "userName": provider_resource["email"],
"externalId": provider_resource["internal_id"], "externalId": provider_resource["internal_id"],
@ -193,129 +37,180 @@ def convert_provider_resource_to_client_resource(
"displayName": provider_resource["name"] or provider_resource["email"], "displayName": provider_resource["name"] or provider_resource["email"],
"active": provider_resource["deleted_at"] is None, "active": provider_resource["deleted_at"] is None,
"groups": groups, "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: def query_resources(tenant_id: int) -> list[dict]:
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: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
f""" 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 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( def get_resource(resource_id: str, tenant_id: int) -> dict | None:
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: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
f""" 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 FROM public.users
WHERE {where_clause} LEFT JOIN public.roles ON roles.role_id = users.role_id
LIMIT {limit} WHERE users.tenant_id = {tenant_id} AND users.deleted_at IS NULL AND users.user_id = {resource_id}
OFFSET {offset};
""" """
) )
return cur.fetchall() item = cur.fetchone()
if item:
return convert_provider_resource_to_client_resource(item)
return None
def get_provider_resource( def delete_resource(resource_id: str, tenatn_id: int) -> None:
resource_id: ResourceId, tenant_id: int with pg_client.PostgresClient() as cur:
) -> ProviderResource | None: 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: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
""" """
SELECT * SELECT *
FROM public.users FROM public.users
WHERE WHERE email = %(email)s
users.user_id = %(user_id)s """,
AND users.tenant_id = %(tenant_id)s {"email": resource.user_name},
AND users.deleted_at IS NULL )
LIMIT 1; )
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, "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( def create_resource(tenant_id: int, resource: Resource) -> dict:
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:
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
@ -340,29 +235,50 @@ def create_provider_resource(
""", """,
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"email": email, "email": resource.user_name,
"name": name, "name": " ".join(
"internal_id": internal_id, [
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() item = cur.fetchone()
_update_role_projects_and_permissions( return convert_provider_resource_to_client_resource(item)
user["role_id"], project_keys, permissions, cur
)
return user
def restore_provider_resource( def update_resource(tenant_id: int, resource: Resource) -> dict | None:
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:
with pg_client.PostgresClient() as cur: 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.execute(
cur.mogrify( cur.mogrify(
""" """
@ -373,87 +289,83 @@ def restore_provider_resource(
email = %(email)s, email = %(email)s,
name = %(name)s, name = %(name)s,
internal_id = %(internal_id)s, internal_id = %(internal_id)s,
deleted_at = NULL, updated_at = now()
created_at = now(), WHERE user_id = %(user_id)s
updated_at = now(),
api_key = default,
jwt_iat = NULL,
weekly_report = default
WHERE users.email = %(email)s
RETURNING * 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 FROM u
LEFT JOIN public.roles ON roles.role_id = u.role_id
""", """,
{ {
"user_id": resource.id,
"tenant_id": tenant_id, "tenant_id": tenant_id,
"email": email, "email": resource.user_name,
"name": name, "name": " ".join(
"internal_id": internal_id, [
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() item = cur.fetchone()
_update_role_projects_and_permissions( return convert_provider_resource_to_client_resource(item)
user["role_id"], project_keys, permissions, cur
)
return user
def _update_resource_sql( def _update_role_projects_and_permissions(
resource_id: int, role_id: int,
tenant_id: int, project_keys: list[str] | None,
project_keys: list[str] | None = None, permissions: list[str] | None,
permissions: list[str] | None = None, cur: pg_client.PostgresClient,
**kwargs: dict[str, Any], ) -> None:
) -> dict[str, Any]: all_projects = "true" if not project_keys else "false"
with pg_client.PostgresClient() as cur: project_key_clause = helpers.safe_mogrify_array(project_keys, "varchar", cur)
kwargs["updated_at"] = datetime.now() permission_clause = helpers.safe_mogrify_array(permissions, "varchar", cur)
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( cur.execute(
f""" f"""
UPDATE public.users UPDATE public.roles
SET {set_clause} SET
WHERE updated_at = now(),
users.user_id = {resource_id} all_projects = {all_projects},
AND users.tenant_id = {tenant_id} permissions = {permission_clause}
AND users.deleted_at IS NULL WHERE role_id = {role_id}
RETURNING * RETURNING *
""" """
) )
user = cur.fetchone() cur.execute(
role_id = user["role_id"] f"""
_update_role_projects_and_permissions(role_id, project_keys, permissions, cur) DELETE FROM public.roles_projects
return user WHERE roles_projects.role_id = {role_id}
"""
)
def rewrite_provider_resource( cur.execute(
resource_id: int, f"""
tenant_id: int, INSERT INTO public.roles_projects (role_id, project_id)
email: str, SELECT {role_id}, projects.project_id
name: str = "", FROM public.projects
internal_id: str | None = None, WHERE projects.project_key = ANY({project_key_clause})
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(
resource_id: int,
tenant_id: int,
**kwargs,
):
return _update_resource_sql(resource_id, tenant_id, **kwargs)