dirigent-server 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ """Local accounts: the listing, creation, edits, activation, password resets, and their tokens."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Response, status
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from dirigent_client.schemas import (
7
+ IssuedTokenOut,
8
+ Page,
9
+ PasswordResetRequest,
10
+ TokenOut,
11
+ TokenRequest,
12
+ UserIn,
13
+ UserOut,
14
+ UserUpdate,
15
+ )
16
+ from dirigent_core.auth import (
17
+ DuplicateEmail,
18
+ DuplicateUser,
19
+ LastAdmin,
20
+ WeakPassword,
21
+ activate_user,
22
+ create_user,
23
+ deactivate_user,
24
+ find_user,
25
+ issue_token,
26
+ list_tokens,
27
+ list_users,
28
+ reset_password,
29
+ revoke_token,
30
+ set_email,
31
+ set_role,
32
+ )
33
+ from dirigent_core.models import User
34
+ from dirigent_server.dependencies import SessionDep
35
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, uuid_cursor
36
+ from dirigent_server.security import AdminDep
37
+ from dirigent_server.transactions import Transactional
38
+
39
+ router = APIRouter(route_class=Transactional, tags=["users"])
40
+
41
+
42
+ def render(row: User) -> UserOut:
43
+ """Render an account row for the API."""
44
+ return UserOut.model_validate(row, from_attributes=True)
45
+
46
+
47
+ async def find(session: AsyncSession, username: str) -> User:
48
+ """Read one account by name, or say this instance has no such account."""
49
+ row = await find_user(session, username)
50
+ if row is None:
51
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no user named {username!r}")
52
+ return row
53
+
54
+
55
+ @router.get("/users", operation_id="listUsers", summary="List accounts", response_model=Page[UserOut])
56
+ async def list_accounts(
57
+ session: SessionDep,
58
+ principal: AdminDep,
59
+ after: AfterParam = None,
60
+ limit: LimitParam = DEFAULT_PAGE,
61
+ ) -> Page[UserOut]:
62
+ """List every account."""
63
+ found = [render(row) for row in await list_users(session, after=after, limit=limit + 1)]
64
+ items, following = clip(found, limit, lambda row: row.username)
65
+ return Page(items=items, next=following)
66
+
67
+
68
+ @router.post(
69
+ "/users",
70
+ operation_id="createUser",
71
+ summary="Create an account",
72
+ response_model=UserOut,
73
+ status_code=status.HTTP_201_CREATED,
74
+ )
75
+ async def create_account(payload: UserIn, session: SessionDep, principal: AdminDep) -> UserOut:
76
+ """Create an account with an Argon2id password hash."""
77
+ try:
78
+ user = await create_user(
79
+ session,
80
+ payload.username,
81
+ payload.password.get_secret_value(),
82
+ role=payload.role,
83
+ name=payload.name,
84
+ email=payload.email,
85
+ )
86
+ except (DuplicateEmail, DuplicateUser) as error:
87
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
88
+ except WeakPassword as error:
89
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
90
+ return render(user)
91
+
92
+
93
+ @router.patch("/users/{username}", operation_id="updateUser", summary="Update an account", response_model=UserOut)
94
+ async def update_account(
95
+ username: str,
96
+ payload: UserUpdate,
97
+ session: SessionDep,
98
+ principal: AdminDep,
99
+ ) -> UserOut:
100
+ """Change an account's display name, email or role; the username and the password are not editable."""
101
+ row = await find(session, username)
102
+ if payload.changing("name"):
103
+ row.name = payload.name
104
+ if payload.changing("email"):
105
+ try:
106
+ await set_email(session, row, payload.email)
107
+ except DuplicateEmail as error:
108
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
109
+ if payload.role is not None:
110
+ try:
111
+ await set_role(session, row, payload.role)
112
+ except LastAdmin as error:
113
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
114
+ await session.flush()
115
+ return render(row)
116
+
117
+
118
+ @router.post(
119
+ "/users/{username}/$deactivate",
120
+ operation_id="deactivateUser",
121
+ summary="Deactivate an account",
122
+ response_model=UserOut,
123
+ )
124
+ async def deactivate_account(username: str, session: SessionDep, principal: AdminDep) -> UserOut:
125
+ """Bar an account from logging in and revoke the sessions it already holds."""
126
+ row = await find(session, username)
127
+ try:
128
+ await deactivate_user(session, row)
129
+ except LastAdmin as error:
130
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
131
+ return render(row)
132
+
133
+
134
+ @router.post(
135
+ "/users/{username}/$activate",
136
+ operation_id="activateUser",
137
+ summary="Activate an account",
138
+ response_model=UserOut,
139
+ )
140
+ async def activate_account(username: str, session: SessionDep, principal: AdminDep) -> UserOut:
141
+ """Let an account log in again; the sessions it lost are not restored."""
142
+ return render(await activate_user(session, await find(session, username)))
143
+
144
+
145
+ @router.post(
146
+ "/users/{username}/$reset-password",
147
+ operation_id="resetPassword",
148
+ summary="Reset an account's password",
149
+ status_code=status.HTTP_204_NO_CONTENT,
150
+ )
151
+ async def reset_account_password(
152
+ username: str,
153
+ payload: PasswordResetRequest,
154
+ session: SessionDep,
155
+ principal: AdminDep,
156
+ ) -> Response:
157
+ """Set an account's password without presenting the old one, ending every session it holds and no API token."""
158
+ row = await find(session, username)
159
+ try:
160
+ await reset_password(session, row, payload.password.get_secret_value())
161
+ except WeakPassword as error:
162
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
163
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
164
+
165
+
166
+ @router.get(
167
+ "/users/{username}/tokens",
168
+ operation_id="listUserTokens",
169
+ summary="List an account's API tokens",
170
+ response_model=Page[TokenOut],
171
+ )
172
+ async def list_account_tokens(
173
+ username: str,
174
+ session: SessionDep,
175
+ principal: AdminDep,
176
+ after: AfterParam = None,
177
+ limit: LimitParam = DEFAULT_PAGE,
178
+ ) -> Page[TokenOut]:
179
+ """List the API tokens one account holds, without their secrets; sessions are not listed."""
180
+ row = await find(session, username)
181
+ rows = await list_tokens(session, user_id=row.id, after=uuid_cursor(after), limit=limit + 1)
182
+ found = [TokenOut.model_validate(token, from_attributes=True) for token in rows]
183
+ items, following = clip(found, limit, lambda token: token.id)
184
+ return Page(items=items, next=following)
185
+
186
+
187
+ @router.post(
188
+ "/users/{username}/tokens",
189
+ operation_id="createUserToken",
190
+ summary="Create an API token for an account",
191
+ response_model=IssuedTokenOut,
192
+ status_code=status.HTTP_201_CREATED,
193
+ )
194
+ async def create_account_token(
195
+ username: str,
196
+ payload: TokenRequest,
197
+ session: SessionDep,
198
+ principal: AdminDep,
199
+ ) -> IssuedTokenOut:
200
+ """Mint a bearer token for another account and return its secret exactly once."""
201
+ row = await find(session, username)
202
+ issued = await issue_token(session, row, name=payload.name)
203
+ return IssuedTokenOut(
204
+ id=issued.id,
205
+ name=issued.name,
206
+ username=issued.username,
207
+ prefix=issued.prefix,
208
+ token=issued.secret.get_secret_value(),
209
+ )
210
+
211
+
212
+ @router.delete(
213
+ "/users/{username}/tokens/{name}",
214
+ operation_id="revokeUserToken",
215
+ summary="Revoke an account's API token",
216
+ status_code=status.HTTP_204_NO_CONTENT,
217
+ )
218
+ async def revoke_account_token(username: str, name: str, session: SessionDep, principal: AdminDep) -> Response:
219
+ """Revoke that account's live tokens of the given name."""
220
+ row = await find(session, username)
221
+ if not await revoke_token(session, user_id=row.id, name=name):
222
+ raise HTTPException(
223
+ status_code=status.HTTP_404_NOT_FOUND,
224
+ detail=f"no live token named {name!r} for {username!r}",
225
+ )
226
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,52 @@
1
+ """The worker registry: who is alive, on what version, with which plugins and tags."""
2
+
3
+ import sqlalchemy as sa
4
+ from fastapi import APIRouter
5
+
6
+ from dirigent_client.enums import WorkerStatus
7
+ from dirigent_client.schemas import Page, WorkerOut
8
+ from dirigent_core.models import Worker, utcnow
9
+ from dirigent_core.registry import STALE_AFTER
10
+ from dirigent_server.dependencies import ServicesDep, SessionDep
11
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip
12
+ from dirigent_server.security import PrincipalDep
13
+ from dirigent_server.transactions import Transactional
14
+
15
+ router = APIRouter(route_class=Transactional, tags=["workers"])
16
+
17
+
18
+ @router.get("/workers", operation_id="listWorkers", summary="List workers", response_model=Page[WorkerOut])
19
+ async def list_workers(
20
+ session: SessionDep,
21
+ services: ServicesDep,
22
+ principal: PrincipalDep,
23
+ after: AfterParam = None,
24
+ limit: LimitParam = DEFAULT_PAGE,
25
+ ) -> Page[WorkerOut]:
26
+ """List the worker registry, flagging anything stale or running different code."""
27
+ digest = services.host.catalog().digest
28
+ cutoff = utcnow() - STALE_AFTER
29
+ statement = sa.select(Worker).order_by(Worker.name).limit(limit + 1)
30
+ if after is not None:
31
+ statement = statement.where(Worker.name > after)
32
+ rows = await session.execute(statement)
33
+ found = [
34
+ WorkerOut(
35
+ id=row.id,
36
+ name=row.name,
37
+ hostname=row.hostname,
38
+ version=row.version,
39
+ status=row.status,
40
+ concurrency=row.concurrency,
41
+ tags=list(row.tags),
42
+ plugins=dict(row.plugins),
43
+ catalog_digest=row.catalog_digest,
44
+ code_matches_server=row.catalog_digest == digest,
45
+ stale=row.last_seen_at < cutoff and row.status is not WorkerStatus.STOPPED,
46
+ created_at=row.created_at,
47
+ last_seen_at=row.last_seen_at,
48
+ )
49
+ for row in rows.scalars()
50
+ ]
51
+ items, following = clip(found, limit, lambda row: row.name)
52
+ return Page(items=items, next=following)
@@ -0,0 +1,186 @@
1
+ """Authentication and the three roles: a session cookie for the UI, a bearer token for automation.
2
+
3
+ Every route under the versioned API requires a principal. The only exemptions are the
4
+ health probes, the OpenAPI document and its viewers, and the login endpoint.
5
+
6
+ A viewer may read: every GET, the event streams, exports and reports, and whatever acts on
7
+ the caller's own credential. An operator may in addition define, apply, run, cancel, retry
8
+ and schedule. Anything that hands out authority -- accounts, API tokens, connections,
9
+ schemas -- and deleting a pipeline require admin.
10
+
11
+ The session cookie is an ambient credential: a browser attaches it to a request another
12
+ site's page caused. So a write that spends it has to say it came from this instance, which
13
+ is what the cross-site guard below requires. A bearer token is not ambient and is exempt.
14
+ """
15
+
16
+ from collections.abc import Awaitable, Callable
17
+ from typing import Annotated
18
+
19
+ from fastapi import FastAPI, HTTPException, Request, Response, Security, status
20
+ from fastapi.security import APIKeyCookie, HTTPAuthorizationCredentials, HTTPBearer
21
+
22
+ from dirigent_core.auth import Principal, resolve_token
23
+ from dirigent_core.config import Settings
24
+ from dirigent_core.logging import redact_path
25
+ from dirigent_server.dependencies import SessionDep
26
+ from dirigent_server.errors import answer, render
27
+
28
+ SESSION_COOKIE = "dirigent_session"
29
+
30
+ BEARER_PREFIX = "Bearer "
31
+
32
+ #: Must not hint at which credential would have worked.
33
+ UNAUTHENTICATED = "authentication required: present a bearer token or log in"
34
+
35
+ #: One detail for both role refusals, saying neither which role would have sufficed nor what
36
+ #: any role may do: a refusal is not the place to teach the permission model.
37
+ FORBIDDEN = "not permitted for your role"
38
+
39
+ #: Declared so the schemes reach the OpenAPI document; without them every operation renders
40
+ #: as open and a generated client has no place to put a token. ``auto_error=False`` on both,
41
+ #: so that :func:`require_principal` remains the one place a request is refused.
42
+ bearer_scheme = HTTPBearer(
43
+ scheme_name="bearerAuth",
44
+ description="An API token from `dg admin token create`, presented as `Authorization: Bearer <token>`.",
45
+ auto_error=False,
46
+ )
47
+ cookie_scheme = APIKeyCookie(
48
+ name=SESSION_COOKIE,
49
+ scheme_name="sessionCookie",
50
+ description="The http-only session cookie `POST /auth/login` sets, which the UI uses.",
51
+ auto_error=False,
52
+ )
53
+
54
+
55
+ #: Methods that change state, and so may not be caused by another site's page.
56
+ UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
57
+
58
+ #: What a browser reports for a request that no other site initiated: a same-origin fetch,
59
+ #: or a typed URL, bookmark or redirect, which is ``none``.
60
+ SAME_SITE_FETCH = frozenset({"same-origin", "none"})
61
+
62
+ #: Login is guarded although it carries no session yet: a cross-site login POST logs a
63
+ #: person into an account the attacker controls, and every later action is attributed there.
64
+ LOGIN_PATH = "/auth/login"
65
+
66
+ CROSS_SITE = (
67
+ "this write was initiated by another site, and a session cookie may not be spent across "
68
+ "origins; automation authenticating with a bearer token is unaffected"
69
+ )
70
+
71
+
72
+ def presented_secret(request: Request) -> str | None:
73
+ """Read the credential from the request: an Authorization header, or the session cookie."""
74
+ header = request.headers.get("authorization")
75
+ if header and header.startswith(BEARER_PREFIX):
76
+ return header[len(BEARER_PREFIX) :].strip() or None
77
+ return request.cookies.get(SESSION_COOKIE)
78
+
79
+
80
+ async def optional_principal(
81
+ request: Request,
82
+ session: SessionDep,
83
+ _bearer: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)] = None,
84
+ _cookie: Annotated[str | None, Security(cookie_scheme)] = None,
85
+ ) -> Principal | None:
86
+ """Resolve who is asking, or nothing when no usable credential was presented.
87
+
88
+ The two unused scheme parameters are what put the security schemes in the OpenAPI
89
+ document; the credential itself is read from the request.
90
+ """
91
+ secret = presented_secret(request)
92
+ if secret is None:
93
+ return None
94
+ return await resolve_token(session, secret)
95
+
96
+
97
+ async def require_principal(principal: Annotated[Principal | None, Security(optional_principal)]) -> Principal:
98
+ """Refuse a request that carries no valid credential."""
99
+ if principal is None:
100
+ raise HTTPException(
101
+ status_code=status.HTTP_401_UNAUTHORIZED,
102
+ detail=UNAUTHENTICATED,
103
+ headers={"WWW-Authenticate": "Bearer"},
104
+ )
105
+ return principal
106
+
107
+
108
+ async def require_operator(principal: Annotated[Principal, Security(require_principal)]) -> Principal:
109
+ """Refuse a request from an account that may only read."""
110
+ if not principal.may_operate:
111
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=FORBIDDEN)
112
+ return principal
113
+
114
+
115
+ async def require_admin(principal: Annotated[Principal, Security(require_principal)]) -> Principal:
116
+ """Refuse a request from an account that may not manage accounts, tokens, or connections."""
117
+ if not principal.is_admin:
118
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=FORBIDDEN)
119
+ return principal
120
+
121
+
122
+ def initiated_elsewhere(request: Request) -> bool:
123
+ """Whether a browser says another site caused this request.
124
+
125
+ Fetch metadata is the answer when the browser sends it; ``Origin`` is the fallback, and
126
+ a request carrying neither is not from a browser at all.
127
+ """
128
+ site = request.headers.get("sec-fetch-site")
129
+ if site is not None:
130
+ return site not in SAME_SITE_FETCH
131
+ origin = request.headers.get("origin")
132
+ if origin is None:
133
+ return False
134
+ return origin.rstrip("/").lower() != f"{request.url.scheme}://{request.url.netloc}".lower()
135
+
136
+
137
+ def spends_the_cookie(request: Request, api_prefix: str) -> bool:
138
+ """Whether this request is a write under the API that a session cookie would authenticate."""
139
+ if request.method not in UNSAFE_METHODS or not request.url.path.startswith(api_prefix):
140
+ return False
141
+ if request.url.path == f"{api_prefix.rstrip('/')}{LOGIN_PATH}":
142
+ return True
143
+ header = request.headers.get("authorization")
144
+ if header and header.startswith(BEARER_PREFIX):
145
+ return False
146
+ return SESSION_COOKIE in request.cookies
147
+
148
+
149
+ def install_cross_site_guard(app: FastAPI, settings: Settings) -> None:
150
+ """Refuse a state-changing request that a cookie would authenticate and another site caused.
151
+
152
+ Registered before the error handlers, so the version-header middleware they install wraps
153
+ this refusal too.
154
+ """
155
+ prefix = settings.api_prefix
156
+
157
+ @app.middleware("http")
158
+ async def guard( # pyright: ignore[reportUnusedFunction]
159
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
160
+ ) -> Response:
161
+ if spends_the_cookie(request, prefix) and initiated_elsewhere(request):
162
+ return answer(render(status.HTTP_403_FORBIDDEN, CROSS_SITE, instance=redact_path(request.url.path)))
163
+ return await call_next(request)
164
+
165
+
166
+ def set_session_cookie(response: Response, secret: str, *, max_age: int, secure: bool) -> None:
167
+ """Attach a session cookie that JavaScript cannot read and another site cannot send."""
168
+ response.set_cookie(
169
+ SESSION_COOKIE,
170
+ secret,
171
+ max_age=max_age,
172
+ httponly=True,
173
+ samesite="lax",
174
+ secure=secure,
175
+ path="/",
176
+ )
177
+
178
+
179
+ def clear_session_cookie(response: Response) -> None:
180
+ """Remove the session cookie."""
181
+ response.delete_cookie(SESSION_COOKIE, path="/")
182
+
183
+
184
+ PrincipalDep = Annotated[Principal, Security(require_principal)]
185
+ OperatorDep = Annotated[Principal, Security(require_operator)]
186
+ AdminDep = Annotated[Principal, Security(require_admin)]
File without changes
@@ -0,0 +1,34 @@
1
+ """Where a request's transaction is committed: before its response, not after."""
2
+
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Any
5
+
6
+ from fastapi import Request, Response
7
+ from fastapi.routing import APIRoute
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+
11
+ class Transactional(APIRoute):
12
+ """A route that commits its request's transaction before the response is sent.
13
+
14
+ The alternative is to commit in the exit of the dependency that opened it, which FastAPI
15
+ runs after the response has gone out. Two things follow from that, and both are wrong: a
16
+ client that reads straight back can miss the write it was just told about, and a commit
17
+ that fails does so after the client has a 2xx it will believe.
18
+
19
+ Committing here happens while the response can still be changed, so a commit that fails
20
+ is the error the client sees.
21
+ """
22
+
23
+ def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
24
+ """Wrap the handler so what it decided is durable before anybody is told."""
25
+ handle = super().get_route_handler()
26
+
27
+ async def commit_then_respond(request: Request) -> Response:
28
+ response = await handle(request)
29
+ session: AsyncSession | None = getattr(request.state, "session", None)
30
+ if session is not None and session.in_transaction():
31
+ await session.commit()
32
+ return response
33
+
34
+ return commit_then_respond