map groups to roles instead of having separate groups table

This commit is contained in:
Jonathan Griffin 2025-04-29 17:13:28 +02:00
parent d3a9a50892
commit 8bdbf7ef95
3 changed files with 101 additions and 199 deletions

View file

@ -18,8 +18,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input(
result = {} result = {}
if "displayName" in client_input: if "displayName" in client_input:
result["name"] = client_input["displayName"] result["name"] = client_input["displayName"]
if "externalId" in client_input:
result["external_id"] = client_input["externalId"]
if "members" in client_input: if "members" in client_input:
members = client_input["members"] or [] members = client_input["members"] or []
result["user_ids"] = [int(member["value"]) for member in members] result["user_ids"] = [int(member["value"]) for member in members]
@ -32,15 +30,14 @@ def convert_provider_resource_to_client_resource(
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"],
"id": str(provider_resource["group_id"]), "id": str(provider_resource["role_id"]),
"externalId": provider_resource["external_id"],
"meta": { "meta": {
"resourceType": "Group", "resourceType": "Group",
"created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"), "created": provider_resource["created_at"].strftime("%Y-%m-%dT%H:%M:%SZ"),
"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['group_id']}", "location": f"Groups/{provider_resource['role_id']}",
}, },
"displayName": provider_resource["name"], "displayName": provider_resource["name"],
"members": [ "members": [
@ -60,8 +57,10 @@ def get_active_resource_count(tenant_id: int) -> int:
cur.mogrify( cur.mogrify(
""" """
SELECT COUNT(*) SELECT COUNT(*)
FROM public.groups FROM public.roles
WHERE groups.tenant_id = %(tenant_id)s WHERE
roles.tenant_id = %(tenant_id)s
AND roles.deleted_at IS NULL
""", """,
{"tenant_id": tenant_id}, {"tenant_id": tenant_id},
) )
@ -77,18 +76,19 @@ def get_provider_resource_chunk(
cur.mogrify( cur.mogrify(
""" """
SELECT SELECT
groups.*, roles.*,
COALESCE( COALESCE(
( (
SELECT json_agg(users) SELECT json_agg(users)
FROM public.user_group FROM public.users
JOIN public.users USING (user_id) WHERE users.role_id = roles.role_id
WHERE user_group.group_id = groups.group_id
), ),
'[]' '[]'
) AS users ) AS users
FROM public.groups FROM public.roles
WHERE groups.tenant_id = %(tenant_id)s WHERE
roles.tenant_id = %(tenant_id)s
AND roles.deleted_at IS NULL
LIMIT %(limit)s LIMIT %(limit)s
OFFSET %(offset)s; OFFSET %(offset)s;
""", """,
@ -110,23 +110,23 @@ def get_provider_resource(
cur.mogrify( cur.mogrify(
""" """
SELECT SELECT
groups.*, roles.*,
COALESCE( COALESCE(
( (
SELECT json_agg(users) SELECT json_agg(users)
FROM public.user_group FROM public.users
JOIN public.users USING (user_id) WHERE users.role_id = roles.role_id
WHERE user_group.group_id = groups.group_id
), ),
'[]' '[]'
) AS users ) AS users
FROM public.groups FROM public.roles
WHERE WHERE
groups.tenant_id = %(tenant_id)s roles.tenant_id = %(tenant_id)s
AND groups.group_id = %(group_id)s AND roles.role_id = %(resource_id)s
AND roles.deleted_at IS NULL
LIMIT 1; LIMIT 1;
""", """,
{"group_id": resource_id, "tenant_id": tenant_id}, {"resource_id": resource_id, "tenant_id": tenant_id},
) )
) )
return cur.fetchone() return cur.fetchone()
@ -135,7 +135,7 @@ def get_provider_resource(
def get_provider_resource_from_unique_fields( def get_provider_resource_from_unique_fields(
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> ProviderResource | None: ) -> ProviderResource | None:
# note(jon): we do not really use this for groups as we don't have unique values outside # note(jon): we do not really use this for scim.groups (openreplay.roles) as we don't have unique values outside
# of the primary key # of the primary key
return None return None
@ -145,7 +145,6 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input(
) -> ProviderInput: ) -> ProviderInput:
return { return {
"name": client_input["displayName"], "name": client_input["displayName"],
"external_id": client_input.get("externalId"),
"user_ids": [ "user_ids": [
int(member["value"]) for member in client_input.get("members", []) int(member["value"]) for member in client_input.get("members", [])
], ],
@ -157,7 +156,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
) -> ProviderInput: ) -> ProviderInput:
return { return {
"name": client_input["displayName"], "name": client_input["displayName"],
"external_id": client_input.get("externalId"),
"user_ids": [ "user_ids": [
int(member["value"]) for member in client_input.get("members", []) int(member["value"]) for member in client_input.get("members", [])
], ],
@ -189,50 +187,37 @@ def create_provider_resource(
cur.execute( cur.execute(
f""" f"""
WITH WITH
g AS ( r AS (
INSERT INTO public.groups ({column_clause}) INSERT INTO public.roles ({column_clause})
VALUES ({value_clause}) VALUES ({value_clause})
RETURNING * RETURNING *
), ),
ugs AS ( linked_users AS (
INSERT INTO public.user_group (user_id, group_id) UPDATE public.users
SELECT users.user_id, g.group_id SET
FROM g updated_at = now(),
JOIN public.users ON users.user_id = ANY({user_id_clause}) role_id = (SELECT r.role_id FROM r)
WHERE users.user_id = ANY({user_id_clause})
RETURNING * RETURNING *
) )
SELECT SELECT
g.*, r.*,
COALESCE( COALESCE(
( (
SELECT json_agg(users) SELECT json_agg(linked_users.*)
FROM ugs FROM linked_users
JOIN public.users USING (user_id)
), ),
'[]' '[]'
) AS users ) AS users
FROM g FROM r
LIMIT 1; LIMIT 1;
""" """
) )
return cur.fetchone() return cur.fetchone()
def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None:
with pg_client.PostgresClient() as cur:
cur.execute(
cur.mogrify(
"""
DELETE FROM public.groups
WHERE groups.group_id = %(group_id)s AND groups.tenant_id = %(tenant_id)s;
"""
),
{"tenant_id": tenant_id, "group_id": resource_id},
)
def _update_resource_sql( def _update_resource_sql(
group_id: int, resource_id: int,
tenant_id: int, tenant_id: int,
user_ids: list[int] | None = None, user_ids: list[int] | None = None,
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
@ -251,46 +236,71 @@ def _update_resource_sql(
user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]" user_id_clause = f"ARRAY[{', '.join(user_id_fragments)}]::int[]"
cur.execute( cur.execute(
f""" f"""
DELETE FROM public.user_group UPDATE public.users
WHERE user_group.group_id = {group_id} SET role_id = NULL
WHERE users.role_id = {resource_id}
""" """
) )
cur.execute( cur.execute(
f""" f"""
WITH WITH
g AS ( r AS (
UPDATE public.groups UPDATE public.roles
SET {set_clause} SET {set_clause}
WHERE WHERE
groups.group_id = {group_id} roles.role_id = {resource_id}
AND groups.tenant_id = {tenant_id} AND roles.tenant_id = {tenant_id}
AND roles.deleted_at IS NULL
RETURNING * RETURNING *
), ),
linked_user_group AS ( linked_users AS (
INSERT INTO public.user_group (user_id, group_id) UPDATE public.users
SELECT users.user_id, g.group_id SET
FROM g updated_at = now(),
JOIN public.users ON users.user_id = ANY({user_id_clause}) role_id = {resource_id}
WHERE users.deleted_at IS NULL AND users.tenant_id = {tenant_id} WHERE users.user_id = ANY({user_id_clause})
RETURNING * RETURNING *
) )
SELECT SELECT
g.*, r.*,
COALESCE( COALESCE(
( (
SELECT json_agg(users) SELECT json_agg(linked_users.*)
FROM linked_user_group FROM linked_users
JOIN public.users USING (user_id)
), ),
'[]' '[]'
) AS users ) AS users
FROM g FROM r
LIMIT 1; 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 restore_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,
created_at=datetime.now(),
deleted_at=None,
**kwargs,
)
def rewrite_provider_resource( def rewrite_provider_resource(
resource_id: int, resource_id: int,
tenant_id: int, tenant_id: int,
@ -298,7 +308,7 @@ def rewrite_provider_resource(
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
return _update_resource_sql( return _update_resource_sql(
group_id=resource_id, resource_id=resource_id,
tenant_id=tenant_id, tenant_id=tenant_id,
name=name, name=name,
**kwargs, **kwargs,
@ -311,7 +321,7 @@ def update_provider_resource(
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
): ):
return _update_resource_sql( return _update_resource_sql(
group_id=resource_id, resource_id=resource_id,
tenant_id=tenant_id, tenant_id=tenant_id,
**kwargs, **kwargs,
) )

View file

@ -17,9 +17,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input(
tenant_id: int, client_input: ClientInput tenant_id: int, client_input: ClientInput
) -> ProviderInput: ) -> ProviderInput:
result = {} result = {}
if "userType" in client_input:
role = roles.get_role_by_name(tenant_id, client_input["userType"])
result["role_id"] = role["roleId"] if role else None
if "name" in client_input: if "name" in client_input:
# note(jon): we're currently not handling the case where the client # note(jon): we're currently not handling the case where the client
# send patches of individual name components (e.g. name.middleName) # send patches of individual name components (e.g. name.middleName)
@ -38,10 +35,6 @@ def convert_client_resource_update_input_to_provider_resource_update_input(
def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input( def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
tenant_id: int, client_input: ClientInput tenant_id: int, client_input: ClientInput
) -> ProviderInput: ) -> ProviderInput:
role_id = None
if "userType" in client_input:
role = roles.get_role_by_name(tenant_id, client_input["userType"])
role_id = role["roleId"] if role else None
name = client_input.get("name", {}).get("formatted") name = client_input.get("name", {}).get("formatted")
if not name: if not name:
name = " ".join( name = " ".join(
@ -61,7 +54,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
"email": client_input["userName"], "email": client_input["userName"],
"internal_id": client_input.get("externalId"), "internal_id": client_input.get("externalId"),
"name": name, "name": name,
"role_id": role_id,
} }
result = {k: v for k, v in result.items() if v is not None} result = {k: v for k, v in result.items() if v is not None}
return result return result
@ -70,10 +62,6 @@ def convert_client_resource_rewrite_input_to_provider_resource_rewrite_input(
def convert_client_resource_creation_input_to_provider_resource_creation_input( def convert_client_resource_creation_input_to_provider_resource_creation_input(
tenant_id: int, client_input: ClientInput tenant_id: int, client_input: ClientInput
) -> ProviderInput: ) -> ProviderInput:
role_id = None
if "userType" in client_input:
role = roles.get_role_by_name(tenant_id, client_input["userType"])
role_id = role["roleId"] if role else None
name = client_input.get("name", {}).get("formatted") name = client_input.get("name", {}).get("formatted")
if not name: if not name:
name = " ".join( name = " ".join(
@ -93,7 +81,6 @@ def convert_client_resource_creation_input_to_provider_resource_creation_input(
"email": client_input["userName"], "email": client_input["userName"],
"internal_id": client_input.get("externalId"), "internal_id": client_input.get("externalId"),
"name": name, "name": name,
"role_id": role_id,
} }
result = {k: v for k, v in result.items() if v is not None} result = {k: v for k, v in result.items() if v is not None}
return result return result
@ -124,7 +111,7 @@ def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None:
UPDATE public.users UPDATE public.users
SET SET
deleted_at = NULL, deleted_at = NULL,
updated_at = default updated_at = now()
WHERE WHERE
users.user_id = %(user_id)s users.user_id = %(user_id)s
AND users.tenant_id = %(tenant_id)s AND users.tenant_id = %(tenant_id)s
@ -137,6 +124,14 @@ def delete_provider_resource(resource_id: ResourceId, tenant_id: int) -> None:
def convert_provider_resource_to_client_resource( def convert_provider_resource_to_client_resource(
provider_resource: ProviderResource, provider_resource: ProviderResource,
) -> ClientResource: ) -> ClientResource:
groups = []
if provider_resource["role_id"]:
groups.append(
{
"value": str(provider_resource["role_id"]),
"$ref": f"Groups/{provider_resource['role_id']}",
}
)
return { return {
"id": str(provider_resource["user_id"]), "id": str(provider_resource["user_id"]),
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
@ -154,15 +149,8 @@ def convert_provider_resource_to_client_resource(
"formatted": provider_resource["name"], "formatted": provider_resource["name"],
}, },
"displayName": provider_resource["name"] or provider_resource["email"], "displayName": provider_resource["name"] or provider_resource["email"],
"userType": provider_resource.get("role_name"),
"active": provider_resource["deleted_at"] is None, "active": provider_resource["deleted_at"] is None,
"groups": [ "groups": groups,
{
"value": str(group["group_id"]),
"$ref": f"Groups/{group['group_id']}",
}
for group in provider_resource["groups"]
],
} }
@ -190,20 +178,8 @@ def get_provider_resource_chunk(
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
""" """
SELECT SELECT *
users.*,
roles.name AS role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = users.user_id
),
'[]'
) AS groups
FROM public.users FROM public.users
LEFT JOIN public.roles USING (role_id)
WHERE WHERE
users.tenant_id = %(tenant_id)s users.tenant_id = %(tenant_id)s
AND users.deleted_at IS NULL AND users.deleted_at IS NULL
@ -223,20 +199,8 @@ def get_provider_resource(
cur.execute( cur.execute(
cur.mogrify( cur.mogrify(
""" """
SELECT SELECT *
users.*,
roles.name AS role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = users.user_id
),
'[]'
) AS groups
FROM public.users FROM public.users
LEFT JOIN public.roles USING (role_id)
WHERE WHERE
users.user_id = %(user_id)s users.user_id = %(user_id)s
AND users.tenant_id = %(tenant_id)s AND users.tenant_id = %(tenant_id)s
@ -257,7 +221,6 @@ def create_provider_resource(
tenant_id: int, tenant_id: int,
name: str = "", name: str = "",
internal_id: str | None = None, internal_id: str | None = None,
role_id: int | None = None,
) -> ProviderResource: ) -> ProviderResource:
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -268,39 +231,24 @@ def create_provider_resource(
tenant_id, tenant_id,
email, email,
name, name,
internal_id, internal_id
role_id
) )
VALUES ( VALUES (
%(tenant_id)s, %(tenant_id)s,
%(email)s, %(email)s,
%(name)s, %(name)s,
%(internal_id)s, %(internal_id)s
%(role_id)s
) )
RETURNING * RETURNING *
) )
SELECT SELECT *
u.*,
roles.name as role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = u.user_id
),
'[]'
) AS groups
FROM u FROM u
LEFT JOIN public.roles USING (role_id)
""", """,
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"email": email, "email": email,
"name": name, "name": name,
"internal_id": internal_id, "internal_id": internal_id,
"role_id": role_id,
}, },
) )
) )
@ -312,7 +260,6 @@ def restore_provider_resource(
email: str, email: str,
name: str = "", name: str = "",
internal_id: str | None = None, internal_id: str | None = None,
role_id: int | None = None,
**kwargs: dict[str, Any], **kwargs: dict[str, Any],
) -> ProviderResource: ) -> ProviderResource:
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
@ -326,7 +273,6 @@ 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,
role_id = %(role_id)s,
deleted_at = NULL, deleted_at = NULL,
created_at = now(), created_at = now(),
updated_at = now(), updated_at = now(),
@ -336,26 +282,14 @@ def restore_provider_resource(
WHERE users.email = %(email)s WHERE users.email = %(email)s
RETURNING * RETURNING *
) )
SELECT SELECT *
u.*, FROM u
roles.name as role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = u.user_id
),
'[]'
) AS groups
FROM u LEFT JOIN public.roles USING (role_id);
""", """,
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"email": email, "email": email,
"name": name, "name": name,
"internal_id": internal_id, "internal_id": internal_id,
"role_id": role_id,
}, },
) )
) )
@ -368,7 +302,6 @@ def rewrite_provider_resource(
email: str, email: str,
name: str = "", name: str = "",
internal_id: str | None = None, internal_id: str | None = None,
role_id: int | None = None,
): ):
with pg_client.PostgresClient() as cur: with pg_client.PostgresClient() as cur:
cur.execute( cur.execute(
@ -380,7 +313,6 @@ def rewrite_provider_resource(
email = %(email)s, email = %(email)s,
name = %(name)s, name = %(name)s,
internal_id = %(internal_id)s, internal_id = %(internal_id)s,
role_id = %(role_id)s,
updated_at = now() updated_at = now()
WHERE WHERE
users.user_id = %(user_id)s users.user_id = %(user_id)s
@ -388,19 +320,8 @@ def rewrite_provider_resource(
AND users.deleted_at IS NULL AND users.deleted_at IS NULL
RETURNING * RETURNING *
) )
SELECT SELECT *
u.*, FROM u
roles.name as role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = u.user_id
),
'[]'
) AS groups
FROM u LEFT JOIN public.roles USING (role_id);
""", """,
{ {
"tenant_id": tenant_id, "tenant_id": tenant_id,
@ -408,7 +329,6 @@ def rewrite_provider_resource(
"email": email, "email": email,
"name": name, "name": name,
"internal_id": internal_id, "internal_id": internal_id,
"role_id": role_id,
}, },
) )
) )
@ -441,19 +361,8 @@ def update_provider_resource(
AND users.deleted_at IS NULL AND users.deleted_at IS NULL
RETURNING * RETURNING *
) )
SELECT SELECT *
u.*, FROM u
roles.name as role_name,
COALESCE(
(
SELECT json_agg(groups)
FROM public.user_group
JOIN public.groups USING (group_id)
WHERE user_group.user_id = u.user_id
),
'[]'
) AS groups
FROM u LEFT JOIN public.roles USING (role_id)
""" """
) )
return cur.fetchone() return cur.fetchone()

