Skip to content

Commit 6968ea0

Browse files
committed
feat: add custom openBIS type
1 parent 0b5124a commit 6968ea0

File tree

21 files changed

+696
-85
lines changed

21 files changed

+696
-85
lines changed

DEVELOPING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ function if you prefer to keep your favorite shell.
119119
You can run style checks using `make style_checks`.
120120
To run the test suite, use `make tests` (you likely need to run in the devcontainer for this to work, as it needs
121121
some surrounding services to run).
122+
* Run a specific test e.g.: `poetry run pytest -v test/bases/renku_data_services/data_api/test_data_connectors.py::test_create_openbis_data_connector`
123+
* Also run tests marked with `@pytest.mark.myskip`: `PYTEST_FORCE_RUN_MYSKIPS=1 make tests`
122124

123125
We use [Syrupy](https://github.com/syrupy-project/syrupy) for snapshotting data in tests.
124126

components/renku_data_services/data_connectors/blueprints.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"""Data connectors blueprint."""
22

33
from dataclasses import dataclass
4+
from datetime import datetime
45
from typing import Any
56

67
from sanic import Request
78
from sanic.response import HTTPResponse, JSONResponse
89
from sanic_ext import validate
910
from ulid import ULID
1011

11-
from renku_data_services import base_models
12+
from renku_data_services import base_models, errors
1213
from renku_data_services.base_api.auth import (
1314
authenticate,
1415
only_authenticated,
@@ -38,8 +39,8 @@
3839
DataConnectorRepository,
3940
DataConnectorSecretRepository,
4041
)
41-
from renku_data_services.errors import errors
4242
from renku_data_services.storage.rclone import RCloneValidator
43+
from renku_data_services.utils.core import get_openbis_pat
4344

4445

4546
@dataclass(kw_only=True)
@@ -418,8 +419,15 @@ async def _get_secrets(
418419
secrets = await self.data_connector_secret_repo.get_data_connector_secrets(
419420
user=user, data_connector_id=data_connector_id
420421
)
422+
data_connector = await self.data_connector_repo.get_data_connector(
423+
user=user, data_connector_id=data_connector_id
424+
)
421425
return validated_json(
422-
apispec.DataConnectorSecretsList, [self._dump_data_connector_secret(secret) for secret in secrets]
426+
apispec.DataConnectorSecretsList,
427+
[
428+
self._dump_data_connector_secret(secret)
429+
for secret in self._adjust_secrets(secrets, data_connector.storage)
430+
],
423431
)
424432

425433
return "/data_connectors/<data_connector_id:ulid>/secrets", ["GET"], _get_secrets
@@ -435,13 +443,59 @@ async def _patch_secrets(
435443
user: base_models.APIUser,
436444
data_connector_id: ULID,
437445
body: apispec.DataConnectorSecretPatchList,
446+
validator: RCloneValidator,
438447
) -> JSONResponse:
439448
unsaved_secrets = validate_data_connector_secrets_patch(put=body)
449+
data_connector = await self.data_connector_repo.get_data_connector(
450+
user=user, data_connector_id=data_connector_id
451+
)
452+
storage = data_connector.storage
453+
provider = validator.providers[storage.storage_type]
454+
sensitive_lookup = [o.name for o in provider.options if o.sensitive]
455+
for secret in unsaved_secrets:
456+
if secret.name in sensitive_lookup:
457+
continue
458+
raise errors.ValidationError(
459+
message=f"The '{secret.name}' property is not marked sensitive and can not be saved in the secret "
460+
f"storage."
461+
)
462+
expiration_timestamp = None
463+
464+
if storage.storage_type == "openbis":
465+
466+
async def openbis_transform_session_token_to_pat() -> (
467+
tuple[list[models.DataConnectorSecretUpdate], datetime]
468+
):
469+
if len(unsaved_secrets) == 1 and unsaved_secrets[0].name == "session_token":
470+
if unsaved_secrets[0].value is not None:
471+
try:
472+
openbis_pat = await get_openbis_pat(
473+
storage.configuration["host"], unsaved_secrets[0].value
474+
)
475+
return (
476+
[models.DataConnectorSecretUpdate(name="pass", value=openbis_pat[0])],
477+
openbis_pat[1],
478+
)
479+
except Exception as e:
480+
raise errors.ProgrammingError(message=str(e)) from e
481+
raise errors.ValidationError(message="The openBIS session token must be a string value.")
482+
483+
raise errors.ValidationError(message="The openBIS storage has only one secret: session_token")
484+
485+
(
486+
unsaved_secrets,
487+
expiration_timestamp,
488+
) = await openbis_transform_session_token_to_pat()
489+
440490
secrets = await self.data_connector_secret_repo.patch_data_connector_secrets(
441-
user=user, data_connector_id=data_connector_id, secrets=unsaved_secrets
491+
user=user,
492+
data_connector_id=data_connector_id,
493+
secrets=unsaved_secrets,
494+
expiration_timestamp=expiration_timestamp,
442495
)
443496
return validated_json(
444-
apispec.DataConnectorSecretsList, [self._dump_data_connector_secret(secret) for secret in secrets]
497+
apispec.DataConnectorSecretsList,
498+
[self._dump_data_connector_secret(secret) for secret in self._adjust_secrets(secrets, storage)],
445499
)
446500

447501
return "/data_connectors/<data_connector_id:ulid>/secrets", ["PATCH"], _patch_secrets
@@ -508,6 +562,22 @@ def _dump_data_connector_to_project_link(link: models.DataConnectorToProjectLink
508562
created_by=link.created_by,
509563
)
510564

565+
@staticmethod
566+
def _adjust_secrets(
567+
secrets: list[models.DataConnectorSecret], storage: models.CloudStorageCore
568+
) -> list[models.DataConnectorSecret]:
569+
if storage.storage_type == "openbis":
570+
for i, secret in enumerate(secrets):
571+
if secret.name == "pass":
572+
secrets[i] = models.DataConnectorSecret(
573+
name="session_token",
574+
user_id=secret.user_id,
575+
data_connector_id=secret.data_connector_id,
576+
secret_id=secret.secret_id,
577+
)
578+
break
579+
return secrets
580+
511581
@staticmethod
512582
def _dump_data_connector_secret(secret: models.DataConnectorSecret) -> dict[str, Any]:
513583
"""Dumps a data connector secret for API responses."""

components/renku_data_services/data_connectors/db.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import string
55
from collections.abc import AsyncIterator, Callable, Sequence
66
from contextlib import suppress
7+
from datetime import datetime
78
from typing import TypeVar
89

910
from cryptography.hazmat.primitives.asymmetric import rsa
@@ -886,7 +887,11 @@ async def get_data_connector_secrets(
886887
return [secret.dump() for secret in secrets]
887888

888889
async def patch_data_connector_secrets(
889-
self, user: base_models.APIUser, data_connector_id: ULID, secrets: list[models.DataConnectorSecretUpdate]
890+
self,
891+
user: base_models.APIUser,
892+
data_connector_id: ULID,
893+
secrets: list[models.DataConnectorSecretUpdate],
894+
expiration_timestamp: datetime | None,
890895
) -> list[models.DataConnectorSecret]:
891896
"""Create, update or remove data connector secrets."""
892897
if user.id is None:
@@ -935,7 +940,9 @@ async def patch_data_connector_secrets(
935940

936941
if data_connector_secret_orm := existing_secrets_as_dict.get(name):
937942
data_connector_secret_orm.secret.update(
938-
encrypted_value=encrypted_value, encrypted_key=encrypted_key
943+
encrypted_value=encrypted_value,
944+
encrypted_key=encrypted_key,
945+
expiration_timestamp=expiration_timestamp,
939946
)
940947
else:
941948
secret_name = f"{data_connector.name[:45]} - {name[:45]}"
@@ -949,6 +956,7 @@ async def patch_data_connector_secrets(
949956
encrypted_value=encrypted_value,
950957
encrypted_key=encrypted_key,
951958
kind=SecretKind.storage,
959+
expiration_timestamp=expiration_timestamp,
952960
)
953961
data_connector_secret_orm = schemas.DataConnectorSecretORM(
954962
name=name,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add secret expiration timestamp
2+
3+
Revision ID: aa8f1a39a3bc
4+
Revises: d437be68a4fb
5+
Create Date: 2025-10-21 13:12:56.882429
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "aa8f1a39a3bc"
14+
down_revision = "d437be68a4fb"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column(
22+
"secrets", sa.Column("expiration_timestamp", sa.DateTime(timezone=True), nullable=True), schema="secrets"
23+
)
24+
# ### end Alembic commands ###
25+
26+
27+
def downgrade() -> None:
28+
# ### commands auto generated by Alembic - please adjust! ###
29+
op.drop_column("secrets", "expiration_timestamp", schema="secrets")
30+
# ### end Alembic commands ###

components/renku_data_services/notebooks/api/schemas/cloud_storage.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,8 @@ def config_string(self, name: str) -> str:
227227
"""
228228
if not self.configuration:
229229
raise ValidationError("Missing configuration for cloud storage")
230-
231-
# Transform configuration for polybox or switchDrive
230+
# TODO Use RCloneValidator.get_real_configuration(...) instead.
231+
# Transform configuration for polybox, switchDrive or openBIS
232232
storage_type = self.configuration.get("type", "")
233233
access = self.configuration.get("provider", "")
234234

@@ -239,6 +239,15 @@ def config_string(self, name: str) -> str:
239239
# time for touched files to be temporarily set to `1999-09-04` which causes the text
240240
# editor to complain that the file has changed and whether it should overwrite new changes.
241241
self.configuration["vendor"] = "owncloud"
242+
elif storage_type == "s3" and access == "Switch":
243+
# Switch is a fake provider we add for users, we need to replace it since rclone itself
244+
# doesn't know it
245+
self.configuration["provider"] = "Other"
246+
elif storage_type == "openbis":
247+
self.configuration["type"] = "sftp"
248+
self.configuration["port"] = "2222"
249+
self.configuration["user"] = "?"
250+
self.configuration["pass"] = self.configuration.pop("session_token", self.configuration["pass"])
242251

243252
if access == "shared" and storage_type == "polybox":
244253
self.configuration["url"] = "https://polybox.ethz.ch/public.php/webdav/"
@@ -255,10 +264,6 @@ def config_string(self, name: str) -> str:
255264
user_identifier = public_link.split("/")[-1]
256265
self.configuration["user"] = user_identifier
257266

258-
if self.configuration["type"] == "s3" and self.configuration.get("provider", None) == "Switch":
259-
# Switch is a fake provider we add for users, we need to replace it since rclone itself
260-
# doesn't know it
261-
self.configuration["provider"] = "Other"
262267
parser = ConfigParser()
263268
parser.add_section(name)
264269

components/renku_data_services/secrets/db.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
import random
77
import string
88
from collections.abc import AsyncGenerator, Callable, Sequence
9-
from datetime import UTC, datetime
9+
from datetime import UTC, datetime, timedelta
1010
from typing import cast
1111

1212
from cryptography.hazmat.primitives.asymmetric import rsa
1313
from prometheus_client import Counter, Enum
14-
from sqlalchemy import delete, select
14+
from sqlalchemy import Select, delete, or_, select
1515
from sqlalchemy.exc import IntegrityError
1616
from sqlalchemy.ext.asyncio import AsyncSession
1717
from ulid import ULID
@@ -147,11 +147,23 @@ def __init__(
147147
self.user_repo = user_repo
148148
self.secret_service_public_key = secret_service_public_key
149149

150+
def _get_stmt(self, requested_by: APIUser) -> Select[tuple[SecretORM]]:
151+
return (
152+
select(SecretORM)
153+
.where(SecretORM.user_id == requested_by.id)
154+
.where(
155+
or_(
156+
SecretORM.expiration_timestamp.is_(None),
157+
SecretORM.expiration_timestamp > datetime.now(UTC) + timedelta(seconds=120),
158+
)
159+
)
160+
)
161+
150162
@only_authenticated
151163
async def get_user_secrets(self, requested_by: APIUser, kind: SecretKind) -> list[Secret]:
152164
"""Get all user's secrets from the database."""
153165
async with self.session_maker() as session:
154-
stmt = select(SecretORM).where(SecretORM.user_id == requested_by.id).where(SecretORM.kind == kind)
166+
stmt = self._get_stmt(requested_by).where(SecretORM.kind == kind)
155167
res = await session.execute(stmt)
156168
orm = res.scalars().all()
157169
return [o.dump() for o in orm]
@@ -160,7 +172,7 @@ async def get_user_secrets(self, requested_by: APIUser, kind: SecretKind) -> lis
160172
async def get_secret_by_id(self, requested_by: APIUser, secret_id: ULID) -> Secret:
161173
"""Get a specific user secret from the database."""
162174
async with self.session_maker() as session:
163-
stmt = select(SecretORM).where(SecretORM.user_id == requested_by.id).where(SecretORM.id == secret_id)
175+
stmt = self._get_stmt(requested_by).where(SecretORM.id == secret_id)
164176
res = await session.execute(stmt)
165177
orm = res.scalar_one_or_none()
166178
if not orm:
@@ -187,11 +199,12 @@ async def insert_secret(self, requested_by: APIUser, secret: UnsavedSecret) -> S
187199
async with self.session_maker() as session, session.begin():
188200
secret_orm = SecretORM(
189201
name=secret.name,
190-
default_filename=default_filename,
191202
user_id=requested_by.id,
192203
encrypted_value=encrypted_value,
193204
encrypted_key=encrypted_key,
194205
kind=secret.kind,
206+
expiration_timestamp=secret.expiration_timestamp,
207+
default_filename=default_filename,
195208
)
196209
session.add(secret_orm)
197210

@@ -212,9 +225,7 @@ async def update_secret(self, requested_by: APIUser, secret_id: ULID, patch: Sec
212225
"""Update a secret."""
213226

214227
async with self.session_maker() as session, session.begin():
215-
result = await session.execute(
216-
select(SecretORM).where(SecretORM.id == secret_id).where(SecretORM.user_id == requested_by.id)
217-
)
228+
result = await session.execute(self._get_stmt(requested_by).where(SecretORM.id == secret_id))
218229
secret = result.scalar_one_or_none()
219230
if secret is None:
220231
raise errors.MissingResourceError(message=f"The secret with id '{secret_id}' cannot be found")
@@ -239,6 +250,8 @@ async def update_secret(self, requested_by: APIUser, secret_id: ULID, patch: Sec
239250
secret_value=patch.secret_value,
240251
)
241252
secret.update(encrypted_value=encrypted_value, encrypted_key=encrypted_key)
253+
if patch.expiration_timestamp is not None:
254+
secret.expiration_timestamp = patch.expiration_timestamp
242255

243256
return secret.dump()
244257

@@ -247,9 +260,7 @@ async def delete_secret(self, requested_by: APIUser, secret_id: ULID) -> None:
247260
"""Delete a secret."""
248261

249262
async with self.session_maker() as session, session.begin():
250-
result = await session.execute(
251-
select(SecretORM).where(SecretORM.id == secret_id).where(SecretORM.user_id == requested_by.id)
252-
)
263+
result = await session.execute(self._get_stmt(requested_by).where(SecretORM.id == secret_id))
253264
secret = result.scalar_one_or_none()
254265
if secret is None:
255266
return None

components/renku_data_services/secrets/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from cryptography.hazmat.primitives.asymmetric import rsa
1010
from kubernetes import client as k8s_client
11+
from pydantic import Field
1112
from ulid import ULID
1213

1314
from renku_data_services.app_config import logging
@@ -39,6 +40,7 @@ class Secret:
3940
encrypted_value: bytes = field(repr=False)
4041
encrypted_key: bytes = field(repr=False)
4142
kind: SecretKind
43+
expiration_timestamp: datetime | None = Field(default=None)
4244
modification_date: datetime
4345

4446
session_secret_slot_ids: list[ULID]
@@ -115,15 +117,17 @@ class UnsavedSecret:
115117
"""Model to request the creation of a new user secret."""
116118

117119
name: str
118-
default_filename: str | None
119120
secret_value: str = field(repr=False)
120121
kind: SecretKind
122+
expiration_timestamp: datetime | None = None
123+
default_filename: str | None
121124

122125

123126
@dataclass(frozen=True, eq=True, kw_only=True)
124127
class SecretPatch:
125128
"""Model for changes requested on a user secret."""
126129

127130
name: str | None
128-
default_filename: str | None
129131
secret_value: str | None = field(repr=False)
132+
expiration_timestamp: datetime | None = None
133+
default_filename: str | None

0 commit comments

Comments
 (0)