Skip to content

Commit

Permalink
Merge pull request #1584 from DSD-DBS/session-sharing
Browse files Browse the repository at this point in the history
feat: Share sessions with other users
  • Loading branch information
MoritzWeber0 authored Jun 11, 2024
2 parents 91ca9f5 + c2c3aef commit 640790f
Show file tree
Hide file tree
Showing 67 changed files with 1,982 additions and 230 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Add session sharing
Revision ID: 49f51db92903
Revises: aa88e6d1333b
Create Date: 2024-05-29 14:25:34.801756
"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "49f51db92903"
down_revision = "aa88e6d1333b"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"shared_sessions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("session_id", sa.String(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["session_id"],
["sessions.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id", "session_id", "user_id"),
)
op.create_index(
op.f("ix_shared_sessions_id"), "shared_sessions", ["id"], unique=True
)
8 changes: 7 additions & 1 deletion backend/capellacollab/core/database/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,13 @@ def get_eclipse_session_configuration() -> (
else "{CAPELLACOLLAB_ORIGIN_BASE_URL}"
),
},
redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/",
redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/?floating_menu=0&sharing=1&path={CAPELLACOLLAB_SESSIONS_BASE_PATH}/",
cookies={
"token": "{CAPELLACOLLAB_SESSION_TOKEN}",
},
sharing=tools_models.ToolSessionSharingConfiguration(
enabled=True
),
),
]
),
Expand Down Expand Up @@ -287,6 +290,9 @@ def create_jupyter_tool(db: orm.Session) -> tools_models.DatabaseTool:
description="The only available connection method for Jupyter.",
ports=tools_models.HTTPPorts(http=8888, metrics=9118),
redirect_url="{CAPELLACOLLAB_SESSIONS_SCHEME}://{CAPELLACOLLAB_SESSIONS_HOST}:{CAPELLACOLLAB_SESSIONS_PORT}{CAPELLACOLLAB_SESSIONS_BASE_PATH}/lab?token={CAPELLACOLLAB_SESSION_TOKEN}",
sharing=tools_models.ToolSessionSharingConfiguration(
enabled=True
),
),
]
),
Expand Down
24 changes: 24 additions & 0 deletions backend/capellacollab/sessions/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,23 @@
import sqlalchemy as sa
from sqlalchemy import orm

from capellacollab.users import models as users_models

from . import models


def get_sessions(db: orm.Session) -> abc.Sequence[models.DatabaseSession]:
return db.execute(sa.select(models.DatabaseSession)).scalars().all()


def create_shared_session(
db: orm.Session, shared_session: models.DatabaseSharedSession
) -> models.DatabaseSharedSession:
db.add(shared_session)
db.commit()
return shared_session


def get_sessions_for_user(
db: orm.Session, username: str
) -> abc.Sequence[models.DatabaseSession]:
Expand All @@ -28,6 +38,20 @@ def get_sessions_for_user(
)


def get_shared_sessions_for_user(
db: orm.Session, user: users_models.DatabaseUser
) -> abc.Sequence[models.DatabaseSession]:
return (
db.execute(
sa.select(models.DatabaseSession)
.join(models.DatabaseSharedSession)
.where(models.DatabaseSharedSession.user == user)
)
.scalars()
.all()
)


def get_session_by_id(
db: orm.Session, session_id: str
) -> models.DatabaseSession | None:
Expand Down
24 changes: 23 additions & 1 deletion backend/capellacollab/sessions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ def __init__(self, session_id: str):
)


class SessionAlreadySharedError(core_exceptions.BaseError):
def __init__(self, username: str):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
title="Session already shared with user",
reason=(f"The session is already shared with user '{username}'. "),
err_code="SESSION_ALREADY_SHARED",
)


