Skip to content

Commit abbc20b

Browse files
committed
ENH: Create user session explicitly as primary auth
1 parent a28c137 commit abbc20b

File tree

16 files changed

+1010
-962
lines changed

16 files changed

+1010
-962
lines changed

src/fmu_settings_api/deps.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fmu.settings._init import init_user_fmu_directory
99

1010
from fmu_settings_api.config import settings
11-
from fmu_settings_api.session import Session, session_manager
11+
from fmu_settings_api.session import ProjectSession, Session, session_manager
1212

1313
api_token_header = APIKeyHeader(name=settings.TOKEN_HEADER_NAME)
1414

@@ -22,6 +22,9 @@ async def verify_auth_token(req_token: TokenHeaderDep) -> TokenHeaderDep:
2222
return req_token
2323

2424

25+
AuthTokenDep = Annotated[TokenHeaderDep, Depends(verify_auth_token)]
26+
27+
2528
async def ensure_user_fmu_directory() -> UserFMUDirectory:
2629
"""Ensures the user's FMU Directory exists.
2730
@@ -64,7 +67,7 @@ async def ensure_user_fmu_directory() -> UserFMUDirectory:
6467
UserFMUDirDep = Annotated[UserFMUDirectory, Depends(ensure_user_fmu_directory)]
6568

6669

67-
async def get_fmu_session(fmu_settings_session: str | None = Cookie(None)) -> Session:
70+
async def get_session(fmu_settings_session: str | None = Cookie(None)) -> Session:
6871
"""Gets a session from the session manager."""
6972
if not fmu_settings_session:
7073
raise HTTPException(
@@ -87,4 +90,20 @@ async def get_fmu_session(fmu_settings_session: str | None = Cookie(None)) -> Se
8790
raise HTTPException(status_code=500, detail=f"Session error: {e}") from e
8891

8992

90-
SessionDep = Annotated[Session, Depends(get_fmu_session)]
93+
SessionDep = Annotated[Session, Depends(get_session)]
94+
95+
96+
async def get_project_session(
97+
fmu_settings_session: str | None = Cookie(None),
98+
) -> ProjectSession:
99+
"""Gets a session with an FMU Project opened from the session manager."""
100+
session = await get_session(fmu_settings_session)
101+
if not isinstance(session, ProjectSession):
102+
raise HTTPException(
103+
status_code=401,
104+
detail="No FMU project directory open",
105+
)
106+
return session
107+
108+
109+
ProjectSessionDep = Annotated[ProjectSession, Depends(get_project_session)]
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Models used for messages and responses at API endpoints."""
22

3-
from .common import Message
4-
from .fmu import FMUDirPath, FMUProject
3+
from .common import APIKey, Message, SessionResponse
4+
from .project import FMUDirPath, FMUProject
55

6-
__all__ = ["FMUDirPath", "FMUProject", "Message"]
6+
__all__ = ["FMUDirPath", "FMUProject", "Message", "SessionResponse", "APIKey"]

src/fmu_settings_api/models/common.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
"""Common response models from the API."""
22

3+
from fmu.settings.models.user_config import UserConfig
34
from pydantic import BaseModel, SecretStr
45

6+
from .project import FMUProject
7+
8+
9+
class SessionResponse(BaseModel):
10+
"""Information returned when a session is initially created."""
11+
12+
user_config: UserConfig
13+
fmu_project: FMUProject | None = None
14+
515

616
class Message(BaseModel):
717
"""A generic message to return to the GUI."""
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@
33
from pathlib import Path
44

55
from fmu.settings.models.project_config import ProjectConfig
6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, Field
77

88

99
class FMUDirPath(BaseModel):
1010
"""Path where a .fmu directory may exist."""
1111

12-
path: Path
13-
"""Path to the directory which should or will contain a .fmu directory."""
12+
path: Path = Field(examples=["/path/to/project.2038.02.02"])
13+
"""Absolute path to the directory which maybe contains a .fmu directory."""
1414

1515

1616
class FMUProject(FMUDirPath):
1717
"""Information returned when 'opening' an FMU Directory."""
1818

19-
project_dir_name: str
19+
project_dir_name: str = Field(examples=["project.2038.02.02"])
20+
"""The directory name, not the path, that contains the .fmu directory."""
21+
2022
config: ProjectConfig
2123
"""The configuration of an FMU project's .fmu directory."""

