Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: data connectors in project creation #617

Draft
wants to merge 10 commits into
base: feat-add-project-as-dc-owner
Choose a base branch
from
1 change: 1 addition & 0 deletions bases/renku_data_services/background_jobs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def from_env(cls, prefix: str = "") -> "SyncConfig":
data_connector_repo = DataConnectorRepository(
session_maker=session_maker,
authz=Authz(authz_config),
project_repo=project_repo,
)
data_connector_project_link_repo = DataConnectorProjectLinkRepository(
session_maker=session_maker,
Expand Down
4 changes: 3 additions & 1 deletion components/renku_data_services/app_config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,9 @@ def data_connector_repo(self) -> DataConnectorRepository:
"""The DB adapter for data connectors."""
if not self._data_connector_repo:
self._data_connector_repo = DataConnectorRepository(
session_maker=self.db.async_session_maker, authz=self.authz
session_maker=self.db.async_session_maker,
authz=self.authz,
project_repo=self.project_repo,
)
return self._data_connector_repo

Expand Down
84 changes: 27 additions & 57 deletions components/renku_data_services/authz/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,11 +687,6 @@ async def _get_authz_change(
if result.old.namespace.id != result.new.namespace.id:
user = _extract_user_from_args(*func_args, **func_kwargs)
authz_change.extend(await db_repo.authz._update_data_connector_namespace(user, result.new))
case AuthzOperation.create_link, ResourceType.data_connector if isinstance(
result, DataConnectorToProjectLink
):
user = _extract_user_from_args(*func_args, **func_kwargs)
authz_change = await db_repo.authz._add_data_connector_to_project_link(user, result)
case AuthzOperation.delete_link, ResourceType.data_connector if result is None:
# NOTE: This means that the link does not exist in the first place so nothing was deleted
pass
Expand Down Expand Up @@ -1570,25 +1565,41 @@ async def _remove_user_namespace(self, user_id: str, zed_token: ZedToken | None

def _add_data_connector(self, data_connector: DataConnector) -> _AuthzChange:
"""Create the new data connector and associated resources and relations in the DB."""
creator = SubjectReference(object=_AuthzConverter.user(data_connector.created_by))
data_connector_res = _AuthzConverter.data_connector(data_connector.id)
creator_is_owner = Relationship(resource=data_connector_res, relation=_Relation.owner.value, subject=creator)
match data_connector.namespace.kind:
case NamespaceKind.project:
project_id = (
ULID.from_str(data_connector.namespace.underlying_resource_id)
if isinstance(data_connector.namespace.underlying_resource_id, str)
else data_connector.namespace.underlying_resource_id
)
owned_by = _AuthzConverter.project(project_id)
case NamespaceKind.user:
owned_by = _AuthzConverter.user_namespace(data_connector.namespace.id)
case NamespaceKind.group:
group_id = (
ULID.from_str(data_connector.namespace.underlying_resource_id)
if isinstance(data_connector.namespace.underlying_resource_id, str)
else data_connector.namespace.underlying_resource_id
)
owned_by = _AuthzConverter.group(group_id)
case _:
raise errors.ProgrammingError(
message="Tried to match unexpected data connector namespace kind", quiet=True
)
owner = Relationship(
resource=data_connector_res,
relation=_Relation.data_connector_namespace,
subject=SubjectReference(object=owned_by),
)
all_users = SubjectReference(object=_AuthzConverter.all_users())
all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
data_connector_namespace = SubjectReference(
object=_AuthzConverter.user_namespace(data_connector.namespace.id)
if data_connector.namespace.kind == NamespaceKind.user
else _AuthzConverter.group(cast(ULID, data_connector.namespace.underlying_resource_id))
)
data_connector_in_platform = Relationship(
resource=data_connector_res,
relation=_Relation.data_connector_platform,
subject=SubjectReference(object=self._platform),
)
data_connector_in_namespace = Relationship(
resource=data_connector_res, relation=_Relation.data_connector_namespace, subject=data_connector_namespace
)
relationships = [creator_is_owner, data_connector_in_platform, data_connector_in_namespace]
relationships = [owner, data_connector_in_platform]
if data_connector.visibility == Visibility.PUBLIC:
all_users_are_viewers = Relationship(
resource=data_connector_res,
Expand Down Expand Up @@ -1824,47 +1835,6 @@ async def _update_data_connector_namespace(
)
return _AuthzChange(apply=apply_change, undo=undo_change)

async def _add_data_connector_to_project_link(
self, user: base_models.APIUser, link: DataConnectorToProjectLink
) -> _AuthzChange:
"""Links a data connector to a project."""
# NOTE: we manually check for permissions here since it is not trivially expressed through decorators
allowed_from = await self.has_permission(
user, ResourceType.data_connector, link.data_connector_id, Scope.ADD_LINK
)
if not allowed_from:
raise errors.MissingResourceError(
message=f"The user with ID {user.id} cannot perform operation {Scope.ADD_LINK} "
f"on {ResourceType.data_connector.value} "
f"with ID {link.data_connector_id} or the resource does not exist."
)
allowed_to = await self.has_permission(user, ResourceType.project, link.project_id, Scope.WRITE)
if not allowed_to:
raise errors.MissingResourceError(
message=f"The user with ID {user.id} cannot perform operation {Scope.WRITE} "
f"on {ResourceType.project.value} "
f"with ID {link.project_id} or the resource does not exist."
)

data_connector_res = _AuthzConverter.data_connector(link.data_connector_id)
project_subject = SubjectReference(object=_AuthzConverter.project(link.project_id))
relationship = Relationship(
resource=data_connector_res,
relation=_Relation.linked_to.value,
subject=project_subject,
)
apply = WriteRelationshipsRequest(
updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=relationship)]
)
undo = WriteRelationshipsRequest(
updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=relationship)]
)
change = _AuthzChange(
apply=apply,
undo=undo,
)
return change

