Skip to content

Commit 1f06994

Browse files
committed
refactor: add validation to project blueprints
1 parent 5a32c11 commit 1f06994

File tree

14 files changed

+157
-184
lines changed

14 files changed

+157
-184
lines changed

components/renku_data_services/authz/authz.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from sanic.log import logger
2828
from sqlalchemy.ext.asyncio import AsyncSession
29+
from ulid import ULID
2930

3031
from renku_data_services import base_models
3132
from renku_data_services.authz.config import AuthzConfig
@@ -132,8 +133,8 @@ class AuthzOperation(StrEnum):
132133

133134
class _AuthzConverter:
134135
@staticmethod
135-
def project(id: str) -> ObjectReference:
136-
return ObjectReference(object_type=ResourceType.project.value, object_id=id)
136+
def project(id: ULID) -> ObjectReference:
137+
return ObjectReference(object_type=ResourceType.project.value, object_id=str(id))
137138

138139
@staticmethod
139140
def user(id: str | None) -> ObjectReference:
@@ -170,9 +171,9 @@ def user_namespace(id: ULID) -> ObjectReference:
170171
return ObjectReference(object_type=ResourceType.user_namespace, object_id=str(id))
171172

172173
@staticmethod
173-
def to_object(resource_type: ResourceType, resource_id: str | int) -> ObjectReference:
174+
def to_object(resource_type: ResourceType, resource_id: str | ULID | int) -> ObjectReference:
174175
match (resource_type, resource_id):
175-
case (ResourceType.project, sid) if isinstance(sid, str):
176+
case (ResourceType.project, sid) if isinstance(sid, ULID):
176177
return _AuthzConverter.project(sid)
177178
case (ResourceType.user, sid) if isinstance(sid, str) or sid is None:
178179
return _AuthzConverter.user(sid)
@@ -242,23 +243,26 @@ async def decorated_function(
242243
return decorator
243244

244245

246+
T = TypeVar("T", str, ULID)
247+
248+
245249
def _is_allowed(
246250
operation: Scope,
247251
) -> Callable[
248-
[Callable[Concatenate["Authz", base_models.APIUser, ResourceType, str, _P], Awaitable[_T]]],
249-
Callable[Concatenate["Authz", base_models.APIUser, ResourceType, str, _P], Awaitable[_T]],
252+
[Callable[Concatenate["Authz", base_models.APIUser, ResourceType, T, _P], Awaitable[_T]]],
253+
Callable[Concatenate["Authz", base_models.APIUser, ResourceType, T, _P], Awaitable[_T]],
250254
]:
251255
"""A decorator that checks if the operation on a resource is allowed or not."""
252256

253257
def decorator(
254-
f: Callable[Concatenate["Authz", base_models.APIUser, ResourceType, str, _P], Awaitable[_T]],
255-
) -> Callable[Concatenate["Authz", base_models.APIUser, ResourceType, str, _P], Awaitable[_T]]:
258+
f: Callable[Concatenate["Authz", base_models.APIUser, ResourceType, T, _P], Awaitable[_T]],
259+
) -> Callable[Concatenate["Authz", base_models.APIUser, ResourceType, T, _P], Awaitable[_T]]:
256260
@wraps(f)
257261
async def decorated_function(
258262
self: "Authz",
259263
user: base_models.APIUser,
260264
resource_type: ResourceType,
261-
resource_id: str,
265+
resource_id: T,
262266
*args: _P.args,
263267
**kwargs: _P.kwargs,
264268
) -> _T:
@@ -294,7 +298,7 @@ def client(self) -> AsyncClient:
294298
return self._client
295299