View file

@ -118,20 +118,11 @@ CREATE TABLE public.roles
protected bool NOT NULL DEFAULT FALSE, protected bool NOT NULL DEFAULT FALSE,
all_projects bool NOT NULL DEFAULT TRUE, all_projects bool NOT NULL DEFAULT TRUE,
created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()), created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
updated_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
deleted_at timestamp NULL DEFAULT NULL, deleted_at timestamp NULL DEFAULT NULL,
service_role bool NOT NULL DEFAULT FALSE service_role bool NOT NULL DEFAULT FALSE
); );
CREATE TABLE public.groups
(
group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL REFERENCES public.tenants (tenant_id) ON DELETE CASCADE,
external_id text,
name text NOT NULL,
created_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'),
updated_at timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc')
);
CREATE TYPE user_role AS ENUM ('owner','admin','member','service'); CREATE TYPE user_role AS ENUM ('owner','admin','member','service');
CREATE TABLE public.users CREATE TABLE public.users
@ -167,14 +158,6 @@ CREATE TABLE public.users
CREATE INDEX users_tenant_id_deleted_at_N_idx ON public.users (tenant_id) WHERE deleted_at ISNULL; CREATE INDEX users_tenant_id_deleted_at_N_idx ON public.users (tenant_id) WHERE deleted_at ISNULL;
CREATE INDEX users_name_gin_idx ON public.users USING GIN (name gin_trgm_ops); CREATE INDEX users_name_gin_idx ON public.users USING GIN (name gin_trgm_ops);
CREATE TABLE public.user_group
(
user_group_id integer generated BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES public.users (user_id) ON DELETE CASCADE,
group_id integer REFERENCES public.groups (group_id) ON DELETE CASCADE,
UNIQUE (user_id, group_id)
);
CREATE TABLE public.basic_authentication CREATE TABLE public.basic_authentication
( (
user_id integer NOT NULL REFERENCES public.users (user_id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES public.users (user_id) ON DELETE CASCADE,