Skip to content

Commit

Permalink
feat: Issue session token for usage with API
Browse files Browse the repository at this point in the history
Some sessions may want to automate tasks against the CCM API.
A session token is issued as PAT and is valid for the duration of the session.
  • Loading branch information
MoritzWeber0 committed Feb 4, 2025
1 parent 028a25a commit bd122dd
Show file tree
Hide file tree
Showing 22 changed files with 318 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Add title to PAT
Revision ID: 8731ac0b284e
Revises: 7b7145600133
Create Date: 2025-02-04 16:01:41.279644
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "8731ac0b284e"
down_revision = "7b7145600133"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"basic_auth_token",
sa.Column(
"title",
sa.String(),
nullable=False,
server_default=sa.text("'Legacy Token'"),
),
)
2 changes: 2 additions & 0 deletions backend/capellacollab/sessions/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
pure_variants,
read_only_workspace,
session_preparation,
session_token,
t4c,
)

Expand All @@ -28,6 +29,7 @@

REGISTER_HOOKS_AUTO_USE: list[interface.HookRegistration] = [
persistent_workspace.PersistentWorkspaceHook(),
session_token.SessionTokenIntegration(),
guacamole.GuacamoleIntegration(),
http.HTTPIntegration(),
read_only_workspace.ReadOnlyWorkspaceHook(),
Expand Down
1 change: 1 addition & 0 deletions backend/capellacollab/sessions/hooks/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class ConfigurationHookResult(t.TypedDict):
warnings: t.NotRequired[list[core_models.Message]]
init_volumes: t.NotRequired[list[operators_models.Volume]]
init_environment: t.NotRequired[t.Mapping]
config: t.NotRequired[t.Mapping]


@dataclasses.dataclass()
Expand Down
60 changes: 60 additions & 0 deletions backend/capellacollab/sessions/hooks/session_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0


import datetime

from capellacollab.permissions import models as permissions_models
from capellacollab.users.tokens import crud as tokens_crud

from . import interface


class SessionTokenIntegration(interface.HookRegistration):
"""Create a PAT valid for the duration of the session."""

def configuration_hook(
self,
request: interface.ConfigurationHookRequest,
) -> interface.ConfigurationHookResult:
token, password = tokens_crud.create_token(
db=request.db,
scope=permissions_models.GlobalScopes(
user=permissions_models.UserScopes(
sessions={permissions_models.UserTokenVerb.GET}
)
),
title=f"Session {request.session_id}",
user=request.user,
description=(
"This PAT is managed by the Collaboration Manager."
" It will be revoked when the session is terminated"
" and is only valid for the duration of the session."
" Manual deletion may lead to unexpected behavior."
),
expiration_date=datetime.date.today()
+ datetime.timedelta(
days=2
), # Maximum duration is until end of the next day.
source="session automation",
)

return interface.ConfigurationHookResult(
environment={"CAPELLACOLLAB_SESSION_API_TOKEN": password},
config={"session_token_id": token.id},
)

def pre_session_termination_hook(
self,
request: interface.PreSessionTerminationHookRequest,
) -> interface.PreSessionTerminationHookResult:
token_id = request.session.config.get("session_token_id")
if not token_id:
return interface.PreSessionTerminationHookResult()

Check warning on line 53 in backend/capellacollab/sessions/hooks/session_token.py

View check run for this annotation

Codecov / codecov/patch

backend/capellacollab/sessions/hooks/session_token.py#L53

Added line #L53 was not covered by tests

if token := tokens_crud.get_token_by_user_and_id(
request.db, request.session.owner.id, int(token_id)
):
tokens_crud.delete_token(request.db, token)

return interface.PreSessionTerminationHookResult()
3 changes: 2 additions & 1 deletion backend/capellacollab/sessions/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ async def request_session(
warnings: list[core_models.Message] = []
init_volumes: list[operators_models.Volume] = []
init_environment: dict[str, str] = {}
hook_config: dict[str, str] = {}

hook_request = hooks_interface.ConfigurationHookRequest(
db=db,
Expand All @@ -166,6 +167,7 @@ async def request_session(
volumes += hook_result.get("volumes", [])
init_volumes += hook_result.get("init_volumes", [])
warnings += hook_result.get("warnings", [])
hook_config |= hook_result.get("config", {})

local_env, local_warnings = util.resolve_environment_variables(
logger,
Expand Down Expand Up @@ -247,7 +249,6 @@ async def request_session(
),
)

hook_config: dict[str, str] = {}
for hook in sessions_hooks.get_activated_integration_hooks(tool):
result = hook.post_session_creation_hook(
hooks_interface.PostSessionCreationHookRequest(
Expand Down
2 changes: 2 additions & 0 deletions backend/capellacollab/users/tokens/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def create_token(
db: orm.Session,
user: users_models.DatabaseUser,
scope: permissions_models.GlobalScopes,
title: str,
description: str,
expiration_date: datetime.date | None,
source: str,
Expand All @@ -29,6 +30,7 @@ def create_token(
expiration_date = datetime.date.today() + datetime.timedelta(days=30)
db_token = models.DatabaseUserToken(
user=user,
title=title,
hash=ph.hash(password),
created_at=datetime.datetime.now(datetime.UTC),
expiration_date=expiration_date,
Expand Down
3 changes: 3 additions & 0 deletions backend/capellacollab/users/tokens/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class UserToken(core_pydantic.BaseModel):
description="The actual scope of the token. It might be less than the requested scope."
)
created_at: datetime.datetime | None
title: str
description: str
source: str

Expand All @@ -62,6 +63,7 @@ class UserTokenWithPassword(UserToken):

class PostToken(core_pydantic.BaseModel):
expiration_date: datetime.date
title: str
description: str
source: str
scopes: FineGrainedResource
Expand All @@ -82,6 +84,7 @@ class DatabaseUserToken(database.Base):
hash: orm.Mapped[str]
expiration_date: orm.Mapped[datetime.date]
created_at: orm.Mapped[datetime.datetime | None]
title: orm.Mapped[str]
description: orm.Mapped[str]
source: orm.Mapped[str]

Expand Down
3 changes: 3 additions & 0 deletions backend/capellacollab/users/tokens/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def create_token_for_user(
scope=permissions_models.GlobalScopes.model_validate(
post_token.scopes
),
title=post_token.title,
description=post_token.description,
expiration_date=post_token.expiration_date,
source=post_token.source,
Expand All @@ -68,6 +69,7 @@ def create_token_for_user(
return models.UserTokenWithPassword(
id=token.id,
user_id=token.user_id,
title=token.title,
created_at=token.created_at,
expiration_date=token.expiration_date,
requested_scopes=util.get_database_token_scopes(token),
Expand Down Expand Up @@ -102,6 +104,7 @@ def get_all_tokens_of_user(
models.UserToken(
id=token.id,
user_id=token.user_id,
title=token.title,
created_at=token.created_at,
expiration_date=token.expiration_date,
requested_scopes=util.get_database_token_scopes(token),
Expand Down
53 changes: 53 additions & 0 deletions backend/tests/sessions/hooks/test_session_token_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

from sqlalchemy import orm

from capellacollab.sessions.hooks import interface as sessions_hooks_interface
from capellacollab.sessions.hooks import session_token
from capellacollab.users import models as users_models
from capellacollab.users.tokens import crud as tokens_crud


def test_session_token_hook_lifecycle(
user: users_models.DatabaseUser,
configuration_hook_request: sessions_hooks_interface.ConfigurationHookRequest,
pre_session_termination_hook_request: sessions_hooks_interface.PreSessionTerminationHookRequest,
db: orm.Session,
):
result = session_token.SessionTokenIntegration().configuration_hook(
configuration_hook_request
)

session_token_id = result["config"]["session_token_id"]
assert isinstance(session_token_id, int)
assert result["config"]["session_token_id"]
assert result["environment"]["CAPELLACOLLAB_SESSION_API_TOKEN"]

assert tokens_crud.get_token_by_user_and_id(db, user.id, session_token_id)

pre_session_termination_hook_request.session.config["session_token_id"] = (
str(session_token_id)
)

session_token.SessionTokenIntegration().pre_session_termination_hook(
pre_session_termination_hook_request
)

assert not tokens_crud.get_token_by_user_and_id(
db, user.id, session_token_id
)


def test_termination_with_revoked_token(
pre_session_termination_hook_request: sessions_hooks_interface.PreSessionTerminationHookRequest,
):
"""Test that a session can be terminated if the PAT was already revoked"""

pre_session_termination_hook_request.session.config["session_token_id"] = (
"1"
)

session_token.SessionTokenIntegration().pre_session_termination_hook(
pre_session_termination_hook_request
)
3 changes: 2 additions & 1 deletion backend/tests/users/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ def fixture_pat(
db,
user,
scope=global_scope,
description="test",
title="test",
description="",
expiration_date=None,
source="test",
)
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/users/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

POST_TOKEN = {
"expiration_date": str(datetime.date.today()),
"title": "test",
"description": "test_token",
"source": "test source",
"scopes": {},
Expand All @@ -40,6 +41,7 @@ def test_create_pat_with_scope(client: testclient.TestClient):
"/api/v1/users/current/tokens",
json={
"expiration_date": str(datetime.date.today()),
"title": "test",
"description": "test_token",
"source": "test source",
"scopes": {
Expand Down
31 changes: 31 additions & 0 deletions docs/docs/admin/tools/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ variables can be used by the tool:
Token, which the tool has to use to authenticate the user.
</td>
</tr>
<tr>
<td>`CAPELLACOLLAB_SESSION_API_TOKEN`</td>
<td>`collabmanager_0KCYg7Rt3AzurbZgvTO74GEQzIoOg1DK`</td>
<td>
Token which can be used against the Collaboration Manager API.
More information [here](#internal-communication).
</td>
</tr>
<tr>
<td>`CAPELLACOLLAB_SESSION_ID`</td>
<td>`tctszsirxuoohabwnhyhmzjdh`</td>
Expand Down Expand Up @@ -332,6 +340,29 @@ the tool itself to handle the authentication. Instead, the Collaboration
Manager automatically authenticates all users via pre-authentication with
session tokens.

### Internal communication

Sessions can communicate with the Collaboration Manager API. In addition,
sessions of the same user can communicate via inter-session communication.

Here is an example of how to use the API to identify another session and send a
request to it. You can run the code from a Jupyter session.

```py
import os
import requests
backend_url = os.getenv("CAPELLACOLLAB_API_BASE_URL")
user_id = os.getenv("CAPELLACOLLAB_SESSION_REQUESTER_USER_ID")
username = os.getenv("CAPELLACOLLAB_SESSION_REQUESTER_USERNAME")
pat = os.getenv("CAPELLACOLLAB_SESSION_API_TOKEN")
response = requests.get(f"{backend_url}/v1/users/{user_id}/sessions", auth=(username, pat))
internal_endpoint = response.json()[0]["internal_endpoint"]
requests.get(f"http://{internal_endpoint}").content
```

## Configuration examples

To help you configure your tools, we provide some examples as part of our
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ To authenticate against the API you can use Personal Access Tokens (PAT).
### PAT Creation

To create a personal access token (PAT) you can go to `Menu` > `Tokens`. Every
token requires a description and expiration date. In addition, the token only
has access to the provided scope. In the API documentation you'll find the
required permissions for each route.
token requires a title, description, and expiration date. In addition, the
token only has access to the provided scope. In the API documentation you'll
find the required permissions for each route.

!!! info

Expand Down
Loading

0 comments on commit bd122dd

Please sign in to comment.