Skip to content

Commit 29966c7

Browse files
eikekolevski
andauthored
feat: Extend search to support new group page (#891)
- Adds keywords to the facet results, returned in alphabetical order when read from solr - Implement `inherited_member:…` and `direct_member:…` query part, supporting user slugs like `direct_member:@john.doe` and user ids `direct_member:123-456-uuid`. - while `direct_member` resolves to direct related memberships, `inherited_member` goes up the namespace hierarchy --------- Co-authored-by: Tasko Olevski <olevski90@gmail.com>
1 parent de914c8 commit 29966c7

31 files changed

Lines changed: 2058 additions & 398 deletions

bases/renku_data_services/data_api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from renku_data_services.repositories.blueprints import RepositoriesBP
3333
from renku_data_services.search.blueprints import SearchBP
3434
from renku_data_services.search.reprovision import SearchReprovision
35+
from renku_data_services.search.solr_user_query import UsernameResolve
3536
from renku_data_services.session.blueprints import BuildsBP, EnvironmentsBP, SessionLaunchersBP
3637
from renku_data_services.storage.blueprints import StorageBP, StorageSchemaBP
3738
from renku_data_services.users.blueprints import KCUsersBP, UserPreferencesBP, UserSecretsBP
@@ -238,6 +239,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
238239
name="search2",
239240
url_prefix=url_prefix,
240241
authenticator=dm.authenticator,
242+
username_resolve=UsernameResolve.db(dm.kc_user_repo),
241243
search_reprovision=SearchReprovision(
242244
search_updates_repo=dm.search_updates_repo,
243245
reprovisioning_repo=dm.reprovisioning_repo,

components/renku_data_services/authz/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class Scope(Enum):
5757
ADD_LINK = "add_link"
5858
IS_ADMIN = "is_admin"
5959
NON_PUBLIC_READ = "non_public_read"
60+
EXCLUSIVE_MEMBER = "exclusive_member"
61+
EXCLUSIVE_EDITOR = "exclusive_editor"
62+
EXCLUSIVE_OWNER = "exclusive_owner"
63+
DIRECT_MEMBER = "direct_member"
6064

6165

6266
@dataclass

components/renku_data_services/authz/schemas.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,3 +585,93 @@ def generate_v4(public_project_ids: Iterable[str]) -> AuthzSchemaMigration:
585585
up=[WriteSchemaRequest(schema=_v6)],
586586
down=[WriteSchemaRequest(schema=_v5)],
587587
)
588+
589+
_v7 = """\
590+
definition user {}
591+
592+
definition group {
593+
relation group_platform: platform
594+
relation owner: user
595+
relation editor: user
596+
relation viewer: user
597+
relation public_viewer: user:* | anonymous_user:*
598+
permission read = public_viewer + read_children
599+
permission read_children = viewer + write
600+
permission write = editor + delete
601+
permission change_membership = delete
602+
permission delete = owner + group_platform->is_admin
603+
permission non_public_read = owner + editor + viewer - public_viewer
604+
permission exclusive_owner = owner
605+
permission exclusive_editor = editor
606+
permission exclusive_member = viewer + editor + owner
607+
permission direct_member = owner + editor + viewer
608+
}
609+
610+
definition user_namespace {
611+
relation user_namespace_platform: platform
612+
relation owner: user
613+
relation public_viewer: user:* | anonymous_user:*
614+
permission read = public_viewer + read_children
615+
permission read_children = delete
616+
permission write = delete
617+
permission delete = owner + user_namespace_platform->is_admin
618+
permission non_public_read = owner - public_viewer
619+
permission exclusive_owner = owner
620+
permission exclusive_member = owner
621+
permission direct_member = owner
622+
}
623+
624+
definition anonymous_user {}
625+
626+
definition platform {
627+
relation admin: user
628+
permission is_admin = admin
629+
}
630+
631+
definition project {
632+
relation project_platform: platform
633+
relation project_namespace: user_namespace | group
634+
relation owner: user
635+
relation editor: user
636+
relation viewer: user
637+
relation public_viewer: user:* | anonymous_user:*
638+
permission read = public_viewer + read_children
639+
permission read_children = viewer + write + project_namespace->read_children
640+
permission write = editor + delete + project_namespace->write
641+
permission change_membership = delete
642+
permission delete = owner + project_platform->is_admin + project_namespace->delete
643+
permission non_public_read = owner + editor + viewer + project_namespace->read_children - public_viewer
644+
permission exclusive_owner = owner + project_namespace->exclusive_owner
645+
permission exclusive_editor = editor + project_namespace->exclusive_editor
646+
permission exclusive_member = owner + editor + viewer + project_namespace->exclusive_member
647+
permission direct_member = owner + editor + viewer
648+
}
649+
650+
definition data_connector {
651+
relation data_connector_platform: platform
652+
relation data_connector_namespace: user_namespace | group | project
653+
relation linked_to: project
654+
relation owner: user
655+
relation editor: user
656+
relation viewer: user
657+
relation public_viewer: user:* | anonymous_user:*
658+
permission read = public_viewer + viewer + write + data_connector_namespace->read_children
659+
permission write = editor + delete + data_connector_namespace->write
660+
permission change_membership = delete
661+
permission delete = owner + data_connector_platform->is_admin + data_connector_namespace->delete
662+
permission non_public_read = owner + editor + viewer + data_connector_namespace->read_children - public_viewer
663+
permission exclusive_owner = owner + data_connector_namespace->exclusive_owner
664+
permission exclusive_editor = editor + data_connector_namespace->exclusive_editor
665+
permission exclusive_member = owner + editor + viewer + data_connector_namespace->exclusive_member
666+
permission direct_member = owner + editor + viewer
667+
}"""
668+
"""This adds three permissions starting with `exclusive_` that are identifying the path of a role.
669+
670+
They are used for reverse lookups (LookupResources) to determine which
671+
objects a specific user is an owner, editor or member.
672+
"""
673+
674+
v7 = AuthzSchemaMigration(
675+
up=[WriteSchemaRequest(schema=_v7)],
676+
down=[WriteSchemaRequest(schema=_v6)],
677+
)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Non empty list."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Iterable, Iterator, Sequence
6+
from dataclasses import dataclass
7+
from dataclasses import field as data_field
8+
from typing import Never, overload
9+
10+
11+
@dataclass
12+
class Nel[A](Sequence[A]):
13+
"""A non empty list."""
14+
15+
value: A
16+
more_values: Sequence[A] = data_field(default_factory=list)
17+
18+
@classmethod
19+
def of(cls, el: A, *args: A) -> Nel[A]:
20+
"""Constructor using varargs."""
21+
return Nel(value=el, more_values=list(args))
22+
23+
@classmethod
24+
def unsafe_from_list(cls, els: Sequence[A]) -> Nel[A]:
25+
"""Creates a non-empty list from a list, failing if the argument is empty."""
26+
return Nel(els[0], els[1:])
27+
28+
@classmethod
29+
def from_list(cls, els: Sequence[A]) -> Nel[A] | None:
30+
"""Creates a non-empty list from a list."""
31+
if els == []:
32+
return None
33+
else:
34+
return cls.unsafe_from_list(els)
35+
36+
def __iter__(self) -> Iterator[A]:
37+
return _NelIterator(self.value, self.more_values)
38+
39+
@overload
40+
def __getitem__(self, key: int) -> A: ...
41+
@overload
42+
def __getitem__(self, key: slice[int]) -> Never: ...
43+
44+
def __getitem__(self, key: int | slice[int]) -> A | Sequence[A]:
45+
if isinstance(key, slice):
46+
raise NotImplementedError("slicing non-empty lists is not supported")
47+
if key == 0:
48+
return self.value
49+
else:
50+
return self.more_values[key - 1]
51+
52+
def __len__(self) -> int:
53+
return len(self.more_values) + 1
54+
55+
def append(self, other: Iterable[A]) -> Nel[A]:
56+
"""Append other to this list."""
57+
if not other:
58+
return self
59+
else:
60+
remain = [*self.more_values, *other]
61+
return Nel(self.value, remain)
62+
63+
def to_list(self) -> list[A]:
64+
"""Convert to a list."""
65+
lst = [self.value]
66+
lst.extend(self.more_values)
67+
return lst
68+
69+
def to_set(self) -> set[A]:
70+
"""Convert to a set."""
71+
return set(self.more_values) | {self.value}
72+
73+
def mk_string(self, sep: str, f: Callable[[A], str] = str) -> str:
74+
"""Create a str from all elements mapped over f."""
75+
return sep.join([f(x) for x in self])
76+
77+
def map[B](self, f: Callable[[A], B]) -> Nel[B]:
78+
"""Maps `f` over this list."""
79+
head = f(self.value)
80+
rest = [f(x) for x in self.more_values]
81+
return Nel(head, rest)
82+
83+
84+
class _NelIterator[A](Iterator[A]):
85+
"""Iterator for non empty lists."""
86+
87+
def __init__(self, head: A, tail: Sequence[A]) -> None:
88+
self._head = head
89+
self._tail = tail
90+
self._tail_len = len(tail)
91+
self._index = 0
92+
93+
def __iter__(self) -> Iterator[A]:
94+
return self
95+
96+
def __next__(self) -> A:
97+
if self._index == 0:
98+
self._index += 1
99+
return self._head
100+
else:
101+
idx = self._index - 1
102+
if idx < self._tail_len:
103+
item = self._tail[idx]
104+
self._index += 1
105+
return item
106+
else:
107+
raise StopIteration
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Add exclusive role relations
2+
3+
Revision ID: cfda91a3a6a6
4+
Revises: f4ad62b7b323
5+
Create Date: 2025-06-13 16:06:26.421053
6+
7+
"""
8+
9+
from renku_data_services.app_config import logging
10+
from renku_data_services.authz.config import AuthzConfig
11+
from renku_data_services.authz.schemas import v7
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "cfda91a3a6a6"
15+
down_revision = "f4ad62b7b323"
16+
branch_labels = None
17+
depends_on = None
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
def upgrade() -> None:
23+
config = AuthzConfig.from_env()
24+
client = config.authz_client()
25+
responses = v7.upgrade(client)
26+
logger.info(
27+
f"Finished upgrading the Authz schema to version 7 in Alembic revision {revision}, response: {responses}"
28+
)
29+
30+
31+
def downgrade() -> None:
32+
config = AuthzConfig.from_env()
33+
client = config.authz_client()
34+
responses = v7.downgrade(client)
35+
logger.info(
36+
f"Finished downgrading the Authz schema from version 7 in Alembic revision {revision}, response: {responses}"
37+
)

components/renku_data_services/search/api.spec.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,12 @@ components:
137137
type: object
138138
required:
139139
- entityType
140+
- keywords
140141
properties:
141142
entityType:
142143
$ref: '#/components/schemas/Map_EntityType_Int'
144+
keywords:
145+
$ref: '#/components/schemas/Map_EntityType_Int'
143146
Group:
144147
title: Group
145148
examples:

components/renku_data_services/search/apispec.py

Lines changed: 2 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: 2025-05-16T14:57:02+00:00
3+
# timestamp: 2025-06-12T09:56:04+00:00
44

55
from __future__ import annotations
66

@@ -112,6 +112,7 @@ class SearchQuery(PaginationRequest):
112112

113113
class FacetData(BaseAPISpec):
114114
entityType: MapEntityTypeInt
115+
keywords: MapEntityTypeInt
115116

116117

117118
class SearchProject(BaseAPISpec):

0 commit comments

Comments
 (0)