src/fmu_settings_api/session.py

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Functionality for managing sessions."""
22

3-
from dataclasses import dataclass
3+
from dataclasses import asdict, dataclass
44
from datetime import UTC, datetime, timedelta
55
from typing import Self
66
from uuid import uuid4
@@ -11,18 +11,28 @@
1111
from fmu_settings_api.config import settings
1212

1313

14+
class SessionNotFoundError(ValueError):
15+
"""Raised when getting a session id that does not exist."""
16+
17+
1418
@dataclass
1519
class Session:
1620
"""Represents session information when working on an FMU Directory."""
1721

1822
id: str
19-
fmu_directory: ProjectFMUDirectory
2023
user_fmu_directory: UserFMUDirectory
2124
created_at: datetime
2225
expires_at: datetime
2326
last_accessed: datetime
2427

2528

29+
@dataclass
30+
class ProjectSession(Session):
31+
"""A session with an FMU project attached."""
32+
33+
project_fmu_directory: ProjectFMUDirectory
34+
35+
2636
class SessionManager:
2737
"""Manages sessions started when an FMU Directory as been opened.
2838
@@ -31,7 +41,7 @@ class SessionManager:
3141
it simply uses a dictionary backend.
3242
"""
3343

34-
Storage = dict[str, Session]
44+
Storage = dict[str, Session | ProjectSession]
3545
"""Type alias for the storage backend instance."""
3646

3747
storage: Storage
@@ -41,15 +51,21 @@ def __init__(self: Self) -> None:
4151
"""Initializes the session manager singleton."""
4252
self.storage = {}
4353

44-
async def _store_session(self: Self, session_id: str, session: Session) -> None:
54+
async def _store_session(
55+
self: Self, session_id: str, session: Session | ProjectSession
56+
) -> None:
4557
"""Stores a newly created session."""
4658
self.storage[session_id] = session
4759

48-
async def _retrieve_session(self: Self, session_id: str) -> Session | None:
60+
async def _retrieve_session(
61+
self: Self, session_id: str
62+
) -> Session | ProjectSession | None:
4963
"""Retrieves a session from the storage backend."""
5064
return self.storage.get(session_id, None)
5165

52-
async def _update_session(self: Self, session_id: str, session: Session) -> None:
66+
async def _update_session(
67+
self: Self, session_id: str, session: Session | ProjectSession
68+
) -> None:
5369
"""Stores an updated session back into the session backend."""
5470
self.storage[session_id] = session
5571

@@ -61,7 +77,6 @@ async def destroy_session(self: Self, session_id: str) -> None:
6177

6278
async def create_session(
6379
self: Self,
64-
fmu_directory: ProjectFMUDirectory,
6580
user_fmu_directory: UserFMUDirectory,
6681
expire_seconds: int = settings.SESSION_EXPIRE_SECONDS,
6782
) -> str:
@@ -72,7 +87,6 @@ async def create_session(
7287

7388
session = Session(
7489
id=session_id,
75-
fmu_directory=fmu_directory,
7690
user_fmu_directory=user_fmu_directory,
7791
created_at=now,
7892
expires_at=now + expiration_duration,
@@ -82,7 +96,9 @@ async def create_session(
8296

8397
return session_id
8498

85-
async def get_session(self: Self, session_id: str) -> Session | None:
99+
async def get_session(
100+
self: Self, session_id: str
101+
) -> Session | ProjectSession | None:
86102
"""Get the session data for a session id."""
87103
session = await self._retrieve_session(session_id)
88104
if not session:
@@ -102,14 +118,60 @@ async def get_session(self: Self, session_id: str) -> Session | None:
102118

103119

104120
async def create_fmu_session(
105-
fmu_directory: ProjectFMUDirectory,
106121
user_fmu_directory: UserFMUDirectory,
107122
expire_seconds: int = settings.SESSION_EXPIRE_SECONDS,
108123
) -> str:
109124
"""Creates a new session and stores it in the session mananger."""
110-
return await session_manager.create_session(
111-
fmu_directory, user_fmu_directory, expire_seconds
112-
)
125+
return await session_manager.create_session(user_fmu_directory, expire_seconds)
126+
127+
128+
async def add_fmu_project_to_session(
129+
session_id: str,
130+
project_fmu_directory: ProjectFMUDirectory,
131+
) -> ProjectSession:
132+
"""Adds an opened project FMU directory instance to the session.
133+
134+
Returns:
135+
The updated ProjectSession
136+
137+
Raises:
138+
SessionNotFoundError: If no session was found
139+
"""
140+
session = await session_manager.get_session(session_id)
141+
if not session:
142+
raise SessionNotFoundError(f"No session with id {session_id} found")
143+
144+
if isinstance(session, ProjectSession):
145+
project_session = session
146+
project_session.project_fmu_directory = project_fmu_directory
147+
else:
148+
project_session = ProjectSession(
149+
**asdict(session), project_fmu_directory=project_fmu_directory
150+
)
151+
await session_manager._store_session(session_id, project_session)
152+
return project_session
153+
154+
155+
async def remove_fmu_project_from_session(session_id: str) -> Session:
156+
"""Removes (closes) an open project FMU directory from a session.
157+
158+
Returns:
159+
The update session
160+
161+
Raises:
162+
SessionNotFoundError: If no session was found
163+
"""
164+
maybe_project_session = await session_manager.get_session(session_id)
165+
166+
if maybe_project_session is None:
167+
raise SessionNotFoundError(f"No session with id {session_id} found")
168+
if isinstance(maybe_project_session, Session):
169+
return maybe_project_session
170+
171+
project_session_dict = asdict(maybe_project_session)
172+
session = Session(**project_session_dict)
173+
await session_manager._store_session(session_id, session)
174+
return session
113175

114176

115177
async def destroy_fmu_session(session_id: str) -> None:

src/fmu_settings_api/v1/main.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,79 @@
11
"""The main router for /api/v1."""
22

3-
from fastapi import APIRouter, Depends
3+
import contextlib
4+
from pathlib import Path
5+
6+
from fastapi import APIRouter, Depends, HTTPException, Response
7+
from fmu.settings import find_nearest_fmu_directory
8+
from fmu.settings.models.user_config import UserConfig
49

510
from fmu_settings_api.config import settings
6-
from fmu_settings_api.deps import verify_auth_token
11+
from fmu_settings_api.deps import (
12+
AuthTokenDep,
13+
UserFMUDirDep,
14+
get_session,
15+
verify_auth_token,
16+
)
17+
from fmu_settings_api.models import FMUProject, SessionResponse
18+
from fmu_settings_api.session import (
19+
add_fmu_project_to_session,
20+
create_fmu_session,
21+
)
722

8-
from .routes import config, fmu, user
23+
from .routes import project, user
924

10-
api_v1_router = APIRouter(
11-
prefix=settings.API_V1_PREFIX,
12-
tags=["v1"],
13-
dependencies=[Depends(verify_auth_token)],
14-
)
15-
api_v1_router.include_router(fmu.router)
16-
api_v1_router.include_router(config.router)
17-
api_v1_router.include_router(user.router)
25+
api_v1_router = APIRouter(prefix=settings.API_V1_PREFIX, tags=["v1"])
26+
27+
api_v1_router.include_router(project.router, dependencies=[Depends(get_session)])
28+
api_v1_router.include_router(user.router, dependencies=[Depends(get_session)])
1829

1930

20-
@api_v1_router.get("/health")
31+
@api_v1_router.get("/health", dependencies=[Depends(get_session)])
2132
async def v1_health_check() -> dict[str, str]:
2233
"""Simple health check endpoint."""
2334
return {"status": "ok"}
35+
36+
37+
@api_v1_router.post(
38+
"/session",
39+
response_model=SessionResponse,
40+
dependencies=[Depends(verify_auth_token)],
41+
)
42+
async def create_session(
43+
response: Response, auth_token: AuthTokenDep, user_fmu_dir: UserFMUDirDep
44+
) -> SessionResponse:
45+
"""Establishes a user session."""
46+
try:
47+
session_id = await create_fmu_session(user_fmu_dir)
48+
response.set_cookie(
49+
key=settings.SESSION_COOKIE_KEY,
50+
value=session_id,
51+
httponly=True,
52+
secure=True,
53+
samesite="lax",
54+
)
55+
config_dict = user_fmu_dir.config.load().model_dump()
56+
57+
# Overwrite secret keys with obfuscated keys
58+
for k, v in config_dict["user_api_keys"].items():
59+
if v is not None:
60+
# Convert SecretStr("*********") to "*********"
61+
config_dict["user_api_keys"][k] = str(v)
62+
63+
user_config = UserConfig.model_validate(config_dict)
64+
65+
session_response = SessionResponse(user_config=user_config)
66+
67+
with contextlib.suppress(FileNotFoundError):
68+
path = Path.cwd()
69+
project_fmu_dir = find_nearest_fmu_directory(path)
70+
_ = await add_fmu_project_to_session(session_id, project_fmu_dir)
71+
session_response.fmu_project = FMUProject(
72+
path=project_fmu_dir.base_path,
73+
project_dir_name=project_fmu_dir.base_path.name,
74+
config=project_fmu_dir.config.load(),
75+
)
76+
77+
return session_response
78+
except Exception as e:
79+
raise HTTPException(status_code=500, detail=str(e)) from e

src/fmu_settings_api/v1/routes/config.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

0 commit comments

Comments
 (0)