296300
async def _has_permission(
297-
self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | None, scope: Scope
301+
self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID | None, scope: Scope
298302
) -> tuple[bool, ZedToken | None]:
299303
"""Checks whether the provided user has a specific permission on the specific resource."""
300304
if not resource_id:
@@ -317,7 +321,7 @@ async def _has_permission(
317321
return response.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION, response.checked_at
318322

319323
async def has_permission(
320-
self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str, scope: Scope
324+
self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID, scope: Scope
321325
) -> bool:
322326
"""Checks whether the provided user has a specific permission on the specific resource."""
323327
res, _ = await self._has_permission(user, resource_type, resource_id, scope)
@@ -386,12 +390,13 @@ async def members(
386390
self,
387391
user: base_models.APIUser,
388392
resource_type: ResourceType,
389-
resource_id: str,
393+
resource_id: _T,
390394
role: Role | None = None,
391395
*,
392396
zed_token: ZedToken | None = None,
393397
) -> list[Member]:
394398
"""Get all users that are members of a resource, if role is None then all roles are retrieved."""
399+
resource_id: str = str(resource_id)
395400
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
396401
sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
397402
rel_filter = RelationshipFilter(
@@ -500,7 +505,7 @@ async def _get_authz_change(
500505
)
501506
authz_change.extend(db_repo.authz._add_user_namespace(res.namespace))
502507
case _:
503-
resource_id: str | None = "unknown"
508+
resource_id: str | ULID | None = "unknown"
504509
if isinstance(result, (Project, Namespace, Group)):
505510
resource_id = result.id
506511
elif isinstance(result, (ProjectUpdate, NamespaceUpdate, GroupUpdate)):
@@ -619,7 +624,7 @@ async def _remove_project(
619624
) -> _AuthzChange:
620625
"""Remove the relationships associated with the project."""
621626
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
622-
rel_filter = RelationshipFilter(resource_type=ResourceType.project.value, optional_resource_id=project.id)
627+
rel_filter = RelationshipFilter(resource_type=ResourceType.project.value, optional_resource_id=str(project.id))
623628
responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
624629
ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
625630
)
@@ -640,6 +645,7 @@ async def _update_project_visibility(
640645
self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
641646
) -> _AuthzChange:
642647
"""Update the visibility of the project in the authorization database."""
648+
project_id = str(project.id)
643649
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
644650
project_res = _AuthzConverter.project(project.id)
645651
all_users_sub = SubjectReference(object=_AuthzConverter.all_users())
@@ -668,7 +674,7 @@ async def _update_project_visibility(
668674
)
669675
rel_filter = RelationshipFilter(
670676
resource_type=ResourceType.project.value,
671-
optional_resource_id=project.id,
677+
optional_resource_id=project_id,
672678
optional_subject_filter=SubjectFilter(
673679
subject_type=ResourceType.user.value, optional_subject_id=all_users_sub.object.object_id
674680
),
@@ -678,7 +684,7 @@ async def _update_project_visibility(
678684
)
679685
rel_filter = RelationshipFilter(
680686
resource_type=ResourceType.project.value,
681-
optional_resource_id=project.id,
687+
optional_resource_id=project_id,
682688
optional_subject_filter=SubjectFilter(
683689
subject_type=ResourceType.anonymous_user.value,
684690
optional_subject_id=anon_users_sub.object.object_id,
@@ -724,11 +730,12 @@ async def _update_project_namespace(
724730
self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
725731
) -> _AuthzChange:
726732
"""Update the namespace/group of the project in the authorization database."""
733+
project_id = str(project.id)
727734
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
728735
project_res = _AuthzConverter.project(project.id)
729736
project_namespace_filter = RelationshipFilter(
730737
resource_type=ResourceType.project.value,
731-
optional_resource_id=project.id,
738+
optional_resource_id=str(project_id),
732739
optional_relation=_Relation.project_namespace.value,
733740
)
734741
current_namespace: ReadRelationshipsResponse | None = await anext(
@@ -808,7 +815,7 @@ async def upsert_project_members(
808815
self,
809816
user: base_models.APIUser,
810817
resource_type: ResourceType,
811-
resource_id: str,
818+
resource_id: ULID,
812819
members: list[Member],
813820
*,
814821
zed_token: ZedToken | None = None,
@@ -823,7 +830,8 @@ async def upsert_project_members(
823830
undo: list[RelationshipUpdate] = []
824831
output: list[MembershipChange] = []
825832
expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
826-
existing_owners_rels = await self._get_resource_owners(resource_type, resource_id, consistency)
833+
resource_id_str = str(resource_id)
834+
existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
827835
n_existing_owners = len(existing_owners_rels)
828836
for member in members:
829837
rel = Relationship(
@@ -833,7 +841,7 @@ async def upsert_project_members(
833841
)
834842
existing_rel_filter = RelationshipFilter(
835843
resource_type=resource_type.value,
836-
optional_resource_id=resource_id,
844+
optional_resource_id=resource_id_str,
837845
optional_subject_filter=SubjectFilter(
838846
subject_type=ResourceType.user, optional_subject_id=member.user_id
839847
),
@@ -926,12 +934,13 @@ async def remove_project_members(
926934
self,
927935
user: base_models.APIUser,
928936
resource_type: ResourceType,
929-
resource_id: str,
937+
resource_id: ULID,
930938
user_ids: list[str],
931939
*,
932940
zed_token: ZedToken | None = None,
933941
) -> list[MembershipChange]:
934942
"""Remove the specific members from the project, then return the list of members that were removed."""
943+
resource_id: str = str(resource_id)
935944
consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
936945
add_members: list[RelationshipUpdate] = []
937946
remove_members: list[RelationshipUpdate] = []

components/renku_data_services/base_api/auth.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,6 @@ async def decorated_function(request: Request, *args: _P.args, **kwargs: _P.kwar
4444
return decorator
4545

4646

47-
def validate_path_project_id(
48-
f: Callable[Concatenate[Request, _P], Awaitable[_T]],
49-
) -> Callable[Concatenate[Request, _P], Awaitable[_T]]:
50-
"""Decorator for a Sanic handler that validates the project_id path parameter."""
51-
_path_project_id_regex = re.compile(r"^[A-Za-z0-9]{26}$")
52-
53-
@wraps(f)
54-
async def decorated_function(request: Request, *args: _P.args, **kwargs: _P.kwargs) -> _T:
55-
project_id = cast(str | None, kwargs.get("project_id"))
56-
if not project_id:
57-
raise errors.ProgrammingError(
58-
message="Could not find 'project_id' in the keyword arguments for the handler in order to validate it."
59-
)
60-
if not _path_project_id_regex.match(project_id):
61-
raise errors.ValidationError(
62-
message=f"The 'project_id' path parameter {project_id} does not match the requried "
63-
f"regex {_path_project_id_regex}"
64-
)
65-
66-
return await f(request, *args, **kwargs)
67-
68-
return decorated_function
69-
70-
7147
def validate_path_user_id(
7248
f: Callable[Concatenate[Request, _P], Awaitable[_T]],
7349
) -> Callable[Concatenate[Request, _P], Awaitable[_T]]:

components/renku_data_services/message_queue/converters.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def to_events(
4141
Event(
4242
"project.created",
4343
v2.ProjectCreated(
44-
id=project.id,
44+
id=str(project.id),
4545
name=project.name,
4646
namespace=project.namespace.slug,
4747
slug=project.slug,
@@ -56,7 +56,7 @@ def to_events(
5656
Event(
5757
"projectAuth.added",
5858
v2.ProjectMemberAdded(
59-
projectId=project.id,
59+
projectId=str(project.id),
6060
userId=project.created_by,
6161
role=v2.MemberRole.OWNER,
6262
),
@@ -67,7 +67,7 @@ def to_events(
6767
Event(
6868
"project.updated",
6969
v2.ProjectUpdated(
70-
id=project.id,
70+
id=str(project.id),
7171
name=project.name,
7272
namespace=project.namespace.slug,
7373
slug=project.slug,
@@ -79,7 +79,7 @@ def to_events(
7979
)
8080
]
8181
case v2.ProjectRemoved:
82-
return [Event("project.removed", v2.ProjectRemoved(id=project.id))]
82+
return [Event("project.removed", v2.ProjectRemoved(id=str(project.id)))]
8383
case _:
8484
raise errors.EventError(message=f"Trying to convert a project to an unknown event type {event_type}")
8585

components/renku_data_services/project/api.spec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ paths:
144144
$ref: "#/components/responses/Error"
145145
tags:
146146
- projects
147-
/projects/{namespace}/{slug}:
147+
/namespaces/{namespace}/projects/{slug}:
148148
get:
149149
summary: Get a project by namespace and project slug
150150
parameters:

components/renku_data_services/project/apispec_base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Base models for API specifications."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, field_validator
4+
from ulid import ULID
45

56

67
class BaseAPISpec(BaseModel):
@@ -13,3 +14,9 @@ class Config:
1314
# NOTE: By default the pydantic library does not use python for regex but a rust crate
1415
# this rust crate does not support lookahead regex syntax but we need it in this component
1516
regex_engine = "python-re"
17+
18+
@field_validator("id", mode="before", check_fields=False)
19+
@classmethod
20+
def serialize_id(cls, id: str | ULID) -> str:
21+
"""Custom serializer that can handle ULIDs."""
22+
return str(id)

0 commit comments

Comments
 (0)