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

View file

@ -1,20 +1,22 @@
import logging import logging
import re import re
import uuid import uuid
from typing import Optional from typing import Any, Literal, Optional
import copy import copy
from datetime import datetime
from decouple import config from decouple import config
from fastapi import Depends, HTTPException, Header, Query, Response, Request from fastapi import Depends, HTTPException, Header, Query, Response, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_serializer
import schemas import schemas
from chalicelib.core import users, roles, tenants from chalicelib.core import users, roles, tenants
from chalicelib.utils.scim_auth import auth_optional, auth_required, create_tokens, verify_refresh_token from chalicelib.utils.scim_auth import auth_optional, auth_required, create_tokens, verify_refresh_token
from routers.base import get_routers from routers.base import get_routers
from routers.scim_constants import RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG from routers.scim_constants import RESOURCE_TYPES, SCHEMAS, SERVICE_PROVIDER_CONFIG
from routers import scim_helpers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -189,90 +191,124 @@ class UserRequest(BaseModel):
password: str = Field(default=None) password: str = Field(default=None)
active: bool 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): class PatchUserRequest(BaseModel):
schemas: list[str] schemas: list[str]
Operations: list[dict] Operations: list[dict]
@public_app.get("/Users", dependencies=[Depends(auth_required)]) class ResourceMetaResponse(BaseModel):
async def get_users( resourceType: Literal["ServiceProviderConfig", "ResourceType", "Schema", "User"] | None = None
start_index: int = Query(1, alias="startIndex"), created: datetime | None = None
count: Optional[int] = Query(None, alias="count"), lastModified: datetime | None = None
email: Optional[str] = Query(None, alias="filter"), location: str | None = None
): version: str | None = None
"""Get SCIM Users"""
if email:
email = email.split(" ")[2].strip('"')
result_users = users.get_users_paginated(start_index, count, email)
serialized_users = [] @field_serializer("created", "lastModified")
for user in result_users: def serialize_datetime(self, dt: datetime) -> str | None:
serialized_users.append( if not dt:
UserResponse( return None
schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
id = user["data"]["userId"],
userName = user["email"],
name = Name.model_validate(user["data"]["name"]), class CommonResourceResponse(BaseModel):
emails = [Email.model_validate(user["data"]["emails"])], id: str
displayName = user["name"], externalId: str | None = None
locale = user["data"]["locale"], schemas: list[
externalId = user["internalId"], Literal[
active = True, # ignore for now, since, can't insert actual timestamp "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig",
groups = [], # ignore "urn:ietf:params:scim:schemas:core:2.0:ResourceType",
).model_dump(mode='json') "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( return JSONResponse(
status_code=200, status_code=200,
content={ content=QueryResourceResponse(
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults=len(total_users),
"totalResults": len(serialized_users), startIndex=start_index,
"startIndex": start_index, itemsPerPage=len(scim_users),
"itemsPerPage": len(serialized_users), Resources=scim_users,
"Resources": serialized_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( @public_app.get("/Users/{user_id}")
schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"], def get_user(
id = user["data"]["userId"], user_id: str,
userName = user["email"], tenant_id = Depends(auth_required),
name = Name.model_validate(user["data"]["name"]), attributes: list[str] | None = Query(None),
emails = [Email.model_validate(user["data"]["emails"])], excluded_attributes: list[str] | None = Query(None, alias="excludedAttributes"),
displayName = user["name"], ):
locale = user["data"]["locale"], db_user = users.get_by_uuid(user_id, tenant_id)
externalId = user["internalId"], if not db_user:
active = True, # ignore for now, since, can't insert actual timestamp return _not_found_error_response(user_id)
groups = [], # ignore 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)]) @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 # 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( def _attribute_characteristics(
name: str, 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:ServiceProviderConfig",
"urn:ietf:params:scim:schemas:core:2.0:ResourceType", "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:Schema",
# todo(jon): add the user and group schem when completed "urn:ietf:params:scim:schemas:core:2.0:User",
], ],
case_exact=True, case_exact=True,
mutability="readOnly", mutability="readOnly",
returned="default",
required=True, required=True,
returned="always",
), ),
_attribute_characteristics( _attribute_characteristics(
name="meta", 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( SCHEMAS = sorted(
# todo(jon): add the user schema
[ [
SERVICE_PROVIDER_CONFIG_SCHEMA, SERVICE_PROVIDER_CONFIG_SCHEMA,
RESOURCE_TYPE_SCHEMA, RESOURCE_TYPE_SCHEMA,
SCHEMA_SCHEMA, SCHEMA_SCHEMA,
USER_SCHEMA,
], ],
key=lambda x: x["id"], 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