class SessionForbiddenError(core_exceptions.BaseError):
def __init__(self):
super().__init__(
Expand Down Expand Up @@ -59,6 +69,18 @@ def __init__(
)


class SessionSharingNotSupportedError(core_exceptions.BaseError):
def __init__(self, tool_name: str, connection_method_name: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
title="Session sharing not supported",
reason=(
f"The connection method '{connection_method_name}' of tool '{tool_name}' doesn't support session sharing.'."
),
err_code="SESSION_SHARING_UNSUPPORTED",
)


class ConflictingSessionError(core_exceptions.BaseError):
def __init__(
self,
Expand Down Expand Up @@ -109,7 +131,7 @@ def __init__(
)


class WorkspaceMountingNotAllowed(core_exceptions.BaseError):
class WorkspaceMountingNotAllowedError(core_exceptions.BaseError):
def __init__(
self,
tool_name: str,
Expand Down
4 changes: 3 additions & 1 deletion backend/capellacollab/sessions/hooks/persistent_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ def _check_that_persistent_workspace_is_allowed(
self, tool: tools_models.DatabaseTool
):
if not tool.config.persistent_workspaces.mounting_enabled:
raise sessions_exceptions.WorkspaceMountingNotAllowed(tool.name)
raise sessions_exceptions.WorkspaceMountingNotAllowedError(
tool.name
)

def _get_volume_name(self, username: str) -> str:
return "persistent-session-" + self._normalize_username(username)
Expand Down
15 changes: 8 additions & 7 deletions backend/capellacollab/sessions/hooks/t4c.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ def configuration_hook( # type: ignore
db: orm.Session,
user: users_models.DatabaseUser,
tool_version: tools_models.DatabaseVersion,
username: str,
session_type: sessions_models.SessionType,
**kwargs,
) -> interface.ConfigurationHookResult:
Expand All @@ -49,11 +48,8 @@ def configuration_hook( # type: ignore

warnings: list[core_models.Message] = []

# When using a different tool with TeamForCapella support (e.g., Capella + pure::variants),
# the version ID doesn't match the version from the T4C integration.
# We have to find the matching Capella version by name.
t4c_repositories = repo_crud.get_user_t4c_repositories(
db, tool_version.name, user
db, tool_version, user
)

t4c_json = json.dumps(
Expand Down Expand Up @@ -93,7 +89,7 @@ def configuration_hook( # type: ignore
password=environment["T4C_PASSWORD"],
is_admin=auth_injectables.RoleVerification(
required_role=users_models.Role.ADMIN, verify=False
)(username, db),
)(user.name, db),
)
except requests.RequestException:
warnings.append(
Expand Down Expand Up @@ -130,11 +126,16 @@ def pre_session_termination_hook( # type: ignore
def session_connection_hook( # type: ignore[override]
self,
db_session: sessions_models.DatabaseSession,
user: users_models.DatabaseUser,
**kwargs,
) -> interface.SessionConnectionHookResult:
if db_session.type != sessions_models.SessionType.PERSISTENT:
return interface.SessionConnectionHookResult()

if db_session.owner != user:
# The session is shared, don't provide the T4C token.
return interface.SessionConnectionHookResult()

return interface.SessionConnectionHookResult(
t4c_token=db_session.environment.get("T4C_PASSWORD")
)
Expand All @@ -145,7 +146,7 @@ def _revoke_session_tokens(
session: sessions_models.DatabaseSession,
):
for repository in repo_crud.get_user_t4c_repositories(
db, session.version.name, session.owner
db, session.version, session.owner
):
try:
repo_interface.remove_user_from_repository(
Expand Down
34 changes: 30 additions & 4 deletions backend/capellacollab/sessions/injectables.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,49 @@

from capellacollab.core import database
from capellacollab.core.authentication import injectables as auth_injectables
from capellacollab.users import injectables as users_injectables
from capellacollab.users import models as users_models

from . import crud, exceptions, models
from . import crud, exceptions, models, util


def get_existing_session(
session_id: str,
db: orm.Session = fastapi.Depends(database.get_db),
username: str = fastapi.Depends(auth_injectables.get_username),
user: users_models.DatabaseUser = fastapi.Depends(
users_injectables.get_own_user
),
) -> models.DatabaseSession:
"""Get a session by its ID, ensuring that the user is the owner or an admin."""

if not (session := crud.get_session_by_id(db, session_id)):
raise exceptions.SessionNotFoundError(session_id)
if not (
session.owner_name == user.name
or auth_injectables.RoleVerification(
required_role=users_models.Role.ADMIN, verify=False
)(user.name, db)
):
raise exceptions.SessionNotOwnedError(session_id)

return session


def get_existing_session_including_shared(
session_id: str,
db: orm.Session = fastapi.Depends(database.get_db),
user: users_models.DatabaseUser = fastapi.Depends(
users_injectables.get_own_user
),
) -> models.DatabaseSession:
if not (session := crud.get_session_by_id(db, session_id)):
raise exceptions.SessionNotFoundError(session_id)
if not (
session.owner_name == username
session.owner_name == user.name
or util.is_session_shared_with_user(session, user)
or auth_injectables.RoleVerification(
required_role=users_models.Role.ADMIN, verify=False
)(username, db)
)(user.name, db)
):
raise exceptions.SessionNotOwnedError(session_id)

Expand Down
44 changes: 44 additions & 0 deletions backend/capellacollab/sessions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class PostSessionRequest(core_pydantic.BaseModel):
provisioning: list[SessionProvisioningRequest] = pydantic.Field(default=[])


class SessionSharing(core_pydantic.BaseModel):
user: users_models.BaseUser
created_at: datetime.datetime


class Session(core_pydantic.BaseModel):
id: str
type: SessionType
Expand All @@ -79,6 +84,8 @@ class Session(core_pydantic.BaseModel):
connection_method_id: str
connection_method: tools_models.ToolSessionConnectionMethod | None = None

shared_with: list[SessionSharing] = pydantic.Field(default=[])

_validate_created_at = pydantic.field_serializer("created_at")(
core_pydantic.datetime_serializer
)
Expand All @@ -103,6 +110,10 @@ def add_warnings_and_last_seen(self) -> t.Any:
return self


class ShareSessionRequest(core_pydantic.BaseModel):
username: str


class SessionConnectionInformation(core_pydantic.BaseModel):
"""Information about the connection to the session."""

Expand Down Expand Up @@ -161,3 +172,36 @@ class DatabaseSession(database.Base):
config: orm.Mapped[dict[str, str]] = orm.mapped_column(
nullable=False, default_factory=dict
)

shared_with: orm.Mapped[list[DatabaseSharedSession]] = orm.relationship(
"DatabaseSharedSession",
back_populates="session",
init=False,
cascade="all, delete-orphan",
)


class DatabaseSharedSession(database.Base):
__tablename__ = "shared_sessions"

id: orm.Mapped[int] = orm.mapped_column(
sa.Integer,
init=False,
primary_key=True,
index=True,
unique=True,
autoincrement=True,
)
created_at: orm.Mapped[datetime.datetime]

session_id: orm.Mapped[str] = orm.mapped_column(
sa.ForeignKey("sessions.id"), primary_key=True, init=False
)
session: orm.Mapped[DatabaseSession] = orm.relationship(
back_populates="shared_with"
)

user_id: orm.Mapped[str] = orm.mapped_column(
sa.ForeignKey("users.id"), primary_key=True, init=False
)
user: orm.Mapped[DatabaseUser] = orm.relationship()
Loading

0 comments on commit 640790f

Please sign in to comment.