make compatible with scim2_server
This commit is contained in:
parent
203dcbbb25
commit
7324283c29
22 changed files with 1048 additions and 3016 deletions
|
|
@ -26,6 +26,8 @@ xmlsec = "==1.3.14"
|
|||
python-multipart = "==0.0.20"
|
||||
redis = "==6.1.0"
|
||||
azure-storage-blob = "==12.25.1"
|
||||
scim2-server = "*"
|
||||
scim2-models = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from decouple import config
|
|||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.wsgi import WSGIMiddleware
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from starlette import status
|
||||
|
|
@ -21,7 +22,15 @@ from chalicelib.utils import pg_client, ch_client
|
|||
from crons import core_crons, ee_crons, core_dynamic_crons
|
||||
from routers import core, core_dynamic
|
||||
from routers import ee
|
||||
from routers.subs import insights, metrics, v1_api, health, usability_tests, spot, product_analytics
|
||||
from routers.subs import (
|
||||
insights,
|
||||
metrics,
|
||||
v1_api,
|
||||
health,
|
||||
usability_tests,
|
||||
spot,
|
||||
product_analytics,
|
||||
)
|
||||
from routers.subs import v1_api_ee
|
||||
|
||||
if config("ENABLE_SSO", cast=bool, default=True):
|
||||
|
|
@ -34,7 +43,6 @@ logging.basicConfig(level=loglevel)
|
|||
|
||||
|
||||
class ORPYAsyncConnection(AsyncConnection):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, row_factory=dict_row, **kwargs)
|
||||
|
||||
|
|
@ -43,7 +51,7 @@ class ORPYAsyncConnection(AsyncConnection):
|
|||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
logging.info(">>>>> starting up <<<<<")
|
||||
ap_logger = logging.getLogger('apscheduler')
|
||||
ap_logger = logging.getLogger("apscheduler")
|
||||
ap_logger.setLevel(loglevel)
|
||||
|
||||
app.schedule = AsyncIOScheduler()
|
||||
|
|
@ -53,12 +61,23 @@ async def lifespan(app: FastAPI):
|
|||
await events_queue.init()
|
||||
app.schedule.start()
|
||||
|
||||
for job in core_crons.cron_jobs + core_dynamic_crons.cron_jobs + traces.cron_jobs + ee_crons.ee_cron_jobs:
|
||||
for job in (
|
||||
core_crons.cron_jobs
|
||||
+ core_dynamic_crons.cron_jobs
|
||||
+ traces.cron_jobs
|
||||
+ ee_crons.ee_cron_jobs
|
||||
):
|
||||
app.schedule.add_job(id=job["func"].__name__, **job)
|
||||
|
||||
ap_logger.info(">Scheduled jobs:")
|
||||
for job in app.schedule.get_jobs():
|
||||
ap_logger.info({"Name": str(job.id), "Run Frequency": str(job.trigger), "Next Run": str(job.next_run_time)})
|
||||
ap_logger.info(
|
||||
{
|
||||
"Name": str(job.id),
|
||||
"Run Frequency": str(job.trigger),
|
||||
"Next Run": str(job.next_run_time),
|
||||
}
|
||||
)
|
||||
|
||||
database = {
|
||||
"host": config("pg_host", default="localhost"),
|
||||
|
|
@ -69,9 +88,12 @@ async def lifespan(app: FastAPI):
|
|||
"application_name": "AIO" + config("APP_NAME", default="PY"),
|
||||
}
|
||||
|
||||
database = psycopg_pool.AsyncConnectionPool(kwargs=database, connection_class=ORPYAsyncConnection,
|
||||
min_size=config("PG_AIO_MINCONN", cast=int, default=1),
|
||||
max_size=config("PG_AIO_MAXCONN", cast=int, default=5), )
|
||||
database = psycopg_pool.AsyncConnectionPool(
|
||||
kwargs=database,
|
||||
connection_class=ORPYAsyncConnection,
|
||||
min_size=config("PG_AIO_MINCONN", cast=int, default=1),
|
||||
max_size=config("PG_AIO_MAXCONN", cast=int, default=5),
|
||||
)
|
||||
app.state.postgresql = database
|
||||
|
||||
# App listening
|
||||
|
|
@ -86,16 +108,24 @@ async def lifespan(app: FastAPI):
|
|||
await pg_client.terminate()
|
||||
|
||||
|
||||
app = FastAPI(root_path=config("root_path", default="/api"), docs_url=config("docs_url", default=""),
|
||||
redoc_url=config("redoc_url", default=""), lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
root_path=config("root_path", default="/api"),
|
||||
docs_url=config("docs_url", default=""),
|
||||
redoc_url=config("redoc_url", default=""),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
@app.middleware('http')
|
||||
@app.middleware("http")
|
||||
async def or_middleware(request: Request, call_next):
|
||||
from chalicelib.core import unlock
|
||||
|
||||
if not unlock.is_valid():
|
||||
return JSONResponse(content={"errors": ["expired license"]}, status_code=status.HTTP_403_FORBIDDEN)
|
||||
return JSONResponse(
|
||||
content={"errors": ["expired license"]},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
if helper.TRACK_TIME:
|
||||
now = time.time()
|
||||
|
|
@ -110,8 +140,10 @@ async def or_middleware(request: Request, call_next):
|
|||
now = time.time() - now
|
||||
if now > 2:
|
||||
now = round(now, 2)
|
||||
logging.warning(f"Execution time: {now} s for {request.method}: {request.url.path}")
|
||||
response.headers["x-robots-tag"] = 'noindex, nofollow'
|
||||
logging.warning(
|
||||
f"Execution time: {now} s for {request.method}: {request.url.path}"
|
||||
)
|
||||
response.headers["x-robots-tag"] = "noindex, nofollow"
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -162,3 +194,4 @@ if config("ENABLE_SSO", cast=bool, default=True):
|
|||
app.include_router(scim.public_app)
|
||||
app.include_router(scim.app)
|
||||
app.include_router(scim.app_apikey)
|
||||
app.mount("/sso/scim/v2", WSGIMiddleware(scim.scim_app))
|
||||
|
|
|
|||
|
|
@ -23,20 +23,18 @@ SAML2 = {
|
|||
"entityId": config("SITE_URL") + API_PREFIX + "/sso/saml2/metadata/",
|
||||
"assertionConsumerService": {
|
||||
"url": config("SITE_URL") + API_PREFIX + "/sso/saml2/acs/",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
||||
},
|
||||
"singleLogoutService": {
|
||||
"url": config("SITE_URL") + API_PREFIX + "/sso/saml2/sls/",
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||
},
|
||||
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"x509cert": config("sp_crt", default=""),
|
||||
"privateKey": config("sp_key", default=""),
|
||||
},
|
||||
"security": {
|
||||
"requestedAuthnContext": False
|
||||
},
|
||||
"idp": None
|
||||
"security": {"requestedAuthnContext": False},
|
||||
"idp": None,
|
||||
}
|
||||
|
||||
# in case tenantKey is included in the URL
|
||||
|
|
@ -50,25 +48,29 @@ if config("SAML2_MD_URL", default=None) is not None and len(config("SAML2_MD_URL
|
|||
print("SAML2_MD_URL provided, getting IdP metadata config")
|
||||
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
|
||||
|
||||
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(config("SAML2_MD_URL", default=None))
|
||||
idp_data = OneLogin_Saml2_IdPMetadataParser.parse_remote(
|
||||
config("SAML2_MD_URL", default=None)
|
||||
)
|
||||
idp = idp_data.get("idp")
|
||||
|
||||
if SAML2["idp"] is None:
|
||||
if len(config("idp_entityId", default="")) > 0 \
|
||||
and len(config("idp_sso_url", default="")) > 0 \
|
||||
and len(config("idp_x509cert", default="")) > 0:
|
||||
if (
|
||||
len(config("idp_entityId", default="")) > 0
|
||||
and len(config("idp_sso_url", default="")) > 0
|
||||
and len(config("idp_x509cert", default="")) > 0
|
||||
):
|
||||
idp = {
|
||||
"entityId": config("idp_entityId"),
|
||||
"singleSignOnService": {
|
||||
"url": config("idp_sso_url"),
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||
},
|
||||
"x509cert": config("idp_x509cert")
|
||||
"x509cert": config("idp_x509cert"),
|
||||
}
|
||||
if len(config("idp_sls_url", default="")) > 0:
|
||||
idp["singleLogoutService"] = {
|
||||
"url": config("idp_sls_url"),
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||
}
|
||||
|
||||
if idp is None:
|
||||
|
|
@ -106,8 +108,8 @@ async def prepare_request(request: Request):
|
|||
session = {}
|
||||
# If server is behind proxys or balancers use the HTTP_X_FORWARDED fields
|
||||
headers = request.headers
|
||||
proto = headers.get('x-forwarded-proto', 'http')
|
||||
url_data = urlparse('%s://%s' % (proto, headers['host']))
|
||||
proto = headers.get("x-forwarded-proto", "http")
|
||||
url_data = urlparse("%s://%s" % (proto, headers["host"]))
|
||||
path = request.url.path
|
||||
site_url = urlparse(config("SITE_URL"))
|
||||
# to support custom port without changing IDP config
|
||||
|
|
@ -117,21 +119,21 @@ async def prepare_request(request: Request):
|
|||
|
||||
# add / to /acs
|
||||
if not path.endswith("/"):
|
||||
path = path + '/'
|
||||
path = path + "/"
|
||||
if len(API_PREFIX) > 0 and not path.startswith(API_PREFIX):
|
||||
path = API_PREFIX + path
|
||||
|
||||
return {
|
||||
'https': 'on' if proto == 'https' else 'off',
|
||||
'http_host': request.headers['host'] + host_suffix,
|
||||
'server_port': url_data.port,
|
||||
'script_name': path,
|
||||
'get_data': request.args.copy(),
|
||||
"https": "on" if proto == "https" else "off",
|
||||
"http_host": request.headers["host"] + host_suffix,
|
||||
"server_port": url_data.port,
|
||||
"script_name": path,
|
||||
"get_data": request.args.copy(),
|
||||
# Uncomment if using ADFS as IdP, https://github.com/onelogin/python-saml/pull/144
|
||||
# 'lowercase_urlencoding': True,
|
||||
'post_data': request.form.copy(),
|
||||
'cookie': {"session": session},
|
||||
'request': request
|
||||
"post_data": request.form.copy(),
|
||||
"cookie": {"session": session},
|
||||
"request": request,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -140,8 +142,11 @@ def is_saml2_available():
|
|||
|
||||
|
||||
def get_saml2_provider():
|
||||
return config("idp_name", default="saml2") if is_saml2_available() and len(
|
||||
config("idp_name", default="saml2")) > 0 else None
|
||||
return (
|
||||
config("idp_name", default="saml2")
|
||||
if is_saml2_available() and len(config("idp_name", default="saml2")) > 0
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def get_landing_URL(query_params: dict = None, redirect_to_link2=False):
|
||||
|
|
@ -152,7 +157,9 @@ def get_landing_URL(query_params: dict = None, redirect_to_link2=False):
|
|||
|
||||
if redirect_to_link2:
|
||||
if len(config("sso_landing_override", default="")) == 0:
|
||||
logging.warning("SSO trying to redirect to custom URL, but sso_landing_override env var is empty")
|
||||
logging.warning(
|
||||
"SSO trying to redirect to custom URL, but sso_landing_override env var is empty"
|
||||
)
|
||||
else:
|
||||
return config("sso_landing_override") + query_params
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,58 @@
|
|||
import logging
|
||||
from copy import deepcopy
|
||||
from enum import Enum
|
||||
from scim2_server import utils
|
||||
|
||||
|
||||
from routers.base import get_routers
|
||||
from routers.scim.providers import MultiTenantProvider
|
||||
from routers.scim.backends import PostgresBackend
|
||||
from routers.scim.postgres_resource import PostgresResource
|
||||
from routers.scim import users, groups, helpers
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from chalicelib.utils import pg_client
|
||||
|
||||
from fastapi import Depends, HTTPException, Query, Response, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from psycopg2 import errors
|
||||
|
||||
from chalicelib.core import roles
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from chalicelib.utils.scim_auth import (
|
||||
auth_optional,
|
||||
auth_required,
|
||||
create_tokens,
|
||||
verify_refresh_token,
|
||||
)
|
||||
from routers.base import get_routers
|
||||
from routers.scim.constants import (
|
||||
SERVICE_PROVIDER_CONFIG,
|
||||
RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS,
|
||||
SCHEMA_IDS_TO_SCHEMA_DETAILS,
|
||||
|
||||
|
||||
b = PostgresBackend()
|
||||
b.register_postgres_resource(
|
||||
"User",
|
||||
PostgresResource(
|
||||
query_resources=users.query_resources,
|
||||
get_resource=users.get_resource,
|
||||
create_resource=users.create_resource,
|
||||
search_existing=users.search_existing,
|
||||
restore_resource=users.restore_resource,
|
||||
delete_resource=users.delete_resource,
|
||||
update_resource=users.update_resource,
|
||||
),
|
||||
)
|
||||
b.register_postgres_resource(
|
||||
"Group",
|
||||
PostgresResource(
|
||||
query_resources=groups.query_resources,
|
||||
get_resource=groups.get_resource,
|
||||
create_resource=groups.create_resource,
|
||||
search_existing=groups.search_existing,
|
||||
restore_resource=None,
|
||||
delete_resource=groups.delete_resource,
|
||||
update_resource=groups.update_resource,
|
||||
),
|
||||
)
|
||||
from routers.scim import helpers, groups, users
|
||||
from routers.scim.resource_config import ResourceConfig
|
||||
from routers.scim import resource_config as api_helper
|
||||
|
||||
scim_app = MultiTenantProvider(b)
|
||||
|
||||
for schema in utils.load_default_schemas().values():
|
||||
scim_app.register_schema(schema)
|
||||
for schema in helpers.load_custom_schemas().values():
|
||||
scim_app.register_schema(schema)
|
||||
for resource_type in helpers.load_custom_resource_types().values():
|
||||
scim_app.register_resource_type(resource_type)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
public_app, app, app_apikey = get_routers(prefix="/sso/scim/v2")
|
||||
|
||||
|
|
@ -137,404 +162,3 @@ async def get_authorize(
|
|||
params["state"] = state
|
||||
url = f"{redirect_uri}?{urlencode(params)}"
|
||||
return RedirectResponse(url)
|
||||
|
||||
|
||||
def _not_found_error_response(resource_id: int):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": f"Resource {resource_id} not found",
|
||||
"status": "404",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _uniqueness_error_response():
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "One or more of the attribute values are already in use or are reserved.",
|
||||
"status": "409",
|
||||
"scimType": "uniqueness",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _mutability_error_response():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "The attempted modification is not compatible with the target attribute's mutability or current state.",
|
||||
"status": "400",
|
||||
"scimType": "mutability",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _operation_not_permitted_error_response():
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "Operation is not permitted based on the supplied authorization",
|
||||
"status": "403",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _invalid_value_error_response():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": "A required value was missing, or the value specified was not compatible with the operation or attribtue type, or resource schema.",
|
||||
"status": "400",
|
||||
"scimType": "invalidValue",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _internal_server_error_response(detail: str):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
||||
"detail": detail,
|
||||
"status": "500",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# note(jon): it was recommended to make this endpoint partially open
|
||||
# so that clients can view the `authenticationSchemes` prior to being authenticated.
|
||||
@public_app.get("/ServiceProviderConfig")
|
||||
async def get_service_provider_config(
|
||||
r: Request, tenant_id: str | None = Depends(auth_optional)
|
||||
):
|
||||
is_authenticated = tenant_id is not None
|
||||
if not is_authenticated:
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"schemas": SERVICE_PROVIDER_CONFIG["schemas"],
|
||||
"authenticationSchemes": SERVICE_PROVIDER_CONFIG[
|
||||
"authenticationSchemes"
|
||||
],
|
||||
"meta": SERVICE_PROVIDER_CONFIG["meta"],
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content=SERVICE_PROVIDER_CONFIG)
|
||||
|
||||
|
||||
@public_app.get("/ResourceTypes", dependencies=[Depends(auth_required)])
|
||||
async def get_resource_types(filter_param: str | None = Query(None, alias="filter")):
|
||||
if filter_param is not None:
|
||||
return _operation_not_permitted_error_response()
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"totalResults": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS),
|
||||
"itemsPerPage": len(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS),
|
||||
"startIndex": 1,
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"Resources": list(RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS.values()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@public_app.get("/ResourceTypes/{resource_id}", dependencies=[Depends(auth_required)])
|
||||
async def get_resource_type(resource_id: str):
|
||||
if resource_id not in RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS:
|
||||
return _not_found_error_response(resource_id)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=RESOURCE_TYPE_IDS_TO_RESOURCE_TYPE_DETAILS[resource_id],
|
||||
)
|
||||
|
||||
|
||||
@public_app.get("/Schemas", dependencies=[Depends(auth_required)])
|
||||
async def get_schemas(filter_param: str | None = Query(None, alias="filter")):
|
||||
if filter_param is not None:
|
||||
return _operation_not_permitted_error_response()
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"totalResults": len(SCHEMA_IDS_TO_SCHEMA_DETAILS),
|
||||
"itemsPerPage": len(SCHEMA_IDS_TO_SCHEMA_DETAILS),
|
||||
"startIndex": 1,
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"Resources": [
|
||||
value for _, value in sorted(SCHEMA_IDS_TO_SCHEMA_DETAILS.items())
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@public_app.get("/Schemas/{schema_id}")
|
||||
async def get_schema(schema_id: str, tenant_id=Depends(auth_required)):
|
||||
if schema_id not in SCHEMA_IDS_TO_SCHEMA_DETAILS:
|
||||
return _not_found_error_response(schema_id)
|
||||
schema = deepcopy(SCHEMA_IDS_TO_SCHEMA_DETAILS[schema_id])
|
||||
if schema_id == "urn:ietf:params:scim:schemas:core:2.0:User":
|
||||
db_roles = roles.get_roles(tenant_id)
|
||||
role_names = [role["name"] for role in db_roles]
|
||||
user_type_attribute = next(
|
||||
filter(lambda x: x["name"] == "userType", schema["attributes"])
|
||||
)
|
||||
user_type_attribute["canonicalValues"] = role_names
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content=schema,
|
||||
)
|
||||
|
||||
|
||||
user_config = ResourceConfig(
|
||||
resource_type_id="User",
|
||||
max_chunk_size=10,
|
||||
get_active_resource_count=users.get_active_resource_count,
|
||||
convert_provider_resource_to_client_resource=users.convert_provider_resource_to_client_resource,
|
||||
get_provider_resource_chunk=users.get_provider_resource_chunk,
|
||||
get_provider_resource=users.get_provider_resource,
|
||||
convert_client_resource_creation_input_to_provider_resource_creation_input=users.convert_client_resource_creation_input_to_provider_resource_creation_input,
|
||||
get_provider_resource_from_unique_fields=users.get_provider_resource_from_unique_fields,
|
||||
restore_provider_resource=users.restore_provider_resource,
|
||||
create_provider_resource=users.create_provider_resource,
|
||||
delete_provider_resource=users.delete_provider_resource,
|
||||
convert_client_resource_rewrite_input_to_provider_resource_rewrite_input=users.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input,
|
||||
rewrite_provider_resource=users.rewrite_provider_resource,
|
||||
convert_client_resource_update_input_to_provider_resource_update_input=users.convert_client_resource_update_input_to_provider_resource_update_input,
|
||||
update_provider_resource=users.update_provider_resource,
|
||||
filter_attribute_mapping=users.filter_attribute_mapping,
|
||||
)
|
||||
group_config = ResourceConfig(
|
||||
resource_type_id="Group",
|
||||
max_chunk_size=10,
|
||||
get_active_resource_count=groups.get_active_resource_count,
|
||||
convert_provider_resource_to_client_resource=groups.convert_provider_resource_to_client_resource,
|
||||
get_provider_resource_chunk=groups.get_provider_resource_chunk,
|
||||
get_provider_resource=groups.get_provider_resource,
|
||||
convert_client_resource_creation_input_to_provider_resource_creation_input=groups.convert_client_resource_creation_input_to_provider_resource_creation_input,
|
||||
get_provider_resource_from_unique_fields=lambda **kwargs: None,
|
||||
restore_provider_resource=None,
|
||||
create_provider_resource=groups.create_provider_resource,
|
||||
delete_provider_resource=groups.delete_provider_resource,
|
||||
convert_client_resource_rewrite_input_to_provider_resource_rewrite_input=groups.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input,
|
||||
rewrite_provider_resource=groups.rewrite_provider_resource,
|
||||
convert_client_resource_update_input_to_provider_resource_update_input=groups.convert_client_resource_update_input_to_provider_resource_update_input,
|
||||
update_provider_resource=groups.update_provider_resource,
|
||||
filter_attribute_mapping=groups.filter_attribute_mapping,
|
||||
)
|
||||
|
||||
RESOURCE_TYPE_TO_RESOURCE_CONFIG: dict[str, ResourceConfig] = {
|
||||
"Users": user_config,
|
||||
"Groups": group_config,
|
||||
}
|
||||
|
||||
|
||||
class SCIMResource(str, Enum):
|
||||
USERS = "Users"
|
||||
GROUPS = "Groups"
|
||||
|
||||
|
||||
@public_app.get("/{resource_type}")
|
||||
async def get_resources(
|
||||
resource_type: SCIMResource,
|
||||
tenant_id=Depends(auth_required),
|
||||
requested_start_index_one_indexed: int = Query(1, alias="startIndex"),
|
||||
requested_items_per_page: int | None = Query(None, alias="count"),
|
||||
attributes: str | None = Query(None),
|
||||
excluded_attributes: str | None = Query(None, alias="excludedAttributes"),
|
||||
filter: str | None = Query(None),
|
||||
):
|
||||
config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
filter_clause = helpers.scim_to_sql_where(filter, config.filter_attribute_mapping())
|
||||
total_resources = config.get_active_resource_count(tenant_id, filter_clause)
|
||||
start_index_one_indexed = max(1, requested_start_index_one_indexed)
|
||||
offset = start_index_one_indexed - 1
|
||||
limit = min(
|
||||
max(0, requested_items_per_page or config.max_chunk_size), config.max_chunk_size
|
||||
)
|
||||
provider_resources = config.get_provider_resource_chunk(
|
||||
offset, tenant_id, limit, filter_clause
|
||||
)
|
||||
client_resources = [
|
||||
api_helper.convert_provider_resource_to_client_resource(
|
||||
config, provider_resource, attributes, excluded_attributes
|
||||
)
|
||||
for provider_resource in provider_resources
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||
"totalResults": total_resources,
|
||||
"startIndex": start_index_one_indexed,
|
||||
"itemsPerPage": len(client_resources),
|
||||
"Resources": client_resources,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@public_app.get("/{resource_type}/{resource_id}")
|
||||
async def get_resource(
|
||||
resource_type: SCIMResource,
|
||||
resource_id: int | str,
|
||||
tenant_id=Depends(auth_required),
|
||||
attributes: list[str] | None = Query(None),
|
||||
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
|
||||
):
|
||||
resource_config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
resource = api_helper.get_resource(
|
||||
resource_config,
|
||||
resource_id,
|
||||
tenant_id,
|
||||
attributes,
|
||||
excluded_attributes,
|
||||
)
|
||||
if not resource:
|
||||
return _not_found_error_response(resource_id)
|
||||
return JSONResponse(status_code=200, content=resource)
|
||||
|
||||
|
||||
@public_app.post("/{resource_type}")
|
||||
async def create_resource(
|
||||
resource_type: SCIMResource,
|
||||
r: Request,
|
||||
tenant_id=Depends(auth_required),
|
||||
attributes: list[str] | None = Query(None),
|
||||
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
|
||||
):
|
||||
config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
payload = await r.json()
|
||||
try:
|
||||
provider_resource_input = config.convert_client_resource_creation_input_to_provider_resource_creation_input(
|
||||
tenant_id,
|
||||
payload,
|
||||
)
|
||||
except KeyError:
|
||||
return _invalid_value_error_response()
|
||||
existing_provider_resource = config.get_provider_resource_from_unique_fields(
|
||||
**provider_resource_input
|
||||
)
|
||||
if (
|
||||
existing_provider_resource
|
||||
and existing_provider_resource.get("deleted_at") is None
|
||||
):
|
||||
return _uniqueness_error_response()
|
||||
if (
|
||||
existing_provider_resource
|
||||
and existing_provider_resource.get("deleted_at") is not None
|
||||
):
|
||||
provider_resource = config.restore_provider_resource(
|
||||
tenant_id=tenant_id, **provider_resource_input
|
||||
)
|
||||
else:
|
||||
provider_resource = config.create_provider_resource(
|
||||
tenant_id=tenant_id, **provider_resource_input
|
||||
)
|
||||
client_resource = api_helper.convert_provider_resource_to_client_resource(
|
||||
config, provider_resource, attributes, excluded_attributes
|
||||
)
|
||||
response = JSONResponse(status_code=201, content=client_resource)
|
||||
response.headers["Location"] = client_resource["meta"]["location"]
|
||||
return response
|
||||
|
||||
|
||||
@public_app.delete("/{resource_type}/{resource_id}")
|
||||
async def delete_resource(
|
||||
resource_type: SCIMResource,
|
||||
resource_id: str,
|
||||
tenant_id=Depends(auth_required),
|
||||
):
|
||||
# note(jon): this can be a soft or a hard delete
|
||||
config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
resource = api_helper.get_resource(config, resource_id, tenant_id)
|
||||
if not resource:
|
||||
return _not_found_error_response(resource_id)
|
||||
config.delete_provider_resource(resource_id, tenant_id)
|
||||
return Response(status_code=204, content="")
|
||||
|
||||
|
||||
@public_app.put("/{resource_type}/{resource_id}")
|
||||
async def put_resource(
|
||||
resource_type: SCIMResource,
|
||||
resource_id: int | str,
|
||||
r: Request,
|
||||
tenant_id=Depends(auth_required),
|
||||
attributes: list[str] | None = Query(None),
|
||||
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
|
||||
):
|
||||
config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
client_resource = api_helper.get_resource(config, resource_id, tenant_id)
|
||||
if not client_resource:
|
||||
return _not_found_error_response(resource_id)
|
||||
schema = api_helper.get_schema(config)
|
||||
payload = await r.json()
|
||||
try:
|
||||
client_resource_input = helpers.filter_mutable_attributes(
|
||||
schema, payload, client_resource
|
||||
)
|
||||
except ValueError:
|
||||
return _mutability_error_response()
|
||||
provider_resource_input = (
|
||||
config.convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
|
||||
tenant_id, client_resource_input
|
||||
)
|
||||
)
|
||||
try:
|
||||
provider_resource = config.rewrite_provider_resource(
|
||||
resource_id,
|
||||
tenant_id,
|
||||
**provider_resource_input,
|
||||
)
|
||||
except errors.UniqueViolation:
|
||||
return _uniqueness_error_response()
|
||||
except Exception as e:
|
||||
return _internal_server_error_response(str(e))
|
||||
client_resource = api_helper.convert_provider_resource_to_client_resource(
|
||||
config, provider_resource, attributes, excluded_attributes
|
||||
)
|
||||
return JSONResponse(status_code=200, content=client_resource)
|
||||
|
||||
|
||||
@public_app.patch("/{resource_type}/{resource_id}")
|
||||
async def patch_resource(
|
||||
resource_type: SCIMResource,
|
||||
resource_id: int | str,
|
||||
r: Request,
|
||||
tenant_id=Depends(auth_required),
|
||||
attributes: list[str] | None = Query(None),
|
||||
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
|
||||
):
|
||||
config = RESOURCE_TYPE_TO_RESOURCE_CONFIG[resource_type]
|
||||
client_resource = api_helper.get_resource(config, resource_id, tenant_id)
|
||||
if not client_resource:
|
||||
return _not_found_error_response(resource_id)
|
||||
schema = api_helper.get_schema(config)
|
||||
payload = await r.json()
|
||||
_, changes = helpers.apply_scim_patch(
|
||||
payload["Operations"], client_resource, schema
|
||||
)
|
||||
client_resource_input = {
|
||||
k: new_value for k, (old_value, new_value) in changes.items()
|
||||
}
|
||||
provider_resource_input = (
|
||||
config.convert_client_resource_update_input_to_provider_resource_update_input(
|
||||
tenant_id, client_resource_input
|
||||
)
|
||||
)
|
||||
try:
|
||||
provider_resource = config.update_provider_resource(
|
||||
resource_id, tenant_id, **provider_resource_input
|
||||
)
|
||||
except errors.UniqueViolation:
|
||||
return _uniqueness_error_response()
|
||||
except Exception as e:
|
||||
return _internal_server_error_response(str(e))
|
||||
client_resource = api_helper.convert_provider_resource_to_client_resource(
|
||||
config, provider_resource, attributes, excluded_attributes
|
||||
)
|
||||
return JSONResponse(status_code=200, content=client_resource)
|
||||
|
|
|
|||
203
ee/api/routers/scim/backends.py
Normal file
203
ee/api/routers/scim/backends.py
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
36
ee/api/routers/scim/fixtures/custom_resource_types.json
Normal file
36
ee/api/routers/scim/fixtures/custom_resource_types.json
Normal 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"
|
||||
}
|
||||
}]
|
||||
32
ee/api/routers/scim/fixtures/custom_schemas.json
Normal file
32
ee/api/routers/scim/fixtures/custom_schemas.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -4,30 +4,14 @@ from psycopg2.extensions import AsIs
|
|||
|
||||
from chalicelib.utils import pg_client
|
||||
from routers.scim import helpers
|
||||
from routers.scim.resource_config import (
|
||||
ProviderResource,
|
||||
ClientResource,
|
||||
ResourceId,
|
||||
ClientInput,
|
||||
ProviderInput,
|
||||
)
|
||||
|
||||
|
||||
def convert_client_resource_update_input_to_provider_resource_update_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
result = {}
|
||||
if "displayName" in client_input:
|
||||
result["name"] = client_input["displayName"]
|
||||
if "members" in client_input:
|
||||
members = client_input["members"] or []
|
||||
result["user_ids"] = [int(member["value"]) for member in members]
|
||||
return result
|
||||
from scim2_models import Error, Resource
|
||||
from scim2_server.utils import SCIMException
|
||||
|
||||
|
||||
def convert_provider_resource_to_client_resource(
|
||||
provider_resource: ProviderResource,
|
||||
) -> ClientResource:
|
||||
provider_resource: dict,
|
||||
) -> dict:
|
||||
members = provider_resource["users"] or []
|
||||
return {
|
||||
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||
|
|
@ -38,7 +22,6 @@ def convert_provider_resource_to_client_resource(
|
|||
"lastModified": provider_resource["updated_at"].strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
"location": f"Groups/{provider_resource['role_id']}",
|
||||
},
|
||||
"displayName": provider_resource["name"],
|
||||
"members": [
|
||||
|
|
@ -52,36 +35,97 @@ def convert_provider_resource_to_client_resource(
|
|||
}
|
||||
|
||||
|
||||
def get_active_resource_count(tenant_id: int, filter_clause: str | None = None) -> int:
|
||||
where_and_clauses = [
|
||||
f"roles.tenant_id = {tenant_id}",
|
||||
"roles.deleted_at IS NULL",
|
||||
]
|
||||
if filter_clause is not None:
|
||||
where_and_clauses.append(filter_clause)
|
||||
where_clause = " AND ".join(where_and_clauses)
|
||||
def query_resources(tenant_id: int) -> list[dict]:
|
||||
query = _main_select_query(tenant_id)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(query)
|
||||
items = cur.fetchall()
|
||||
return [convert_provider_resource_to_client_resource(item) for item in items]
|
||||
|
||||
|
||||
def get_resource(resource_id: str, tenant_id: int) -> dict | None:
|
||||
query = _main_select_query(tenant_id, resource_id)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(query)
|
||||
item = cur.fetchone()
|
||||
if item:
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
return None
|
||||
|
||||
|
||||
def delete_resource(resource_id: str, tenant_id: int) -> None:
|
||||
_update_resource_sql(
|
||||
resource_id=resource_id,
|
||||
tenant_id=tenant_id,
|
||||
deleted_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def search_existing(tenant_id: int, resource: Resource) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def create_resource(tenant_id: int, resource: Resource) -> dict:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
user_ids = (
|
||||
[int(x.value) for x in resource.members] if resource.members else None
|
||||
)
|
||||
user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur)
|
||||
try:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
INSERT INTO public.roles (
|
||||
name,
|
||||
tenant_id
|
||||
)
|
||||
VALUES (
|
||||
%(name)s,
|
||||
%(tenant_id)s
|
||||
)
|
||||
RETURNING role_id
|
||||
""",
|
||||
{
|
||||
"name": resource.display_name,
|
||||
"tenant_id": tenant_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
raise SCIMException(Error.make_invalid_value_error())
|
||||
role_id = cur.fetchone()["role_id"]
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM public.roles
|
||||
WHERE {where_clause}
|
||||
UPDATE public.users
|
||||
SET
|
||||
updated_at = now(),
|
||||
role_id = {role_id}
|
||||
WHERE users.user_id = ANY({user_id_clause})
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()["count"]
|
||||
cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1")
|
||||
item = cur.fetchone()
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
|
||||
|
||||
def _main_select_query(
|
||||
tenant_id: int, resource_id: int | None = None, filter_clause: str | None = None
|
||||
) -> str:
|
||||
def update_resource(tenant_id: int, resource: Resource) -> dict | None:
|
||||
item = _update_resource_sql(
|
||||
resource_id=resource.id,
|
||||
tenant_id=tenant_id,
|
||||
name=resource.display_name,
|
||||
user_ids=[int(x.value) for x in resource.members],
|
||||
deleted_at=None,
|
||||
)
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
|
||||
|
||||
def _main_select_query(tenant_id: int, resource_id: str | None = None) -> str:
|
||||
where_and_clauses = [
|
||||
f"roles.tenant_id = {tenant_id}",
|
||||
"roles.deleted_at IS NULL",
|
||||
]
|
||||
if resource_id is not None:
|
||||
where_and_clauses.append(f"roles.role_id = {resource_id}")
|
||||
if filter_clause is not None:
|
||||
where_and_clauses.append(filter_clause)
|
||||
where_clause = " AND ".join(where_and_clauses)
|
||||
return f"""
|
||||
SELECT
|
||||
|
|
@ -108,88 +152,6 @@ def _main_select_query(
|
|||
"""
|
||||
|
||||
|
||||
def get_provider_resource_chunk(
|
||||
offset: int, tenant_id: int, limit: int, filter_clause: str | None = None
|
||||
) -> list[ProviderResource]:
|
||||
query = _main_select_query(tenant_id, filter_clause=filter_clause)
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(f"{query} LIMIT {limit} OFFSET {offset}")
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def filter_attribute_mapping() -> dict[str, str]:
|
||||
return {"displayName": "roles.name"}
|
||||
|
||||
|
||||
def get_provider_resource(
|
||||
resource_id: ResourceId, tenant_id: int
|
||||
) -> ProviderResource | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1")
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def convert_client_resource_creation_input_to_provider_resource_creation_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
return {
|
||||
"name": client_input["displayName"],
|
||||
"user_ids": [
|
||||
int(member["value"]) for member in client_input.get("members", [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
return {
|
||||
"name": client_input["displayName"],
|
||||
"user_ids": [
|
||||
int(member["value"]) for member in client_input.get("members", [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_provider_resource(
|
||||
name: str,
|
||||
tenant_id: int,
|
||||
user_ids: list[str] | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> ProviderResource:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
kwargs["name"] = name
|
||||
kwargs["tenant_id"] = tenant_id
|
||||
column_fragments = [
|
||||
cur.mogrify("%s", (AsIs(k),)).decode("utf-8") for k in kwargs.keys()
|
||||
]
|
||||
column_clause = ", ".join(column_fragments)
|
||||
value_fragments = [
|
||||
cur.mogrify("%s", (v,)).decode("utf-8") for v in kwargs.values()
|
||||
]
|
||||
value_clause = ", ".join(value_fragments)
|
||||
user_id_clause = helpers.safe_mogrify_array(user_ids, "int", cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO public.roles ({column_clause})
|
||||
VALUES ({value_clause})
|
||||
RETURNING role_id
|
||||
"""
|
||||
)
|
||||
role_id = cur.fetchone()["role_id"]
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE public.users
|
||||
SET
|
||||
updated_at = now(),
|
||||
role_id = {role_id}
|
||||
WHERE users.user_id = ANY({user_id_clause})
|
||||
"""
|
||||
)
|
||||
cur.execute(f"{_main_select_query(tenant_id, role_id)} LIMIT 1")
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def _update_resource_sql(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
|
|
@ -235,42 +197,7 @@ def _update_resource_sql(
|
|||
WHERE
|
||||
roles.role_id = {resource_id}
|
||||
AND roles.tenant_id = {tenant_id}
|
||||
AND roles.deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
cur.execute(f"{_main_select_query(tenant_id, resource_id)} LIMIT 1")
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None:
|
||||
_update_resource_sql(
|
||||
resource_id=resource_id,
|
||||
tenant_id=tenant_id,
|
||||
deleted_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def rewrite_provider_resource(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
name: str,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return _update_resource_sql(
|
||||
resource_id=resource_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def update_provider_resource(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
**kwargs: dict[str, Any],
|
||||
):
|
||||
return _update_resource_sql(
|
||||
resource_id=resource_id,
|
||||
tenant_id=tenant_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
from typing import Any, Literal
|
||||
from copy import deepcopy
|
||||
import re
|
||||
from chalicelib.utils import pg_client
|
||||
from scim2_models import Schema, Resource, ResourceType
|
||||
import os
|
||||
import json
|
||||
|
||||
|
||||
def safe_mogrify_array(
|
||||
|
|
@ -15,481 +16,29 @@ def safe_mogrify_array(
|
|||
return result
|
||||
|
||||
|
||||
def convert_query_str_to_list(query_str: str | None) -> list[str]:
|
||||
if query_str is None:
|
||||
return None
|
||||
return query_str.split(",")
|
||||
def load_json_resource(json_name: str) -> dict:
|
||||
with open(json_name) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_all_attribute_names(schema: dict[str, Any]) -> list[str]:
|
||||
result = []
|
||||
|
||||
def _walk(attrs, prefix=None):
|
||||
for attr in attrs:
|
||||
name = attr["name"]
|
||||
path = f"{prefix}.{name}" if prefix else name
|
||||
result.append(path)
|
||||
if attr["type"] == "complex":
|
||||
sub = attr.get("subAttributes") or attr.get("attributes") or []
|
||||
_walk(sub, path)
|
||||
|
||||
_walk(schema["attributes"])
|
||||
return result
|
||||
def load_scim_resource(
|
||||
json_name: str, type_: type[Resource]
|
||||
) -> dict[str, type[Resource]]:
|
||||
ret = {}
|
||||
definitions = load_json_resource(json_name)
|
||||
for d in definitions:
|
||||
model = type_.model_validate(d)
|
||||
ret[model.id] = model
|
||||
return ret
|
||||
|
||||
|
||||
def get_all_attribute_names_where_returned_is_always(
|
||||
schema: dict[str, Any],
|
||||
) -> list[str]:
|
||||
result = []
|
||||
|
||||
def _walk(attrs, prefix=None):
|
||||
for attr in attrs:
|
||||
name = attr["name"]
|
||||
path = f"{prefix}.{name}" if prefix else name
|
||||
if attr["returned"] == "always":
|
||||
result.append(path)
|
||||
if attr["type"] == "complex":
|
||||
sub = attr.get("subAttributes") or attr.get("attributes") or []
|
||||
_walk(sub, path)
|
||||
|
||||
_walk(schema["attributes"])
|
||||
return result
|
||||
def load_custom_schemas() -> dict[str, Schema]:
|
||||
json_name = os.path.join("routers", "scim", "fixtures", "custom_schemas.json")
|
||||
return load_scim_resource(json_name, Schema)
|
||||
|
||||
|
||||
def filter_attributes(
|
||||
obj: dict[str, Any],
|
||||
attributes_query_str: str | None,
|
||||
excluded_attributes_query_str: str | None,
|
||||
schema: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
all_attributes = get_all_attribute_names(schema)
|
||||
always_returned_attributes = get_all_attribute_names_where_returned_is_always(
|
||||
schema
|
||||
def load_custom_resource_types() -> dict[str, ResourceType]:
|
||||
json_name = os.path.join(
|
||||
"routers", "scim", "fixtures", "custom_resource_types.json"
|
||||
)
|
||||
included_attributes = convert_query_str_to_list(attributes_query_str)
|
||||
included_attributes = included_attributes or all_attributes
|
||||
included_attributes_set = set(included_attributes).union(
|
||||
set(always_returned_attributes)
|
||||
)
|
||||
excluded_attributes = convert_query_str_to_list(excluded_attributes_query_str)
|
||||
excluded_attributes = excluded_attributes or []
|
||||
excluded_attributes_set = set(excluded_attributes).difference(
|
||||
set(always_returned_attributes)
|
||||
)
|
||||
include_paths = included_attributes_set.difference(excluded_attributes_set)
|
||||
|
||||
include_tree = {}
|
||||
for path in include_paths:
|
||||
parts = path.split(".")
|
||||
node = include_tree
|
||||
for part in parts:
|
||||
node = node.setdefault(part, {})
|
||||
|
||||
def _recurse(o, tree, parent_key=None):
|
||||
if isinstance(o, dict):
|
||||
out = {}
|
||||
for key, subtree in tree.items():
|
||||
if key in o:
|
||||
out[key] = _recurse(o[key], subtree, key)
|
||||
return out
|
||||
if isinstance(o, list):
|
||||
out = [_recurse(item, tree, parent_key) for item in o]
|
||||
return out
|
||||
return o
|
||||
|
||||
result = _recurse(obj, include_tree)
|
||||
return result
|
||||
|
||||
|
||||
def filter_mutable_attributes(
|
||||
schema: dict[str, Any],
|
||||
requested_changes: dict[str, Any],
|
||||
current_values: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
attributes = {attr.get("name"): attr for attr in schema.get("attributes", [])}
|
||||
|
||||
valid_changes = {}
|
||||
|
||||
for attr_name, new_value in requested_changes.items():
|
||||
attr_def = attributes.get(attr_name)
|
||||
if not attr_def:
|
||||
# Unknown attribute: ignore per RFC 7644
|
||||
continue
|
||||
|
||||
mutability = attr_def.get("mutability", "readWrite")
|
||||
|
||||
if mutability == "readWrite" or mutability == "writeOnly":
|
||||
valid_changes[attr_name] = new_value
|
||||
|
||||
elif mutability == "readOnly":
|
||||
# Cannot modify read-only attributes: ignore
|
||||
continue
|
||||
|
||||
elif mutability == "immutable":
|
||||
# Only valid if the new value matches the current value exactly
|
||||
current_value = current_values.get(attr_name)
|
||||
if new_value != current_value:
|
||||
raise ValueError(
|
||||
f"Attribute '{attr_name}' is immutable (cannot change). "
|
||||
f"Current value: {current_value!r}, attempted change: {new_value!r}"
|
||||
)
|
||||
# If it matches, no change is needed (already set)
|
||||
|
||||
return valid_changes
|
||||
|
||||
|
||||
def apply_scim_patch(
|
||||
operations: list[dict[str, Any]], resource: dict[str, Any], schema: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Apply SCIM patch operations to a resource based on schema.
|
||||
Returns (updated_resource, changes) where `updated_resource` is the new SCIM
|
||||
resource dict and `changes` maps attribute or path to (old_value, new_value).
|
||||
Additions have old_value=None if attribute didn't exist; removals have new_value=None.
|
||||
For add/remove on list-valued attributes, changes record the full list before/after.
|
||||
"""
|
||||
# Deep copy to avoid mutating original
|
||||
updated = deepcopy(resource)
|
||||
changes = {}
|
||||
|
||||
# Allowed attributes from schema
|
||||
allowed_attrs = {attr["name"]: attr for attr in schema.get("attributes", [])}
|
||||
|
||||
for op in operations:
|
||||
op_type = op.get("op", "").strip().lower()
|
||||
path = op.get("path")
|
||||
value = op.get("value")
|
||||
|
||||
if not path:
|
||||
# Top-level merge
|
||||
if op_type in ("add", "replace"):
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(
|
||||
"When path is not provided, value must be a dict of attributes to merge."
|
||||
)
|
||||
for attr, val in value.items():
|
||||
if attr not in allowed_attrs:
|
||||
raise ValueError(
|
||||
f"Attribute '{attr}' not defined in SCIM schema"
|
||||
)
|
||||
old = updated.get(attr)
|
||||
updated[attr] = val if val is not None else updated.pop(attr, None)
|
||||
changes[attr] = (old, val)
|
||||
else:
|
||||
raise ValueError(f"Unsupported operation without path: {op_type}")
|
||||
continue
|
||||
|
||||
tokens = parse_scim_path(path)
|
||||
|
||||
# Detect simple top-level list add/remove
|
||||
if (
|
||||
op_type in ("add", "remove")
|
||||
and len(tokens) == 1
|
||||
and isinstance(tokens[0], str)
|
||||
):
|
||||
attr = tokens[0]
|
||||
if attr not in allowed_attrs:
|
||||
raise ValueError(f"Attribute '{attr}' not defined in SCIM schema")
|
||||
current_list = updated.get(attr, [])
|
||||
if isinstance(current_list, list):
|
||||
before = deepcopy(current_list)
|
||||
if op_type == "add":
|
||||
# Ensure list exists
|
||||
updated.setdefault(attr, [])
|
||||
# Append new items
|
||||
items = value if isinstance(value, list) else [value]
|
||||
updated[attr].extend(items)
|
||||
else: # remove
|
||||
# Remove items matching filter if value not provided
|
||||
# For remove on list without filter, remove all values equal to value
|
||||
if value is None:
|
||||
updated.pop(attr, None)
|
||||
else:
|
||||
# filter value items out
|
||||
items = value if isinstance(value, list) else [value]
|
||||
updated[attr] = [
|
||||
e for e in updated.get(attr, []) if e not in items
|
||||
]
|
||||
after = deepcopy(updated.get(attr, []))
|
||||
changes[attr] = (before, after)
|
||||
continue
|
||||
|
||||
# For other operations, get old value and apply normally
|
||||
old_val = get_by_path(updated, tokens)
|
||||
|
||||
if op_type == "add":
|
||||
set_by_path(updated, tokens, value)
|
||||
elif op_type == "replace":
|
||||
if value is None:
|
||||
remove_by_path(updated, tokens)
|
||||
else:
|
||||
set_by_path(updated, tokens, value)
|
||||
elif op_type == "remove":
|
||||
remove_by_path(updated, tokens)
|
||||
else:
|
||||
raise ValueError(f"Unsupported operation type: {op_type}")
|
||||
|
||||
# Record change for non-list or nested paths
|
||||
new_val = None if op_type == "remove" else get_by_path(updated, tokens)
|
||||
changes[path] = (old_val, new_val)
|
||||
|
||||
return updated, changes
|
||||
|
||||
|
||||
def parse_scim_path(path):
|
||||
"""
|
||||
Parse a SCIM-style path (e.g., 'emails[type eq "work"].value') into a list
|
||||
of tokens. Each token is either a string attribute name or a tuple
|
||||
(attr, filter_attr, filter_value) for list-filtering.
|
||||
"""
|
||||
tokens = []
|
||||
# Regex matches segments like attr or attr[filter] where filter is e.g. type eq "work"
|
||||
segment_re = re.compile(r"([^\.\[]+)(?:\[(.*?)\])?")
|
||||
for match in segment_re.finditer(path):
|
||||
attr = match.group(1)
|
||||
filt = match.group(2)
|
||||
if filt:
|
||||
# Support simple equality filter of form: subAttr eq "value"
|
||||
m = re.match(r"\s*(\w+)\s+eq\s+\"([^\"]+)\"", filt)
|
||||
if not m:
|
||||
raise ValueError(f"Unsupported filter expression: {filt}")
|
||||
filter_attr, filter_val = m.group(1), m.group(2)
|
||||
tokens.append((attr, filter_attr, filter_val))
|
||||
else:
|
||||
tokens.append(attr)
|
||||
return tokens
|
||||
|
||||
|
||||
def get_by_path(doc, tokens):
|
||||
"""
|
||||
Retrieve a value from nested dicts/lists using parsed tokens.
|
||||
Returns None if any step is missing.
|
||||
"""
|
||||
cur = doc
|
||||
for token in tokens:
|
||||
if cur is None:
|
||||
return None
|
||||
if isinstance(token, tuple):
|
||||
attr, fattr, fval = token
|
||||
lst = cur.get(attr)
|
||||
if not isinstance(lst, list):
|
||||
return None
|
||||
# Find first dict element matching filter
|
||||
for elem in lst:
|
||||
if isinstance(elem, dict) and elem.get(fattr) == fval:
|
||||
cur = elem
|
||||
break
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(token)
|
||||
elif isinstance(cur, list) and isinstance(token, int):
|
||||
if 0 <= token < len(cur):
|
||||
cur = cur[token]
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def set_by_path(doc, tokens, value):
|
||||
"""
|
||||
Set a value in nested dicts/lists using parsed tokens.
|
||||
Creates intermediate dicts/lists as needed.
|
||||
"""
|
||||
cur = doc
|
||||
for i, token in enumerate(tokens):
|
||||
last = i == len(tokens) - 1
|
||||
if isinstance(token, tuple):
|
||||
attr, fattr, fval = token
|
||||
lst = cur.setdefault(attr, [])
|
||||
if not isinstance(lst, list):
|
||||
raise ValueError(f"Expected list at attribute '{attr}'")
|
||||
# Find existing entry
|
||||
idx = next(
|
||||
(
|
||||
j
|
||||
for j, e in enumerate(lst)
|
||||
if isinstance(e, dict) and e.get(fattr) == fval
|
||||
),
|
||||
None,
|
||||
)
|
||||
if idx is None:
|
||||
if last:
|
||||
lst.append(value)
|
||||
return
|
||||
else:
|
||||
new = {}
|
||||
lst.append(new)
|
||||
cur = new
|
||||
else:
|
||||
if last:
|
||||
lst[idx] = value
|
||||
return
|
||||
cur = lst[idx]
|
||||
|
||||
else:
|
||||
if last:
|
||||
if value is None:
|
||||
if isinstance(cur, dict):
|
||||
cur.pop(token, None)
|
||||
else:
|
||||
cur[token] = value
|
||||
else:
|
||||
cur = cur.setdefault(token, {})
|
||||
|
||||
|
||||
def remove_by_path(doc, tokens):
|
||||
"""
|
||||
Remove a value in nested dicts/lists using parsed tokens.
|
||||
Does nothing if path not present.
|
||||
"""
|
||||
cur = doc
|
||||
for i, token in enumerate(tokens):
|
||||
last = i == len(tokens) - 1
|
||||
if isinstance(token, tuple):
|
||||
attr, fattr, fval = token
|
||||
lst = cur.get(attr)
|
||||
if not isinstance(lst, list):
|
||||
return
|
||||
for j, elem in enumerate(lst):
|
||||
if isinstance(elem, dict) and elem.get(fattr) == fval:
|
||||
if last:
|
||||
lst.pop(j)
|
||||
return
|
||||
cur = elem
|
||||
break
|
||||
else:
|
||||
return
|
||||
else:
|
||||
if last:
|
||||
if isinstance(cur, dict):
|
||||
cur.pop(token, None)
|
||||
elif isinstance(cur, list) and isinstance(token, int):
|
||||
if 0 <= token < len(cur):
|
||||
cur.pop(token)
|
||||
return
|
||||
else:
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(token)
|
||||
elif isinstance(cur, list) and isinstance(token, int):
|
||||
cur = cur[token] if 0 <= token < len(cur) else None
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
class SCIMFilterParser:
|
||||
_TOK_RE = re.compile(
|
||||
r"""
|
||||
(?:"[^"]*"|'[^']*')| # double- or single-quoted string
|
||||
\band\b|\bor\b|\bnot\b|
|
||||
\beq\b|\bne\b|\bco\b|\bsw\b|\bew\b|\bgt\b|\blt\b|\bge\b|\ble\b|\bpr\b|
|
||||
[()]| # parentheses
|
||||
[^\s()]+ # bare token
|
||||
""",
|
||||
re.IGNORECASE | re.VERBOSE,
|
||||
)
|
||||
_NUMERIC_RE = re.compile(r"^-?\d+(\.\d+)?$")
|
||||
|
||||
def __init__(self, text: str, attr_map: dict[str, str]):
|
||||
self.tokens = [tok for tok in self._TOK_RE.findall(text)]
|
||||
self.pos = 0
|
||||
self.attr_map = attr_map
|
||||
|
||||
def peek(self) -> str | None:
|
||||
return self.tokens[self.pos].lower() if self.pos < len(self.tokens) else None
|
||||
|
||||
def next(self) -> str:
|
||||
tok = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
def parse(self) -> str:
|
||||
expr = self._parse_or()
|
||||
if self.pos != len(self.tokens):
|
||||
raise ValueError(f"Unexpected token at end: {self.peek()}")
|
||||
return expr
|
||||
|
||||
def _parse_or(self) -> str:
|
||||
left = self._parse_and()
|
||||
while self.peek() == "or":
|
||||
self.next()
|
||||
right = self._parse_and()
|
||||
left = f"({left} OR {right})"
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> str:
|
||||
left = self._parse_not()
|
||||
while self.peek() == "and":
|
||||
self.next()
|
||||
right = self._parse_not()
|
||||
left = f"({left} AND {right})"
|
||||
return left
|
||||
|
||||
def _parse_not(self) -> str:
|
||||
if self.peek() == "not":
|
||||
self.next()
|
||||
inner = self._parse_simple()
|
||||
return f"(NOT {inner})"
|
||||
return self._parse_simple()
|
||||
|
||||
def _parse_simple(self) -> str:
|
||||
if self.peek() == "(":
|
||||
self.next()
|
||||
expr = self._parse_or()
|
||||
if self.next() != ")":
|
||||
raise ValueError("Missing closing parenthesis")
|
||||
return f"({expr})"
|
||||
return self._parse_comparison()
|
||||
|
||||
def _parse_comparison(self) -> str:
|
||||
raw_attr = self.next()
|
||||
col = self.attr_map.get(raw_attr, raw_attr)
|
||||
op = self.next().lower()
|
||||
|
||||
if op == "pr":
|
||||
return f"{col} IS NOT NULL"
|
||||
|
||||
val = self.next()
|
||||
|
||||
# strip quotes if present (single or double)
|
||||
if (val.startswith('"') and val.endswith('"')) or (
|
||||
val.startswith("'") and val.endswith("'")
|
||||
):
|
||||
inner = val[1:-1].replace("'", "''")
|
||||
sql_val = f"'{inner}'"
|
||||
elif self._NUMERIC_RE.match(val):
|
||||
sql_val = val
|
||||
else:
|
||||
inner = val.replace("'", "''")
|
||||
sql_val = f"'{inner}'"
|
||||
|
||||
if op == "eq":
|
||||
return f"{col} = {sql_val}"
|
||||
if op == "ne":
|
||||
return f"{col} <> {sql_val}"
|
||||
if op == "co":
|
||||
return f"{col} LIKE '%' || {sql_val} || '%'"
|
||||
if op == "sw":
|
||||
return f"{col} LIKE {sql_val} || '%'"
|
||||
if op == "ew":
|
||||
return f"{col} LIKE '%' || {sql_val}"
|
||||
if op in ("gt", "lt", "ge", "le"):
|
||||
sql_ops = {"gt": ">", "lt": "<", "ge": ">=", "le": "<="}
|
||||
return f"{col} {sql_ops[op]} {sql_val}"
|
||||
|
||||
raise ValueError(f"Unknown operator: {op}")
|
||||
|
||||
|
||||
def scim_to_sql_where(filter_str: str | None, attr_map: dict[str, str]) -> str | None:
|
||||
"""
|
||||
Convert a SCIM filter into an SQL WHERE fragment,
|
||||
mapping SCIM attributes per attr_map and correctly quoting
|
||||
both single- and double-quoted strings.
|
||||
"""
|
||||
if filter_str is None:
|
||||
return None
|
||||
parser = SCIMFilterParser(filter_str, attr_map)
|
||||
return parser.parse()
|
||||
return load_scim_resource(json_name, ResourceType)
|
||||
|
|
|
|||
14
ee/api/routers/scim/postgres_resource.py
Normal file
14
ee/api/routers/scim/postgres_resource.py
Normal 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]
|
||||
280
ee/api/routers/scim/providers.py
Normal file
280
ee/api/routers/scim/providers.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
@ -1,168 +1,12 @@
|
|||
from typing import Any
|
||||
from datetime import datetime
|
||||
from psycopg2.extensions import AsIs
|
||||
from routers.scim import helpers
|
||||
|
||||
from chalicelib.utils import pg_client
|
||||
from routers.scim.resource_config import (
|
||||
ProviderResource,
|
||||
ClientResource,
|
||||
ResourceId,
|
||||
ClientInput,
|
||||
ProviderInput,
|
||||
)
|
||||
from schemas.schemas_ee import Permissions
|
||||
|
||||
|
||||
def _is_valid_permission_for_identity_provider(permission: str) -> bool:
|
||||
permission_display_to_value_mapping = {
|
||||
"Session Replay": Permissions.SESSION_REPLAY,
|
||||
"Developer Tools": Permissions.DEV_TOOLS,
|
||||
"Dashboard": Permissions.METRICS,
|
||||
"Assist (Live)": Permissions.ASSIST_LIVE,
|
||||
"Assist (Call)": Permissions.ASSIST_CALL,
|
||||
"Spots": Permissions.SPOT,
|
||||
"Change Spot Visibility": Permissions.SPOT_PUBLIC,
|
||||
}
|
||||
value = permission_display_to_value_mapping.get(permission)
|
||||
return Permissions.has_value(value)
|
||||
|
||||
|
||||
def convert_client_resource_update_input_to_provider_resource_update_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
result = {}
|
||||
if "name" in client_input:
|
||||
# note(jon): we're currently not handling the case where the client
|
||||
# send patches of individual name components (e.g. name.middleName)
|
||||
name = client_input.get("name", {}).get("formatted")
|
||||
if name:
|
||||
result["name"] = name
|
||||
if "userName" in client_input:
|
||||
result["email"] = client_input["userName"]
|
||||
if "externalId" in client_input:
|
||||
result["internal_id"] = client_input["externalId"]
|
||||
if "active" in client_input:
|
||||
result["deleted_at"] = None if client_input["active"] else datetime.now()
|
||||
if "projectKeys" in client_input:
|
||||
result["project_keys"] = [item["value"] for item in client_input["projectKeys"]]
|
||||
if "entitlements" in client_input:
|
||||
result["permissions"] = [
|
||||
item
|
||||
for item in client_input["entitlements"]
|
||||
if _is_valid_permission_for_identity_provider(item)
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
name = " ".join(
|
||||
[
|
||||
x
|
||||
for x in [
|
||||
client_input.get("name", {}).get("honorificPrefix"),
|
||||
client_input.get("name", {}).get("givenName"),
|
||||
client_input.get("name", {}).get("middleName"),
|
||||
client_input.get("name", {}).get("familyName"),
|
||||
client_input.get("name", {}).get("honorificSuffix"),
|
||||
]
|
||||
if x
|
||||
]
|
||||
)
|
||||
if not name:
|
||||
name = client_input.get("displayName")
|
||||
result = {
|
||||
"email": client_input["userName"],
|
||||
"internal_id": client_input.get("externalId"),
|
||||
"name": name,
|
||||
"project_keys": [item for item in client_input.get("projectKeys", [])],
|
||||
"permissions": [
|
||||
item
|
||||
for item in client_input.get("entitlements", [])
|
||||
if _is_valid_permission_for_identity_provider(item)
|
||||
],
|
||||
}
|
||||
result = {k: v for k, v in result.items() if v is not None}
|
||||
return result
|
||||
|
||||
|
||||
def convert_client_resource_creation_input_to_provider_resource_creation_input(
|
||||
tenant_id: int, client_input: ClientInput
|
||||
) -> ProviderInput:
|
||||
name = " ".join(
|
||||
[
|
||||
x
|
||||
for x in [
|
||||
client_input.get("name", {}).get("honorificPrefix"),
|
||||
client_input.get("name", {}).get("givenName"),
|
||||
client_input.get("name", {}).get("middleName"),
|
||||
client_input.get("name", {}).get("familyName"),
|
||||
client_input.get("name", {}).get("honorificSuffix"),
|
||||
]
|
||||
if x
|
||||
]
|
||||
)
|
||||
if not name:
|
||||
name = client_input.get("displayName")
|
||||
result = {
|
||||
"email": client_input["userName"],
|
||||
"internal_id": client_input.get("externalId"),
|
||||
"name": name,
|
||||
"project_keys": [item["value"] for item in client_input.get("projectKeys", [])],
|
||||
"permissions": [
|
||||
item
|
||||
for item in client_input.get("entitlements", [])
|
||||
if _is_valid_permission_for_identity_provider(item)
|
||||
],
|
||||
}
|
||||
result = {k: v for k, v in result.items() if v is not None}
|
||||
return result
|
||||
|
||||
|
||||
def filter_attribute_mapping() -> dict[str, str]:
|
||||
return {"userName": "users.email"}
|
||||
|
||||
|
||||
def get_provider_resource_from_unique_fields(
|
||||
email: str, **kwargs: dict[str, Any]
|
||||
) -> ProviderResource | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
SELECT *
|
||||
FROM public.users
|
||||
WHERE users.email = %(email)s
|
||||
""",
|
||||
{"email": email},
|
||||
)
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
UPDATE public.users
|
||||
SET
|
||||
deleted_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE
|
||||
users.user_id = %(user_id)s
|
||||
AND users.tenant_id = %(tenant_id)s
|
||||
""",
|
||||
{"user_id": resource_id, "tenant_id": tenant_id},
|
||||
)
|
||||
)
|
||||
from scim2_models import Resource
|
||||
|
||||
|
||||
def convert_provider_resource_to_client_resource(
|
||||
provider_resource: ProviderResource,
|
||||
) -> ClientResource:
|
||||
provider_resource: dict,
|
||||
) -> dict:
|
||||
groups = []
|
||||
if provider_resource["role_id"]:
|
||||
groups.append(
|
||||
|
|
@ -175,7 +19,8 @@ def convert_provider_resource_to_client_resource(
|
|||
"id": str(provider_resource["user_id"]),
|
||||
"schemas": [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extensions:openreplay:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
|
||||
"urn:ietf:params:scim:schemas:extension:openreplay:2.0:User",
|
||||
],
|
||||
"meta": {
|
||||
"resourceType": "User",
|
||||
|
|
@ -183,7 +28,6 @@ def convert_provider_resource_to_client_resource(
|
|||
"lastModified": provider_resource["updated_at"].strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
"location": f"Users/{provider_resource['user_id']}",
|
||||
},
|
||||
"userName": provider_resource["email"],
|
||||
"externalId": provider_resource["internal_id"],
|
||||
|
|
@ -193,129 +37,180 @@ def convert_provider_resource_to_client_resource(
|
|||
"displayName": provider_resource["name"] or provider_resource["email"],
|
||||
"active": provider_resource["deleted_at"] is None,
|
||||
"groups": groups,
|
||||
"urn:ietf:params:scim:schemas:extension:openreplay:2.0:User": {
|
||||
"permissions": provider_resource.get("permissions") or [],
|
||||
"projectKeys": provider_resource.get("project_keys") or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_active_resource_count(tenant_id: int, filter_clause: str | None = None) -> int:
|
||||
where_and_statements = [
|
||||
f"users.tenant_id = {tenant_id}",
|
||||
"users.deleted_at IS NULL",
|
||||
]
|
||||
if filter_clause is not None:
|
||||
where_and_statements.append(filter_clause)
|
||||
where_clause = " AND ".join(where_and_statements)
|
||||
def query_resources(tenant_id: int) -> list[dict]:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
SELECT
|
||||
users.*,
|
||||
roles.permissions AS permissions,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(projects.project_key)
|
||||
FROM public.projects
|
||||
LEFT JOIN public.roles_projects USING (project_id)
|
||||
WHERE roles_projects.role_id = roles.role_id
|
||||
),
|
||||
'[]'
|
||||
) AS project_keys
|
||||
FROM public.users
|
||||
WHERE {where_clause}
|
||||
LEFT JOIN public.roles ON roles.role_id = users.role_id
|
||||
WHERE users.tenant_id = {tenant_id} AND users.deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()["count"]
|
||||
items = cur.fetchall()
|
||||
return [convert_provider_resource_to_client_resource(item) for item in items]
|
||||
|
||||
|
||||
def get_provider_resource_chunk(
|
||||
offset: int, tenant_id: int, limit: int, filter_clause: str | None = None
|
||||
) -> list[ProviderResource]:
|
||||
where_and_statements = [
|
||||
f"users.tenant_id = {tenant_id}",
|
||||
"users.deleted_at IS NULL",
|
||||
]
|
||||
if filter_clause is not None:
|
||||
where_and_statements.append(filter_clause)
|
||||
where_clause = " AND ".join(where_and_statements)
|
||||
def get_resource(resource_id: str, tenant_id: int) -> dict | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
SELECT
|
||||
users.*,
|
||||
roles.permissions AS permissions,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(projects.project_key)
|
||||
FROM public.projects
|
||||
LEFT JOIN public.roles_projects USING (project_id)
|
||||
WHERE roles_projects.role_id = roles.role_id
|
||||
),
|
||||
'[]'
|
||||
) AS project_keys
|
||||
FROM public.users
|
||||
WHERE {where_clause}
|
||||
LIMIT {limit}
|
||||
OFFSET {offset};
|
||||
LEFT JOIN public.roles ON roles.role_id = users.role_id
|
||||
WHERE users.tenant_id = {tenant_id} AND users.deleted_at IS NULL AND users.user_id = {resource_id}
|
||||
"""
|
||||
)
|
||||
return cur.fetchall()
|
||||
item = cur.fetchone()
|
||||
if item:
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
return None
|
||||
|
||||
|
||||
def get_provider_resource(
|
||||
resource_id: ResourceId, tenant_id: int
|
||||
) -> ProviderResource | None:
|
||||
def delete_resource(resource_id: str, tenatn_id: int) -> None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
UPDATE public.users
|
||||
SET
|
||||
deleted_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE users.user_id = %(user_id)s
|
||||
""",
|
||||
{"user_id": resource_id},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def search_existing(tenant_id: int, resource: Resource) -> dict | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
SELECT *
|
||||
FROM public.users
|
||||
WHERE
|
||||
users.user_id = %(user_id)s
|
||||
AND users.tenant_id = %(tenant_id)s
|
||||
AND users.deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
WHERE email = %(email)s
|
||||
""",
|
||||
{"email": resource.user_name},
|
||||
)
|
||||
)
|
||||
item = cur.fetchone()
|
||||
if item:
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
return None
|
||||
|
||||
|
||||
def restore_resource(tenant_id: int, resource: Resource) -> dict | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
SELECT role_id
|
||||
FROM public.users
|
||||
WHERE user_id = %(user_id)s
|
||||
""",
|
||||
{"user_id": resource.id},
|
||||
)
|
||||
)
|
||||
item = cur.fetchone()
|
||||
if item and item["role_id"] is not None:
|
||||
_update_role_projects_and_permissions(
|
||||
item["role_id"],
|
||||
resource.OpenreplayUser.project_keys,
|
||||
resource.OpenreplayUser.permissions,
|
||||
cur,
|
||||
)
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
WITH u AS (
|
||||
UPDATE public.users
|
||||
SET
|
||||
tenant_id = %(tenant_id)s,
|
||||
email = %(email)s,
|
||||
name = %(name)s,
|
||||
internal_id = %(internal_id)s,
|
||||
deleted_at = NULL,
|
||||
created_at = now(),
|
||||
updated_at = now(),
|
||||
api_key = default,
|
||||
jwt_iat = NULL,
|
||||
weekly_report = default
|
||||
WHERE users.email = %(email)s
|
||||
RETURNING *
|
||||
)
|
||||
SELECT
|
||||
u.*,
|
||||
roles.permissions AS permissions,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(projects.project_key)
|
||||
FROM public.projects
|
||||
LEFT JOIN public.roles_projects USING (project_id)
|
||||
WHERE roles_projects.role_id = roles.role_id
|
||||
),
|
||||
'[]'
|
||||
) AS project_keys
|
||||
FROM u
|
||||
LEFT JOIN public.roles ON roles.role_id = u.role_id
|
||||
""",
|
||||
{
|
||||
"user_id": resource_id,
|
||||
"tenant_id": tenant_id,
|
||||
"email": resource.user_name,
|
||||
"name": " ".join(
|
||||
[
|
||||
x
|
||||
for x in [
|
||||
resource.name.honorific_prefix,
|
||||
resource.name.given_name,
|
||||
resource.name.middle_name,
|
||||
resource.name.family_name,
|
||||
resource.name.honorific_suffix,
|
||||
]
|
||||
if x
|
||||
]
|
||||
)
|
||||
if resource.name
|
||||
else "",
|
||||
"internal_id": resource.external_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
return cur.fetchone()
|
||||
item = cur.fetchone()
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
|
||||
|
||||
def _update_role_projects_and_permissions(
|
||||
role_id: int | None,
|
||||
project_keys: list[str] | None,
|
||||
permissions: list[str] | None,
|
||||
cur: pg_client.PostgresClient,
|
||||
) -> None:
|
||||
if role_id is None:
|
||||
return
|
||||
all_projects = "true" if not project_keys else "false"
|
||||
project_key_clause = helpers.safe_mogrify_array(project_keys, "varchar", cur)
|
||||
permission_clause = helpers.safe_mogrify_array(permissions, "varchar", cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE public.roles
|
||||
SET
|
||||
updated_at = now(),
|
||||
all_projects = {all_projects},
|
||||
permissions = {permission_clause}
|
||||
WHERE role_id = {role_id}
|
||||
RETURNING *
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
DELETE FROM public.roles_projects
|
||||
USING public.projects
|
||||
WHERE
|
||||
projects.project_id = roles_projects.project_id
|
||||
AND roles_projects.role_id = {role_id}
|
||||
AND projects.project_key != ALL({project_key_clause})
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO public.roles_projects (role_id, project_id)
|
||||
SELECT {role_id}, projects.project_id
|
||||
FROM public.projects
|
||||
LEFT JOIN public.roles_projects USING (project_id)
|
||||
WHERE
|
||||
projects.project_key = ANY({project_key_clause})
|
||||
AND roles_projects.role_id IS NULL
|
||||
RETURNING *
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def create_provider_resource(
|
||||
email: str,
|
||||
tenant_id: int,
|
||||
name: str = "",
|
||||
internal_id: str | None = None,
|
||||
project_keys: list[str] | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
) -> ProviderResource:
|
||||
def create_resource(tenant_id: int, resource: Resource) -> dict:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
|
|
@ -340,29 +235,50 @@ def create_provider_resource(
|
|||
""",
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"internal_id": internal_id,
|
||||
"email": resource.user_name,
|
||||
"name": " ".join(
|
||||
[
|
||||
x
|
||||
for x in [
|
||||
resource.name.honorific_prefix,
|
||||
resource.name.given_name,
|
||||
resource.name.middle_name,
|
||||
resource.name.family_name,
|
||||
resource.name.honorific_suffix,
|
||||
]
|
||||
if x
|
||||
]
|
||||
)
|
||||
if resource.name
|
||||
else "",
|
||||
"internal_id": resource.external_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
_update_role_projects_and_permissions(
|
||||
user["role_id"], project_keys, permissions, cur
|
||||
)
|
||||
return user
|
||||
item = cur.fetchone()
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
|
||||
|
||||
def restore_provider_resource(
|
||||
tenant_id: int,
|
||||
email: str,
|
||||
name: str = "",
|
||||
internal_id: str | None = None,
|
||||
project_keys: list[str] | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> ProviderResource:
|
||||
def update_resource(tenant_id: int, resource: Resource) -> dict | None:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
SELECT role_id
|
||||
FROM public.users
|
||||
WHERE user_id = %(user_id)s
|
||||
""",
|
||||
{"user_id": resource.id},
|
||||
)
|
||||
)
|
||||
item = cur.fetchone()
|
||||
if item and item["role_id"] is not None:
|
||||
_update_role_projects_and_permissions(
|
||||
item["role_id"],
|
||||
resource.OpenreplayUser.project_keys,
|
||||
resource.OpenreplayUser.permissions,
|
||||
cur,
|
||||
)
|
||||
cur.execute(
|
||||
cur.mogrify(
|
||||
"""
|
||||
|
|
@ -373,87 +289,83 @@ def restore_provider_resource(
|
|||
email = %(email)s,
|
||||
name = %(name)s,
|
||||
internal_id = %(internal_id)s,
|
||||
deleted_at = NULL,
|
||||
created_at = now(),
|
||||
updated_at = now(),
|
||||
api_key = default,
|
||||
jwt_iat = NULL,
|
||||
weekly_report = default
|
||||
WHERE users.email = %(email)s
|
||||
updated_at = now()
|
||||
WHERE user_id = %(user_id)s
|
||||
RETURNING *
|
||||
)
|
||||
SELECT *
|
||||
SELECT
|
||||
u.*,
|
||||
roles.permissions AS permissions,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(projects.project_key)
|
||||
FROM public.projects
|
||||
LEFT JOIN public.roles_projects USING (project_id)
|
||||
WHERE roles_projects.role_id = roles.role_id
|
||||
),
|
||||
'[]'
|
||||
) AS project_keys
|
||||
FROM u
|
||||
LEFT JOIN public.roles ON roles.role_id = u.role_id
|
||||
""",
|
||||
{
|
||||
"user_id": resource.id,
|
||||
"tenant_id": tenant_id,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"internal_id": internal_id,
|
||||
"email": resource.user_name,
|
||||
"name": " ".join(
|
||||
[
|
||||
x
|
||||
for x in [
|
||||
resource.name.honorific_prefix,
|
||||
resource.name.given_name,
|
||||
resource.name.middle_name,
|
||||
resource.name.family_name,
|
||||
resource.name.honorific_suffix,
|
||||
]
|
||||
if x
|
||||
]
|
||||
)
|
||||
if resource.name
|
||||
else "",
|
||||
"internal_id": resource.external_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
user = cur.fetchone()
|
||||
_update_role_projects_and_permissions(
|
||||
user["role_id"], project_keys, permissions, cur
|
||||
)
|
||||
return user
|
||||
item = cur.fetchone()
|
||||
return convert_provider_resource_to_client_resource(item)
|
||||
|
||||
|
||||
def _update_resource_sql(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
project_keys: list[str] | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
with pg_client.PostgresClient() as cur:
|
||||
kwargs["updated_at"] = datetime.now()
|
||||
set_fragments = [
|
||||
cur.mogrify("%s = %s", (AsIs(k), v)).decode("utf-8")
|
||||
for k, v in kwargs.items()
|
||||
]
|
||||
set_clause = ", ".join(set_fragments)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE public.users
|
||||
SET {set_clause}
|
||||
WHERE
|
||||
users.user_id = {resource_id}
|
||||
AND users.tenant_id = {tenant_id}
|
||||
AND users.deleted_at IS NULL
|
||||
RETURNING *
|
||||
"""
|
||||
)
|
||||
user = cur.fetchone()
|
||||
role_id = user["role_id"]
|
||||
_update_role_projects_and_permissions(role_id, project_keys, permissions, cur)
|
||||
return user
|
||||
|
||||
|
||||
def rewrite_provider_resource(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
email: str,
|
||||
name: str = "",
|
||||
internal_id: str | None = None,
|
||||
project_keys: list[str] | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return _update_resource_sql(
|
||||
resource_id,
|
||||
tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
internal_id=internal_id,
|
||||
project_keys=project_keys,
|
||||
permissions=permissions,
|
||||
def _update_role_projects_and_permissions(
|
||||
role_id: int,
|
||||
project_keys: list[str] | None,
|
||||
permissions: list[str] | None,
|
||||
cur: pg_client.PostgresClient,
|
||||
) -> None:
|
||||
all_projects = "true" if not project_keys else "false"
|
||||
project_key_clause = helpers.safe_mogrify_array(project_keys, "varchar", cur)
|
||||
permission_clause = helpers.safe_mogrify_array(permissions, "varchar", cur)
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE public.roles
|
||||
SET
|
||||
updated_at = now(),
|
||||
all_projects = {all_projects},
|
||||
permissions = {permission_clause}
|
||||
WHERE role_id = {role_id}
|
||||
RETURNING *
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
DELETE FROM public.roles_projects
|
||||
WHERE roles_projects.role_id = {role_id}
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO public.roles_projects (role_id, project_id)
|
||||
SELECT {role_id}, projects.project_id
|
||||
FROM public.projects
|
||||
WHERE projects.project_key = ANY({project_key_clause})
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def update_provider_resource(
|
||||
resource_id: int,
|
||||
tenant_id: int,
|
||||
**kwargs,
|
||||
):
|
||||
return _update_resource_sql(resource_id, tenant_id, **kwargs)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue