Skip to content

Commit a449135

Browse files
authored
feat: add support for filtering project and group listing by direct membership (#394)
Add support to filter the project and group list endpoints by using the query parameter `direct_member`. When used, the group or project list will contain only items the current user is a direct member of.
1 parent 0e7ae79 commit a449135

File tree

11 files changed

+201
-25
lines changed

11 files changed

+201
-25
lines changed

components/renku_data_services/authz/authz.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,31 @@ async def resources_with_permission(
358358
ids.append(response.resource_object_id)
359359
return ids
360360

361+
async def resources_with_direct_membership(
362+
self, user: base_models.APIUser, resource_type: ResourceType
363+
) -> list[str]:
364+
"""Get all the resource IDs (for a specific resource kind) that a specific user is a direct member of."""
365+
resource_ids: list[str] = []
366+
if user.id is None:
367+
return resource_ids
368+
369+
rel_filter = RelationshipFilter(
370+
resource_type=resource_type.value,
371+
optional_subject_filter=SubjectFilter(subject_type=ResourceType.user.value, optional_subject_id=user.id),
372+
)
373+
374+
responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
375+
ReadRelationshipsRequest(
376+
consistency=Consistency(fully_consistent=True),
377+
relationship_filter=rel_filter,
378+
)
379+
)
380+
381+
async for response in responses:
382+
resource_ids.append(response.relationship.resource.object_id)
383+
384+
return resource_ids
385+
361386
@_is_allowed(Scope.READ) # The scope on the resource that allows the user to perform this check in the first place
362387
async def users_with_permission(
363388
self,

components/renku_data_services/namespace/api.spec.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ paths:
1919
style: form
2020
explode: true
2121
schema:
22-
$ref: "#/components/schemas/PaginationRequest"
22+
$ref: "#/components/schemas/GroupsGetQuery"
2323
responses:
2424
"200":
2525
description: List of groups
@@ -525,6 +525,15 @@ components:
525525
minimum_role:
526526
description: A minimum role to filter results by.
527527
$ref: "#/components/schemas/GroupRole"
528+
GroupsGetQuery:
529+
description: Query params for namespace get request
530+
allOf:
531+
- $ref: "#/components/schemas/PaginationRequest"
532+
- properties:
533+
direct_member:
534+
description: A flag to filter groups where the user is a direct member.
535+
type: boolean
536+
default: false
528537
PaginationRequest:
529538
type: object
530539
additionalProperties: false

components/renku_data_services/namespace/apispec.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2024-08-30T10:14:05+00:00
3+
# timestamp: 2024-10-03T13:53:07+00:00
44

55
from __future__ import annotations
66

@@ -83,10 +83,6 @@ class ErrorResponse(BaseAPISpec):
8383
error: Error
8484

8585

86-
class GroupsGetParametersQuery(BaseAPISpec):
87-
params: Optional[PaginationRequest] = None
88-
89-
9086
class GroupResponse(BaseAPISpec):
9187
id: str = Field(
9288
...,
@@ -263,6 +259,16 @@ class NamespaceGetQuery(PaginationRequest):
263259
)
264260

265261

262+
class GroupsGetQuery(PaginationRequest):
263+
direct_member: bool = Field(
264+
False, description="A flag to filter groups where the user is a direct member."
265+
)
266+
267+
268+
class GroupsGetParametersQuery(BaseAPISpec):
269+
params: Optional[GroupsGetQuery] = None
270+
271+
266272
class NamespacesGetParametersQuery(BaseAPISpec):
267273
params: Optional[NamespaceGetQuery] = None
268274

components/renku_data_services/namespace/blueprints.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ def get_all(self) -> BlueprintFactoryResponse:
2929
"""List all groups."""
3030

3131
@authenticate(self.authenticator)
32-
@validate_query(query=apispec.PaginationRequest)
32+
@validate_query(query=apispec.GroupsGetQuery)
3333
@paginate
3434
async def _get_all(
35-
_: Request, user: base_models.APIUser, pagination: PaginationRequest, query: apispec.PaginationRequest
35+
_: Request, user: base_models.APIUser, pagination: PaginationRequest, query: apispec.GroupsGetQuery
3636
) -> tuple[list[dict], int]:
37-
groups, rec_count = await self.group_repo.get_groups(user=user, pagination=pagination)
37+
groups, rec_count = await self.group_repo.get_groups(
38+
user=user, pagination=pagination, direct_member=query.direct_member
39+
)
3840
return (
3941
validate_and_dump(apispec.GroupResponseList, groups),
4042
rec_count,

components/renku_data_services/namespace/db.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,18 @@ async def generate_user_namespaces(self, *, session: AsyncSession | None = None)
7474
return output
7575

7676
async def get_groups(
77-
self,
78-
user: base_models.APIUser,
79-
pagination: PaginationRequest,
77+
self, user: base_models.APIUser, pagination: PaginationRequest, direct_member: bool = False
8078
) -> tuple[list[models.Group], int]:
8179
"""Get all groups from the database."""
80+
if direct_member:
81+
group_ids = await self.authz.resources_with_direct_membership(user, ResourceType.group)
82+
8283
async with self.session_maker() as session, session.begin():
83-
stmt = select(schemas.GroupORM).limit(pagination.per_page).offset(pagination.offset)
84-
stmt = stmt.order_by(schemas.GroupORM.creation_date.asc(), schemas.GroupORM.id.asc(), schemas.GroupORM.name)
84+
stmt = select(schemas.GroupORM)
85+
if direct_member:
86+
stmt = stmt.where(schemas.GroupORM.id.in_(group_ids))
87+
stmt = stmt.limit(pagination.per_page).offset(pagination.offset)
88+
stmt = stmt.order_by(schemas.GroupORM.id.desc())
8589
result = await session.execute(stmt)
8690
groups_orm = result.scalars().all()
8791

components/renku_data_services/project/api.spec.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ components:
283283
$ref: "#/components/schemas/CreationDate"
284284
created_by:
285285
$ref: "#/components/schemas/UserId"
286+
updated_at:
287+
$ref: "#/components/schemas/UpdatedAt"
286288
repositories:
287289
$ref: "#/components/schemas/RepositoriesList"
288290
visibility:
@@ -385,6 +387,11 @@ components:
385387
type: string
386388
format: date-time
387389
example: "2023-11-01T17:32:28Z"
390+
UpdatedAt:
391+
description: The date and time the resource was updated (in UTC and ISO-8601 format)
392+
type: string
393+
format: date-time
394+
example: "2023-11-01T17:32:28Z"
388395
Description:
389396
description: A description for the resource
390397
type: string
@@ -504,6 +511,10 @@ components:
504511
description: A namespace, used as a filter.
505512
type: string
506513
default: ""
514+
direct_member:
515+
description: A flag to filter projects where the user is a direct member.
516+
type: boolean
517+
default: false
507518
PaginationRequest:
508519
type: object
509520
additionalProperties: false

components/renku_data_services/project/apispec.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2024-08-30T09:58:32+00:00
3+
# timestamp: 2024-09-30T13:50:44+00:00
44

55
from __future__ import annotations
66

@@ -105,6 +105,10 @@ class ProjectMemberResponse(BaseAPISpec):
105105

106106
class ProjectGetQuery(PaginationRequest):
107107
namespace: str = Field("", description="A namespace, used as a filter.")
108+
direct_member: bool = Field(
109+
False,
110+
description="A flag to filter projects where the user is a direct member.",
111+
)
108112

109113

110114
class ProjectsGetParametersQuery(BaseAPISpec):
@@ -153,6 +157,11 @@ class Project(BaseAPISpec):
153157
example="f74a228b-1790-4276-af5f-25c2424e9b0c",
154158
pattern="^[A-Za-z0-9]{1}[A-Za-z0-9-]+$",
155159
)
160+
updated_at: Optional[datetime] = Field(
161+
None,
162+
description="The date and time the resource was updated (in UTC and ISO-8601 format)",
163+
example="2023-11-01T17:32:28Z",
164+
)
156165
repositories: Optional[List[str]] = Field(
157166
None,
158167
description="A list of repositories",

components/renku_data_services/project/blueprints.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def _get_all(
4646
request: Request, user: base_models.APIUser, pagination: PaginationRequest, query: apispec.ProjectGetQuery
4747
) -> tuple[list[dict[str, Any]], int]:
4848
projects, total_num = await self.project_repo.get_projects(
49-
user=user, pagination=pagination, namespace=query.namespace
49+
user=user, pagination=pagination, namespace=query.namespace, direct_member=query.direct_member
5050
)
5151
return [
5252
dict(
@@ -56,6 +56,7 @@ async def _get_all(
5656
slug=p.slug,
5757
creation_date=p.creation_date.isoformat(),
5858
created_by=p.created_by,
59+
updated_at=p.updated_at.isoformat() if p.updated_at else None,
5960
repositories=p.repositories,
6061
visibility=p.visibility.value,
6162
description=p.description,

components/renku_data_services/project/db.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from sqlalchemy import Select, delete, func, select, update
1111
from sqlalchemy.ext.asyncio import AsyncSession
12+
from sqlalchemy.sql.functions import coalesce
1213
from ulid import ULID
1314

1415
import renku_data_services.base_models as base_models
@@ -48,18 +49,29 @@ def __init__(
4849
self.authz = authz
4950

5051
async def get_projects(
51-
self, user: base_models.APIUser, pagination: PaginationRequest, namespace: str | None = None
52+
self,
53+
user: base_models.APIUser,
54+
pagination: PaginationRequest,
55+
namespace: str | None = None,
56+
direct_member: bool = False,
5257
) -> tuple[list[models.Project], int]:
5358
"""Get all projects from the database."""
54-
project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, Scope.READ)
59+
project_ids = []
60+
if direct_member:
61+
project_ids = await self.authz.resources_with_direct_membership(user, ResourceType.project)
62+
else:
63+
project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, Scope.READ)
5564

5665
async with self.session_maker() as session:
5766
stmt = select(schemas.ProjectORM)
5867
stmt = stmt.where(schemas.ProjectORM.id.in_(project_ids))
5968
if namespace:
6069
stmt = _filter_by_namespace_slug(stmt, namespace)
70+
71+
stmt = stmt.order_by(coalesce(schemas.ProjectORM.updated_at, schemas.ProjectORM.creation_date).desc())
72+
6173
stmt = stmt.limit(pagination.per_page).offset(pagination.offset)
62-
stmt = stmt.order_by(schemas.ProjectORM.creation_date.desc())
74+
6375
stmt_count = (
6476
select(func.count()).select_from(schemas.ProjectORM).where(schemas.ProjectORM.id.in_(project_ids))
6577
)

test/bases/renku_data_services/data_api/test_groups.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,15 @@ async def test_group_pagination(
9191
res2_json = res2.json
9292
assert len(res1_json) == 12
9393
assert len(res2_json) == 3
94-
assert res1_json[0]["name"] == "group0"
95-
assert res1_json[-1]["name"] == "group11"
96-
assert res2_json[0]["name"] == "group12"
97-
assert res2_json[-1]["name"] == "group14"
94+
assert res1_json[0]["name"] == "group14"
95+
assert res1_json[-1]["name"] == "group3"
96+
assert res2_json[0]["name"] == "group2"
97+
assert res2_json[-1]["name"] == "group0"
9898
_, res3 = await sanic_client.get("/api/data/groups", headers=admin_headers, params={"per_page": 20, "page": 1})
9999
res3_json = res3.json
100100
assert len(res3_json) == 15
101-
assert res3_json[0]["name"] == "group0"
102-
assert res3_json[-1]["name"] == "group14"
101+
assert res3_json[0]["name"] == "group14"
102+
assert res3_json[-1]["name"] == "group0"
103103

104104

105105
@pytest.mark.asyncio
@@ -461,3 +461,47 @@ async def test_get_group_anonymously(sanic_client, user_headers) -> None:
461461
member_2 = members[1]
462462
assert member_2["id"] == "member-1"
463463
assert member_2["role"] == "viewer"
464+
465+
466+
@pytest.mark.asyncio
467+
async def test_get_groups_with_direct_membership(sanic_client, user_headers, member_1_headers, member_1_user) -> None:
468+
# Create a group
469+
namespace = "my-group"
470+
payload = {
471+
"name": "Group",
472+
"slug": namespace,
473+
}
474+
_, response = await sanic_client.post("/api/data/groups", headers=user_headers, json=payload)
475+
assert response.status_code == 201, response.text
476+
group_1 = response.json
477+
478+
# Create another group
479+
namespace_2 = "my-second-group"
480+
payload = {
481+
"name": "Group 2",
482+
"slug": namespace_2,
483+
}
484+
_, response = await sanic_client.post("/api/data/groups", headers=user_headers, json=payload)
485+
assert response.status_code == 201, response.text
486+
group_2 = response.json
487+
488+
# Add member_1 to Group 2
489+
roles = [{"id": member_1_user.id, "role": "editor"}]
490+
_, response = await sanic_client.patch(f"/api/data/groups/{namespace_2}/members", headers=user_headers, json=roles)
491+
assert response.status_code == 200, response.text
492+
493+
# Get groups where member_1 has direct membership
494+
parameters = {"direct_member": True}
495+
_, response = await sanic_client.get("/api/data/groups", headers=member_1_headers, params=parameters)
496+
assert response.status_code == 200, response.text
497+
groups = response.json
498+
assert len(groups) == 1
499+
group_ids = {g["id"] for g in groups}
500+
assert group_ids == {group_2["id"]}
501+
502+
# Check that both groups can be seen without the filter
503+
_, response = await sanic_client.get("/api/data/groups", headers=member_1_headers)
504+
groups = response.json
505+
assert len(groups) == 2
506+
group_ids = {g["id"] for g in groups}
507+
assert group_ids == {group_1["id"], group_2["id"]}

0 commit comments

Comments
 (0)