async def _remove_data_connector_to_project_link(
self, user: base_models.APIUser, link: DataConnectorToProjectLink
) -> _AuthzChange:
Expand Down
6 changes: 3 additions & 3 deletions components/renku_data_services/authz/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,8 @@ def generate_v4(public_project_ids: Iterable[str]) -> AuthzSchemaMigration:
relation editor: user
relation viewer: user
relation public_viewer: user:* | anonymous_user:*
permission read = public_viewer + read_children
permission read_children = viewer + write + project_namespace->read_children
permission read = read_children
permission read_children = public_viewer + viewer + write + project_namespace->read_children
permission write = editor + delete + project_namespace->write
permission change_membership = delete
permission delete = owner + project_platform->is_admin + project_namespace->delete
Expand All @@ -504,7 +504,7 @@ def generate_v4(public_project_ids: Iterable[str]) -> AuthzSchemaMigration:
relation editor: user
relation viewer: user
relation public_viewer: user:* | anonymous_user:*
permission read = public_viewer + viewer + write + data_connector_namespace->read
permission read = public_viewer + viewer + write + data_connector_namespace->read_children
permission write = editor + delete + data_connector_namespace->write
permission change_membership = delete
permission delete = owner + data_connector_platform->is_admin + data_connector_namespace->delete
Expand Down
60 changes: 58 additions & 2 deletions components/renku_data_services/base_models/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def from_name(cls, name: str) -> Self:
no_space = re.sub(r"\s+", "-", lower_case)
normalized = unicodedata.normalize("NFKD", no_space).encode("ascii", "ignore").decode("utf-8")
valid_chars_pattern = [r"\w", ".", "_", "-"]
no_invalid_characters = re.sub(f'[^{"".join(valid_chars_pattern)}]', "-", normalized)
no_invalid_characters = re.sub(f"[^{''.join(valid_chars_pattern)}]", "-", normalized)
no_duplicates = re.sub(r"([._-])[._-]+", r"\1", no_invalid_characters)
valid_start = re.sub(r"^[._-]", "", no_duplicates)
valid_end = re.sub(r"[._-]$", "", valid_start)
Expand Down Expand Up @@ -192,7 +192,7 @@ def from_user(cls, email: str | None, first_name: str | None, last_name: str | N
slug = slug[:80]
return cls.from_name(slug)

def __true_div__(self, other: "Slug") -> str:
def __truediv__(self, other: "Slug") -> str:
"""Joins two slugs into a path fraction without dashes at the beginning or end."""
if type(self) is not type(other):
raise errors.ValidationError(
Expand All @@ -207,6 +207,62 @@ def __repr__(self) -> str:
return self.value


@dataclass(frozen=True, eq=True)
class EntityPath:
"""The collection of slugs that makes up the path to an entity in Renku."""

slugs: list[Slug] = field(default_factory=list)

def __repr__(self) -> str:
return "/".join([slug.value for slug in self.slugs])

def __truediv__(self, other: Slug | str | Self) -> "EntityPath":
"""Create new entity path with an extra slug."""
slugs = [slug for slug in self.slugs]
if isinstance(other, Slug):
slugs.append(other)
elif isinstance(other, str):
slugs.append(Slug(other))
elif type(self) is type(other):
slugs.extend(other.slugs)
else:
raise errors.ValidationError(
message="A path can be constructed from an entity path and a slug,string or entity path, "
f"but the 'divisor' is of type {type(other)}"
)
return EntityPath(slugs)

@classmethod
def join(cls, *slugs: Slug | str) -> Self:
"""Create an entity path from a list of slugs or strings."""
res: list[Slug] = []
for slug in slugs:
if isinstance(slug, Slug):
res.append(slug)
elif isinstance(slug, str):
res.append(Slug(slug))
else:
raise errors.ValidationError(
message="A path can be constructed from a list of slugs or strings, "
f"but a value in the list is of type {type(slug)}"
)
return cls(res)

@classmethod
def from_string(cls, path: str) -> Self:
"""Create an entity path from a single string which may contain several slugs."""
res: list[Slug] = []
for slug in path.split("/"):
res.append(Slug(slug))
return cls(res)

def __getitem__(self, ind: int) -> Slug:
return self.slugs[ind]

def __len__(self) -> int:
return len(self.slugs)


AnyAPIUser = TypeVar("AnyAPIUser", bound=APIUser, covariant=True)


Expand Down
13 changes: 10 additions & 3 deletions components/renku_data_services/data_connectors/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ components:
name:
$ref: "#/components/schemas/DataConnectorName"
namespace:
$ref: "#/components/schemas/Slug"
$ref: "#/components/schemas/OneOrTwoSlugs"
slug:
$ref: "#/components/schemas/Slug"
storage:
Expand Down Expand Up @@ -364,7 +364,7 @@ components:
name:
$ref: "#/components/schemas/DataConnectorName"
namespace:
$ref: "#/components/schemas/Slug"
$ref: "#/components/schemas/OneOrTwoSlugs"
slug:
$ref: "#/components/schemas/Slug"
storage:
Expand All @@ -391,7 +391,7 @@ components:
name:
$ref: "#/components/schemas/DataConnectorName"
namespace:
$ref: "#/components/schemas/Slug"
$ref: "#/components/schemas/OneOrTwoSlugs"
slug:
$ref: "#/components/schemas/Slug"
storage:
Expand Down Expand Up @@ -644,6 +644,13 @@ components:
# - cannot contain uppercase characters
pattern: '^(?!.*\.git$|.*\.atom$|.*[\-._][\-._].*)[a-z0-9][a-z0-9\-_.]*$'
example: "a-slug-example"
OneOrTwoSlugs:
description: A command-line/url friendly name for a single slug or two slugs separated by /
type: string
minLength: 1
maxLength: 200
pattern: '^(?!.*\.git$|.*\.atom$|.*[\-._][\-._].*)[a-z0-9][a-z0-9\-_.]*(?<!\.git)(?<!\.atom)(?:/[a-z0-9][a-z0-9\-_.]*){0,1}$'
example: "user1/project-1"
CreationDate:
description: The date and time the resource was created (in UTC and ISO-8601 format)
type: string
Expand Down
26 changes: 13 additions & 13 deletions components/renku_data_services/data_connectors/apispec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: api.spec.yaml
# timestamp: 2024-11-22T08:23:26+00:00
# timestamp: 2025-01-23T08:51:51+00:00

from __future__ import annotations

Expand Down Expand Up @@ -326,11 +326,11 @@ class DataConnector(BaseAPISpec):
)
namespace: str = Field(
...,
description="A command-line/url friendly name for a namespace",
example="a-slug-example",
max_length=99,
description="A command-line/url friendly name for a single slug or two slugs separated by /",
example="user1/project-1",
max_length=200,
min_length=1,
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*$",
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*(?<!\\.git)(?<!\\.atom)(?:/[a-z0-9][a-z0-9\\-_.]*){0,1}$",
)
slug: str = Field(
...,
Expand Down Expand Up @@ -380,11 +380,11 @@ class DataConnectorPost(BaseAPISpec):
)
namespace: str = Field(
...,
description="A command-line/url friendly name for a namespace",
example="a-slug-example",
max_length=99,
description="A command-line/url friendly name for a single slug or two slugs separated by /",
example="user1/project-1",
max_length=200,
min_length=1,
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*$",
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*(?<!\\.git)(?<!\\.atom)(?:/[a-z0-9][a-z0-9\\-_.]*){0,1}$",
)
slug: Optional[str] = Field(
None,
Expand Down Expand Up @@ -420,11 +420,11 @@ class DataConnectorPatch(BaseAPISpec):
)
namespace: Optional[str] = Field(
None,
description="A command-line/url friendly name for a namespace",
example="a-slug-example",
max_length=99,
description="A command-line/url friendly name for a single slug or two slugs separated by /",
example="user1/project-1",
max_length=200,
min_length=1,
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*$",
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*(?<!\\.git)(?<!\\.atom)(?:/[a-z0-9][a-z0-9\\-_.]*){0,1}$",
)
slug: Optional[str] = Field(
None,
Expand Down
Loading
Loading