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