update GET Users api to be minimal working rfc version

This commit is contained in:
Jonathan Griffin 2025-04-17 14:01:59 +02:00
parent 13f46fe566
commit bcfa421b8f
4 changed files with 363 additions and 220 deletions

View file

@ -30,7 +30,7 @@ def create_new_member(tenant_id, email, invitation_token, admin, name, owner=Fal
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (tenant_id, email, role, name, data, role_id)
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s,
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s,
(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))))
@ -78,7 +78,7 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1)))
WHERE user_id=%(user_id)s
RETURNING
RETURNING
tenant_id,
user_id,
email,
@ -104,7 +104,7 @@ def restore_member(tenant_id, user_id, email, invitation_token, admin, name, own
u.role_id,
roles.name AS role_name,
TRUE AS has_password
FROM au,u LEFT JOIN roles USING(tenant_id)
FROM au,u LEFT JOIN roles USING(tenant_id)
WHERE roles.role_id IS NULL OR roles.role_id = (SELECT u.role_id FROM u);""",
{"tenant_id": tenant_id, "user_id": user_id, "email": email,
"role": "owner" if owner else "admin" if admin else "member", "name": name,
@ -240,7 +240,7 @@ def __get_invitation_link(invitation_token):
def allow_password_change(user_id, delta_min=10):
pass_token = secrets.token_urlsafe(8)
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""UPDATE public.basic_authentication
query = cur.mogrify(f"""UPDATE public.basic_authentication
SET change_pwd_expire_at = timezone('utc'::text, now()+INTERVAL '%(delta)s MINUTES'),
change_pwd_token = %(pass_token)s
WHERE user_id = %(user_id)s""",
@ -255,11 +255,11 @@ def get(user_id, tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.tenant_id,
email,
role,
email,
role,
users.name,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
@ -283,38 +283,25 @@ def get(user_id, tenant_id):
)
r = cur.fetchone()
return helper.dict_to_camel_case(r)
def get_by_uuid(user_uuid, tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
users.user_id,
users.tenant_id,
email,
role,
users.name,
users.data,
users.internal_id,
(CASE WHEN role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN role = 'member' THEN TRUE ELSE FALSE END) AS member,
origin,
role_id,
roles.name AS role_name,
roles.permissions,
roles.all_projects,
basic_authentication.password IS NOT NULL AS has_password,
users.service_account
FROM public.users LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
LEFT JOIN public.roles USING (role_id)
WHERE
users.data->>'user_id' = %(user_uuid)s
AND users.tenant_id = %(tenant_id)s
AND users.deleted_at IS NULL
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s)
LIMIT 1;""",
{"user_uuid": user_uuid, "tenant_id": tenant_id})
"""
SELECT *
FROM public.users
WHERE
users.deleted_at IS NULL
AND users.user_id = %(user_id)s
AND users.tenant_id = %(tenant_id)s
LIMIT 1;
""",
{
"user_id": user_uuid,
"tenant_id": tenant_id,
},
)
)
r = cur.fetchone()
return helper.dict_to_camel_case(r)
@ -323,11 +310,11 @@ def get_deleted_by_uuid(user_uuid, tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.tenant_id,
email,
role,
email,
role,
users.name,
users.data,
users.internal_id,
@ -375,8 +362,8 @@ def __get_account_info(tenant_id, user_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT users.name,
tenants.name AS tenant_name,
f"""SELECT users.name,
tenants.name AS tenant_name,
tenants.opt_out
FROM public.users INNER JOIN public.tenants USING (tenant_id)
WHERE users.user_id = %(userId)s
@ -457,11 +444,11 @@ def get_by_email_only(email):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.tenant_id,
users.email,
users.role,
users.email,
users.role,
users.name,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
@ -473,7 +460,7 @@ def get_by_email_only(email):
roles.name AS role_name
FROM public.users LEFT JOIN public.basic_authentication USING(user_id)
INNER JOIN public.roles USING(role_id)
WHERE users.email = %(email)s
WHERE users.email = %(email)s
AND users.deleted_at IS NULL
LIMIT 1;""",
{"email": email})
@ -481,50 +468,39 @@ def get_by_email_only(email):
r = cur.fetchone()
return helper.dict_to_camel_case(r)
def get_users_paginated(start_index, count=None, email=None):
def get_users_paginated(start_index, tenant_id, count=None):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
users.user_id AS id,
users.tenant_id,
users.email AS email,
users.data AS data,
users.role,
users.name AS name,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member,
origin,
basic_authentication.password IS NOT NULL AS has_password,
role_id,
internal_id,
roles.name AS role_name
FROM public.users LEFT JOIN public.basic_authentication USING(user_id)
INNER JOIN public.roles USING(role_id)
WHERE users.deleted_at IS NULL
AND users.data ? 'user_id'
AND email = COALESCE(%(email)s, email)
LIMIT %(count)s
OFFSET %(startIndex)s;;""",
{"startIndex": start_index - 1, "count": count, "email": email})
"""
SELECT *
FROM public.users
WHERE
users.deleted_at IS NULL
AND users.tenant_id = %(tenant_id)s
LIMIT %(limit)s
OFFSET %(offset)s;
""",
{
"offset": start_index - 1,
"limit": count,
"tenant_id": tenant_id
},
)
)
r = cur.fetchall()
if len(r):
r = helper.list_to_camel_case(r)
return r
return []
return helper.list_to_camel_case(r)
def get_member(tenant_id, user_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.email,
users.role,
users.name,
users.email,
users.role,
users.name,
users.created_at,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
@ -535,7 +511,7 @@ def get_member(tenant_id, user_id):
invitation_token,
role_id,
roles.name AS role_name
FROM public.users
FROM public.users
LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
LEFT JOIN public.roles USING (role_id)
WHERE users.tenant_id = %(tenant_id)s AND users.deleted_at IS NULL AND users.user_id = %(user_id)s
@ -557,11 +533,11 @@ def get_members(tenant_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.email,
users.role,
users.name,
users.email,
users.role,
users.name,
users.created_at,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
@ -572,10 +548,10 @@ def get_members(tenant_id):
invitation_token,
role_id,
roles.name AS role_name
FROM public.users
FROM public.users
LEFT JOIN public.basic_authentication ON users.user_id=basic_authentication.user_id
LEFT JOIN public.roles USING (role_id)
WHERE users.tenant_id = %(tenant_id)s
WHERE users.tenant_id = %(tenant_id)s
AND users.deleted_at IS NULL
AND NOT users.service_account
ORDER BY name, user_id""",
@ -614,7 +590,7 @@ def delete_member(user_id, tenant_id, id_to_delete):
cur.execute(
cur.mogrify(f"""UPDATE public.users
SET deleted_at = timezone('utc'::text, now()),
jwt_iat= NULL, jwt_refresh_jti= NULL,
jwt_iat= NULL, jwt_refresh_jti= NULL,
jwt_refresh_iat= NULL,
role_id=NULL
WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""",
@ -634,10 +610,10 @@ def delete_member_as_admin(tenant_id, id_to_delete):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id AS user_id,
users.tenant_id,
email,
email,
role,
users.name,
origin,
@ -654,12 +630,12 @@ def delete_member_as_admin(tenant_id, id_to_delete):
role = 'owner'
AND users.tenant_id = %(tenant_id)s
AND users.deleted_at IS NULL
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s)
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL AND roles.tenant_id = %(tenant_id)s)
LIMIT 1;""",
{"tenant_id": tenant_id, "user_uuid": id_to_delete})
)
r = cur.fetchone()
if r["user_id"] == id_to_delete:
return {"errors": ["unauthorized, cannot delete self"]}
@ -677,7 +653,7 @@ def delete_member_as_admin(tenant_id, id_to_delete):
cur.execute(
cur.mogrify(f"""UPDATE public.users
SET deleted_at = timezone('utc'::text, now()),
jwt_iat= NULL, jwt_refresh_jti= NULL,
jwt_iat= NULL, jwt_refresh_jti= NULL,
jwt_refresh_iat= NULL,
role_id=NULL
WHERE user_id=%(user_id)s AND tenant_id=%(tenant_id)s;""",
@ -743,8 +719,8 @@ def email_exists(email):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
count(user_id)
f"""SELECT
count(user_id)
FROM public.users
WHERE
email = %(email)s
@ -760,8 +736,8 @@ def get_deleted_user_by_email(email):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
*
f"""SELECT
*
FROM public.users
WHERE
email = %(email)s
@ -777,7 +753,7 @@ def get_by_invitation_token(token, pass_token=None):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
*,
DATE_PART('day',timezone('utc'::text, now()) \
- COALESCE(basic_authentication.invited_at,'2000-01-01'::timestamp ))>=1 AS expired_invitation,
@ -797,15 +773,15 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool:
cur.execute(
cur.mogrify(
f"""SELECT user_id,
EXTRACT(epoch FROM jwt_iat)::BIGINT AS jwt_iat,
EXTRACT(epoch FROM jwt_iat)::BIGINT AS jwt_iat,
changed_at,
service_account,
basic_authentication.user_id IS NOT NULL AS has_basic_auth
FROM public.users
LEFT JOIN public.basic_authentication USING(user_id)
WHERE user_id = %(userId)s
AND tenant_id = %(tenant_id)s
AND deleted_at IS NULL
FROM public.users
LEFT JOIN public.basic_authentication USING(user_id)
WHERE user_id = %(userId)s
AND tenant_id = %(tenant_id)s
AND deleted_at IS NULL
LIMIT 1;""",
{"userId": user_id, "tenant_id": tenant_id})
)
@ -819,9 +795,9 @@ def auth_exists(user_id, tenant_id, jwt_iat) -> bool:
def refresh_auth_exists(user_id, tenant_id, jwt_jti=None):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(f"""SELECT user_id
FROM public.users
WHERE user_id = %(userId)s
cur.mogrify(f"""SELECT user_id
FROM public.users
WHERE user_id = %(userId)s
AND tenant_id= %(tenant_id)s
AND deleted_at IS NULL
AND jwt_refresh_jti = %(jwt_jti)s
@ -866,17 +842,17 @@ def change_jwt_iat_jti(user_id):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""UPDATE public.users
SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'),
jwt_refresh_jti = 0,
jwt_refresh_jti = 0,
jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s'),
spot_jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'),
spot_jwt_refresh_jti = 0,
spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s')
WHERE user_id = %(user_id)s
RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat,
jwt_refresh_jti,
spot_jwt_refresh_jti = 0,
spot_jwt_refresh_iat = timezone('utc'::text, now()-INTERVAL '10s')
WHERE user_id = %(user_id)s
RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat,
jwt_refresh_jti,
EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat,
EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat,
spot_jwt_refresh_jti,
EXTRACT (epoch FROM spot_jwt_iat)::BIGINT AS spot_jwt_iat,
spot_jwt_refresh_jti,
EXTRACT (epoch FROM spot_jwt_refresh_iat)::BIGINT AS spot_jwt_refresh_iat;""",
{"user_id": user_id})
cur.execute(query)
@ -888,10 +864,10 @@ def refresh_jwt_iat_jti(user_id):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""UPDATE public.users
SET jwt_iat = timezone('utc'::text, now()-INTERVAL '10s'),
jwt_refresh_jti = jwt_refresh_jti + 1
WHERE user_id = %(user_id)s
RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat,
jwt_refresh_jti,
jwt_refresh_jti = jwt_refresh_jti + 1
WHERE user_id = %(user_id)s
RETURNING EXTRACT (epoch FROM jwt_iat)::BIGINT AS jwt_iat,
jwt_refresh_jti,
EXTRACT (epoch FROM jwt_refresh_iat)::BIGINT AS jwt_refresh_iat;""",
{"user_id": user_id})
cur.execute(query)
@ -904,7 +880,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No
return {"errors": ["must sign-in with SSO, enforced by admin"]}
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.tenant_id,
users.role,
@ -919,7 +895,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No
users.service_account
FROM public.users AS users INNER JOIN public.basic_authentication USING(user_id)
LEFT JOIN public.roles ON (roles.role_id = users.role_id AND roles.tenant_id = users.tenant_id)
WHERE users.email = %(email)s
WHERE users.email = %(email)s
AND basic_authentication.password = crypt(%(password)s, basic_authentication.password)
AND basic_authentication.user_id = (SELECT su.user_id FROM public.users AS su WHERE su.email=%(email)s AND su.deleted_at IS NULL LIMIT 1)
AND (roles.role_id IS NULL OR roles.deleted_at IS NULL)
@ -932,7 +908,7 @@ def authenticate(email, password, for_change_password=False) -> dict | bool | No
query = cur.mogrify(
f"""SELECT 1
FROM public.users
WHERE users.email = %(email)s
WHERE users.email = %(email)s
AND users.deleted_at IS NULL
AND users.origin IS NOT NULL
LIMIT 1;""",
@ -983,17 +959,17 @@ def get_user_role(tenant_id, user_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.email,
users.role,
users.name,
users.email,
users.role,
users.name,
users.created_at,
(CASE WHEN users.role = 'owner' THEN TRUE ELSE FALSE END) AS super_admin,
(CASE WHEN users.role = 'admin' THEN TRUE ELSE FALSE END) AS admin,
(CASE WHEN users.role = 'member' THEN TRUE ELSE FALSE END) AS member
FROM public.users
WHERE users.deleted_at IS NULL
FROM public.users
WHERE users.deleted_at IS NULL
AND users.user_id=%(user_id)s
AND users.tenant_id=%(tenant_id)s
LIMIT 1""",
@ -1007,7 +983,7 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id=
query = cur.mogrify(f"""\
WITH u AS (
INSERT INTO public.users (tenant_id, email, role, name, data, origin, internal_id, role_id)
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s,
VALUES (%(tenant_id)s, %(email)s, %(role)s, %(name)s, %(data)s, %(origin)s, %(internal_id)s,
(SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))))
@ -1033,7 +1009,7 @@ def create_sso_user(tenant_id, email, admin, name, origin, role_id, internal_id=
query
)
return helper.dict_to_camel_case(cur.fetchone())
def create_scim_user(
tenant_id,
user_uuid,
@ -1094,7 +1070,7 @@ def __hard_delete_user_uuid(user_uuid):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""DELETE FROM public.users
WHERE users.data->>'user_id' = %(user_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL
WHERE users.data->>'user_id' = %(user_uuid)s;""", # removed this: AND users.deleted_at IS NOT NULL
{"user_uuid": user_uuid})
cur.execute(query)
@ -1124,7 +1100,7 @@ def refresh(user_id: int, tenant_id: int = -1) -> dict:
def authenticate_sso(email: str, internal_id: str):
with pg_client.PostgresClient() as cur:
query = cur.mogrify(
f"""SELECT
f"""SELECT
users.user_id,
users.tenant_id,
users.role,
@ -1173,13 +1149,13 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
WITH u AS (
UPDATE public.users
UPDATE public.users
SET tenant_id= %(tenant_id)s,
role= %(role)s,
role= %(role)s,
name= %(name)s,
data= %(data)s,
origin= %(origin)s,
internal_id= %(internal_id)s,
data= %(data)s,
origin= %(origin)s,
internal_id= %(internal_id)s,
role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))),
@ -1198,7 +1174,7 @@ def restore_sso_user(user_id, tenant_id, email, admin, name, origin, role_id, in
invited_at= default,
change_pwd_token= default,
change_pwd_expire_at= default,
changed_at= NULL
changed_at= NULL
WHERE user_id = %(user_id)s
RETURNING user_id
)
@ -1237,13 +1213,13 @@ def restore_scim_user(
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
WITH u AS (
UPDATE public.users
UPDATE public.users
SET tenant_id= %(tenant_id)s,
role= %(role)s,
role= %(role)s,
name= %(name)s,
data= %(data)s,
origin= %(origin)s,
internal_id= %(internal_id)s,
data= %(data)s,
origin= %(origin)s,
internal_id= %(internal_id)s,
role_id= (SELECT COALESCE((SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND role_id = %(role_id)s),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name = 'Member' LIMIT 1),
(SELECT role_id FROM roles WHERE tenant_id = %(tenant_id)s AND name != 'Owner' LIMIT 1))),
@ -1262,7 +1238,7 @@ def restore_scim_user(
invited_at= default,
change_pwd_token= default,
change_pwd_expire_at= default,
changed_at= NULL
changed_at= NULL
WHERE user_id = %(user_id)s
RETURNING user_id
)
@ -1290,10 +1266,10 @@ def get_user_settings(user_id):
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
f"""SELECT
f"""SELECT
settings
FROM public.users
WHERE users.deleted_at IS NULL
FROM public.users
WHERE users.deleted_at IS NULL
AND users.user_id=%(user_id)s
LIMIT 1""",
{"user_id": user_id})

View file

@ -1,20 +1,22 @@
import logging
import re
import uuid
from typing import Optional
from typing import Any, Literal, Optional
import copy
from datetime import datetime
from decouple import config
from fastapi import Depends, HTTPException, Header, Query, Response, Request
from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer
import schemas
from chalicelib.core import users, roles, tenants
from chalicelib.utils.scim_auth import auth_optional, auth_required, create_tokens, verify_refresh_token
from routers.base import get_routers
from routers.scim_constants import RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG
from routers import scim_helpers
logger = logging.getLogger(__name__)
@ -189,90 +191,124 @@ class UserRequest(BaseModel):
password: str = Field(default=None)
active: bool
class UserResponse(BaseModel):
schemas: list[str]
id: str
userName: str
name: Name
emails: list[Email] # ignore for now
displayName: str
locale: str
externalId: str
active: bool
groups: list[dict]
meta: dict = Field(default={"resourceType": "User"})
class PatchUserRequest(BaseModel):
schemas: list[str]
Operations: list[dict]
@public_app.get("/Users", dependencies=[Depends(auth_required)])
async def get_users(
start_index: int = Query(1, alias="startIndex"),
count: Optional[int] = Query(None, alias="count"),
email: Optional[str] = Query(None, alias="filter"),
):
"""Get SCIM Users"""
if email:
email = email.split(" ")[2].strip('"')
result_users = users.get_users_paginated(start_index, count, email)
class ResourceMetaResponse(BaseModel):
resourceType: Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None = None
created: datetime | None = None
lastModified: datetime | None = None
location: str | None = None
version: str | None = None
serialized_users = []
for user in result_users:
serialized_users.append(
UserResponse(
schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"],
id = user["data"]["userId"],
userName = user["email"],
name = Name.model_validate(user["data"]["name"]),
emails = [Email.model_validate(user["data"]["emails"])],
displayName = user["name"],
locale = user["data"]["locale"],
externalId = user["internalId"],
active = True, # ignore for now, since, can't insert actual timestamp
groups = [], # ignore
).model_dump(mode='json')
)
@field_serializer("created", "lastModified")
def serialize_datetime(self, dt: datetime) -> str | None:
if not dt:
return None
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
class CommonResourceResponse(BaseModel):
id: str
externalId: str | None = None
schemas: list[
Literal[
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
"urn:ietf:params:scim:schemas:core:2.0:Schema",
"urn:ietf:params:scim:schemas:core:2.0:User",
]
]
meta: ResourceMetaResponse | None = None
class UserResponse(CommonResourceResponse):
schemas: list[Literal["urn:ietf:params:scim:schemas:core:2.0:User"]] = ["urn:ietf:params:scim:schemas:core:2.0:User"]
userName: str | None = None
class QueryResourceResponse(BaseModel):
schemas: list[Literal["urn:ietf:params:scim:api:messages:2.0:ListResponse"]] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]
totalResults: int
# todo(jon): add the other schemas
Resources: list[UserResponse]
startIndex: int
itemsPerPage: int
MAX_USERS_PER_PAGE = 10
def _convert_db_user_to_scim_user(db_user: dict[str, Any], attributes: list[str] | None = None, excluded_attributes: list[str] | None = None) -> UserResponse:
user_schema = SCHEMA_IDS_TO_SCHEMA_DETAILS["urn:ietf:params:scim:schemas:core:2.0:User"]
all_attributes = scim_helpers.get_all_attribute_names(user_schema)
attributes = attributes or all_attributes
always_returned_attributes = scim_helpers.get_all_attribute_names_where_returned_is_always(user_schema)
included_attributes = list(set(attributes).union(set(always_returned_attributes)))
excluded_attributes = excluded_attributes or []
excluded_attributes = list(set(excluded_attributes).difference(set(always_returned_attributes)))
scim_user = {
"id": str(db_user["userId"]),
"meta": {
"resourceType": "User",
"created": db_user["createdAt"],
"lastModified": db_user["createdAt"], # todo(jon): we currently don't keep track of this in the db
"location": f"Users/{db_user['userId']}"
},
"userName": db_user["email"],
}
scim_user = scim_helpers.filter_attributes(scim_user, included_attributes)
scim_user = scim_helpers.exclude_attributes(scim_user, excluded_attributes)
return UserResponse(**scim_user)
@public_app.get("/Users")
async def get_users(
tenant_id = Depends(auth_required),
requested_start_index: int = Query(1, alias="startIndex"),
requested_items_per_page: int | None = Query(None, alias="count"),
attributes: list[str] | None = Query(None),
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
):
start_index = max(1, requested_start_index)
items_per_page = min(max(0, requested_items_per_page or MAX_USERS_PER_PAGE), MAX_USERS_PER_PAGE)
# todo(jon): this might not be the most efficient thing to do. could be better to just do a count.
# but this is the fastest thing at the moment just to test that it's working
total_users = users.get_users_paginated(1, tenant_id)
db_users = users.get_users_paginated(start_index, tenant_id, count=items_per_page)
scim_users = [
_convert_db_user_to_scim_user(user, attributes, excluded_attributes)
for user in db_users
]
return JSONResponse(
status_code=200,
content={
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
"totalResults": len(serialized_users),
"startIndex": start_index,
"itemsPerPage": len(serialized_users),
"Resources": serialized_users,
},
content=QueryResourceResponse(
totalResults=len(total_users),
startIndex=start_index,
itemsPerPage=len(scim_users),
Resources=scim_users,
).model_dump(mode="json", exclude_none=True),
)
@public_app.get("/Users/{user_id}", dependencies=[Depends(auth_required)])
def get_user(user_id: str):
"""Get SCIM User"""
tenant_id = 1
user = users.get_by_uuid(user_id, tenant_id)
if not user:
return JSONResponse(
status_code=404,
content={
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"detail": "User not found",
"status": 404,
}
)
res = UserResponse(
schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"],
id = user["data"]["userId"],
userName = user["email"],
name = Name.model_validate(user["data"]["name"]),
emails = [Email.model_validate(user["data"]["emails"])],
displayName = user["name"],
locale = user["data"]["locale"],
externalId = user["internalId"],
active = True, # ignore for now, since, can't insert actual timestamp
groups = [], # ignore
@public_app.get("/Users/{user_id}")
def get_user(
user_id: str,
tenant_id = Depends(auth_required),
attributes: list[str] | None = Query(None),
excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
):
db_user = users.get_by_uuid(user_id, tenant_id)
if not db_user:
return _not_found_error_response(user_id)
scim_user = _convert_db_user_to_scim_user(db_user, attributes, excluded_attributes)
return JSONResponse(
status_code=200,
content=scim_user.model_dump(mode="json", exclude_none=True)
)
return JSONResponse(status_code=201, content=res.model_dump(mode='json'))
@public_app.post("/Users", dependencies=[Depends(auth_required)])

View file

@ -1,5 +1,6 @@
# note(jon): please see https://datatracker.ietf.org/doc/html/rfc7643 for details on these constants
from typing import Any
from typing import Any, Literal
def _attribute_characteristics(
name: str,
@ -102,12 +103,12 @@ def _common_resource_attributes(id_required: bool=True, id_uniqueness: str="none
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
"urn:ietf:params:scim:schemas:core:2.0:ResourceType",
"urn:ietf:params:scim:schemas:core:2.0:Schema",
# todo(jon): add the user and group schem when completed
"urn:ietf:params:scim:schemas:core:2.0:User",
],
case_exact=True,
mutability="readOnly",
returned="default",
required=True,
returned="always",
),
_attribute_characteristics(
name="meta",
@ -670,13 +671,38 @@ SCHEMA_SCHEMA = {
}
USER_SCHEMA = {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
"id": "urn:ietf:params:scim:schemas:core:2.0:User",
"name": "User",
"description": "User account.",
"meta": {
"resourceType": "Schema",
"created": "2025-04-16T14:48:00Z",
# note(jon): we might want to think about adding this resource as part of our db
# and then updating these timestamps from an api and such. for now, if we update
# the configuration, we should update the timestamp here.
"lastModified": "2025-04-16T14:48:00Z",
"location": "Schemas/urn:ietf:params:scim:schemas:core:2.0:User",
},
"attributes": [
*_common_resource_attributes(),
_attribute_characteristics(
name="userName",
description="A service provider's unique identifier for the user.",
required=True,
),
],
}
SCHEMAS = sorted(
# todo(jon): add the user schema
[
SERVICE_PROVIDER_CONFIG_SCHEMA,
RESOURCE_TYPE_SCHEMA,
SCHEMA_SCHEMA,
USER_SCHEMA,
],
key=lambda x: x["id"],
)

View file

@ -0,0 +1,105 @@
from typing import Any
from copy import deepcopy
def get_all_attribute_names(schema: dict[str, Any]) -> list[str]:
result = []
def _walk(attrs, prefix=None):
for attr in attrs:
name = attr["name"]
path = f"{prefix}.{name}" if prefix else name
result.append(path)
if attr["type"] == "complex":
sub = attr.get("subAttributes") or attr.get("attributes") or []
_walk(sub, path)
_walk(schema["attributes"])
return result
def get_all_attribute_names_where_returned_is_always(schema: dict[str, Any]) -> list[str]:
result = []
def _walk(attrs, prefix=None):
for attr in attrs:
name = attr["name"]
path = f"{prefix}.{name}" if prefix else name
if attr["returned"] == "always":
result.append(path)
if attr["type"] == "complex":
sub = attr.get("subAttributes") or attr.get("attributes") or []
_walk(sub, path)
_walk(schema["attributes"])
return result
def filter_attributes(resource: dict[str, Any], include_list: list[str]) -> dict[str, Any]:
result = {}
for attr in include_list:
parts = attr.split(".", 1)
key = parts[0]
if key not in resource:
continue
if len(parts) == 1:
# toplevel attr
result[key] = resource[key]
else:
# nested attr
sub = resource[key]
rest = parts[1]
if isinstance(sub, dict):
filtered = filter_attributes(sub, [rest])
if filtered:
result.setdefault(key, {}).update(filtered)
elif isinstance(sub, list):
# apply to each element
new_list = []
for item in sub:
if isinstance(item, dict):
f = filter_attributes(item, [rest])
if f:
new_list.append(f)
if new_list:
result[key] = new_list
return result
def exclude_attributes(resource: dict[str, Any], exclude_list: list[str]) -> dict[str, Any]:
exclude_map = {}
for attr in exclude_list:
parts = attr.split(".", 1)
key = parts[0]
# rest is empty string for top-level exclusion
rest = parts[1] if len(parts) == 2 else ""
exclude_map.setdefault(key, []).append(rest)
new_resource = {}
for key, value in resource.items():
if key in exclude_map:
subs = exclude_map[key]
# If any attr has no rest, exclude entire key
if "" in subs:
continue
# Exclude nested attributes
if isinstance(value, dict):
new_sub = exclude_attributes(value, subs)
if not new_sub:
continue
new_resource[key] = new_sub
elif isinstance(value, list):
new_list = []
for item in value:
if isinstance(item, dict):
new_item = exclude_attributes(item, subs)
new_list.append(new_item)
else:
new_list.append(item)
new_resource[key] = new_list
else:
new_resource[key] = value
else:
# No exclusion for this key: copy safely
if isinstance(value, (dict, list)):
new_resource[key] = deepcopy(value)
else:
new_resource[key] = value
return new_resource