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,289 @@
1
+ """Alert rules, and the notification queue they deliver through."""
2
+
3
+ from datetime import timedelta
4
+ from typing import Annotated
5
+ from uuid import UUID
6
+
7
+ from fastapi import APIRouter, HTTPException, Query, Response, status
8
+
9
+ from dirigent_client.enums import NotificationStatus
10
+ from dirigent_client.schemas import (
11
+ AlertRuleIn,
12
+ AlertRuleOut,
13
+ AlertRuleUpdate,
14
+ NotificationOut,
15
+ Page,
16
+ TestQueued,
17
+ TestRequest,
18
+ )
19
+ from dirigent_common.durations import format_duration
20
+ from dirigent_core.alerting import (
21
+ AlertError,
22
+ AlertRuleRequest,
23
+ create_rule,
24
+ delete_rule,
25
+ find_notification,
26
+ find_rule,
27
+ list_notifications,
28
+ list_rules,
29
+ queue_test_message,
30
+ retry_notification,
31
+ set_paused,
32
+ )
33
+ from dirigent_core.models import AlertRule, Connection, Notification, Pipeline, Run
34
+ from dirigent_server.dependencies import ServicesDep, SessionDep
35
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, uuid_cursor
36
+ from dirigent_server.security import OperatorDep, PrincipalDep
37
+ from dirigent_server.transactions import Transactional
38
+
39
+ router = APIRouter(route_class=Transactional, tags=["alerts"])
40
+
41
+
42
+ def render(rule: AlertRule, pipeline: str | None, connection: str | None = None) -> AlertRuleOut:
43
+ """Render an alert rule, writing its throttle back as the humane duration it was."""
44
+ return AlertRuleOut(
45
+ id=rule.id,
46
+ code=rule.code,
47
+ name=rule.name,
48
+ description=rule.description,
49
+ event=rule.event,
50
+ scope=rule.scope,
51
+ pipeline=pipeline,
52
+ notifier=rule.notifier,
53
+ connection=connection,
54
+ template=rule.template,
55
+ throttle=format_duration(timedelta(seconds=rule.throttle_seconds)),
56
+ active=rule.active,
57
+ paused=rule.paused,
58
+ last_sent_at=rule.last_sent_at,
59
+ created_at=rule.created_at,
60
+ )
61
+
62
+
63
+ @router.get(
64
+ "/alert-rules",
65
+ operation_id="listAlertRules",
66
+ summary="List alert rules",
67
+ response_model=Page[AlertRuleOut],
68
+ )
69
+ async def rules(
70
+ session: SessionDep,
71
+ principal: PrincipalDep,
72
+ after: AfterParam = None,
73
+ limit: LimitParam = DEFAULT_PAGE,
74
+ ) -> Page[AlertRuleOut]:
75
+ """List every alert rule, with the pipeline each one watches when it is scoped."""
76
+ rows = await list_rules(session, after=uuid_cursor(after), limit=limit + 1)
77
+ found = [
78
+ render(rule, await _pipeline_code(session, rule), await _connection_code(session, rule.connection_id))
79
+ for rule in rows
80
+ ]
81
+ items, following = clip(found, limit, lambda row: row.id)
82
+ return Page(items=items, next=following)
83
+
84
+
85
+ @router.post(
86
+ "/alert-rules",
87
+ operation_id="createAlertRule",
88
+ summary="Declare an alert rule",
89
+ response_model=AlertRuleOut,
90
+ status_code=status.HTTP_201_CREATED,
91
+ )
92
+ async def add_rule(
93
+ payload: AlertRuleIn, session: SessionDep, services: ServicesDep, principal: OperatorDep
94
+ ) -> AlertRuleOut:
95
+ """Declare an alert rule, refusing a notifier or a pipeline this instance does not have."""
96
+ try:
97
+ rule = await create_rule(
98
+ session,
99
+ services,
100
+ AlertRuleRequest(
101
+ code=payload.code,
102
+ name=payload.name,
103
+ description=payload.description,
104
+ event=payload.event,
105
+ notifier=payload.notifier,
106
+ scope=payload.scope,
107
+ pipeline=payload.pipeline,
108
+ connection=payload.connection,
109
+ template=payload.template,
110
+ throttle=payload.throttle,
111
+ ),
112
+ )
113
+ except AlertError as error:
114
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
115
+ return render(rule, payload.pipeline, payload.connection)
116
+
117
+
118
+ @router.patch(
119
+ "/alert-rules/{code}",
120
+ operation_id="updateAlertRule",
121
+ summary="Pause or resume an alert rule",
122
+ response_model=AlertRuleOut,
123
+ )
124
+ async def change_rule(code: str, payload: AlertRuleUpdate, session: SessionDep, principal: OperatorDep) -> AlertRuleOut:
125
+ """Hold a rule's deliveries, or let them resume.
126
+
127
+ Pausing is instance state on the row rather than something the rule declares, so a rule an
128
+ operator held keeps holding when the document that declared it is applied again.
129
+ """
130
+ rule = await _rule_or_404(session, code)
131
+ await set_paused(session, rule, paused=payload.paused)
132
+ return render(rule, await _pipeline_code(session, rule), await _connection_code(session, rule.connection_id))
133
+
134
+
135
+ @router.delete(
136
+ "/alert-rules/{code}",
137
+ operation_id="deleteAlertRule",
138
+ summary="Delete an alert rule",
139
+ status_code=status.HTTP_204_NO_CONTENT,
140
+ )
141
+ async def remove_rule(code: str, session: SessionDep, principal: OperatorDep) -> Response:
142
+ """Remove an alert rule; the notifications it already raised are kept."""
143
+ await delete_rule(session, await _rule_or_404(session, code))
144
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
145
+
146
+
147
+ @router.post(
148
+ "/alert-rules/$test",
149
+ operation_id="testNotifier",
150
+ summary="Send a test message through a notifier",
151
+ response_model=TestQueued,
152
+ status_code=status.HTTP_202_ACCEPTED,
153
+ )
154
+ async def test_notifier(
155
+ payload: TestRequest, session: SessionDep, services: ServicesDep, principal: OperatorDep
156
+ ) -> TestQueued:
157
+ """Queue one message through a channel, on the same path a real alert takes."""
158
+ try:
159
+ notification = await queue_test_message(
160
+ session,
161
+ services,
162
+ notifier=payload.notifier,
163
+ connection=payload.connection,
164
+ subject=payload.subject,
165
+ body=payload.body,
166
+ )
167
+ except AlertError as error:
168
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
169
+ return TestQueued(notification_id=notification.id, notifier=notification.notifier)
170
+
171
+
172
+ @router.get(
173
+ "/notifications",
174
+ operation_id="listNotifications",
175
+ summary="List queued and delivered alerts",
176
+ response_model=Page[NotificationOut],
177
+ )
178
+ async def notifications(
179
+ session: SessionDep,
180
+ services: ServicesDep,
181
+ principal: PrincipalDep,
182
+ run_id: Annotated[UUID | None, Query(description="Only this run's notifications.")] = None,
183
+ notification_status: Annotated[
184
+ NotificationStatus | None, Query(alias="status", description="Only rows in this state.")
185
+ ] = None,
186
+ notifier: Annotated[str | None, Query(description="Only rows this channel delivers.")] = None,
187
+ after: AfterParam = None,
188
+ limit: LimitParam = DEFAULT_PAGE,
189
+ ) -> Page[NotificationOut]:
190
+ """Read the alert queue, newest first."""
191
+ rows = await list_notifications(
192
+ session,
193
+ run_id=run_id,
194
+ notification_status=notification_status,
195
+ notifier=notifier,
196
+ after=uuid_cursor(after),
197
+ limit=limit + 1,
198
+ )
199
+ found = [await _notification(session, services, row) for row in rows]
200
+ items, following = clip(found, limit, lambda row: row.id)
201
+ return Page(items=items, next=following)
202
+
203
+
204
+ @router.get(
205
+ "/notifications/{notification_id}",
206
+ operation_id="getNotification",
207
+ summary="Read one queued or delivered alert",
208
+ response_model=NotificationOut,
209
+ )
210
+ async def notification(
211
+ notification_id: UUID, session: SessionDep, services: ServicesDep, principal: PrincipalDep
212
+ ) -> NotificationOut:
213
+ """Read one notification, which is how a caller watches a delivery it just queued."""
214
+ return await _notification(session, services, await _notification_or_404(session, notification_id))
215
+
216
+
217
+ @router.post(
218
+ "/notifications/{notification_id}/$retry",
219
+ operation_id="retryNotification",
220
+ summary="Put a notification back on the queue",
221
+ response_model=NotificationOut,
222
+ )
223
+ async def retry(
224
+ notification_id: UUID, session: SessionDep, services: ServicesDep, principal: OperatorDep
225
+ ) -> NotificationOut:
226
+ """Make one notification due now, so a worker takes it on its next pass."""
227
+ row = await _notification_or_404(session, notification_id)
228
+ try:
229
+ await retry_notification(session, row)
230
+ except AlertError as error:
231
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
232
+ return await _notification(session, services, row)
233
+
234
+
235
+ async def _notification(session: SessionDep, services: ServicesDep, row: Notification) -> NotificationOut:
236
+ """Render one notification row, naming the run by its pipeline rather than by its id."""
237
+ run = await session.get(Run, row.run_id) if row.run_id else None
238
+ pipeline = await session.get(Pipeline, run.pipeline_id) if run else None
239
+ rule = await session.get(AlertRule, row.alert_rule_id) if row.alert_rule_id else None
240
+ return NotificationOut(
241
+ id=row.id,
242
+ event=row.event,
243
+ rule=rule.code if rule else None,
244
+ notifier=row.notifier,
245
+ connection=await _connection_code(session, row.connection_id),
246
+ subject=row.subject,
247
+ status=row.status,
248
+ attempt=row.attempt,
249
+ max_attempts=services.settings.notification_max_attempts,
250
+ run_id=row.run_id,
251
+ run_pipeline=pipeline.code if pipeline else None,
252
+ run_started_at=run.started_at if run else None,
253
+ available_at=row.available_at,
254
+ sent_at=row.sent_at,
255
+ error=row.error,
256
+ created_at=row.created_at,
257
+ )
258
+
259
+
260
+ async def _notification_or_404(session: SessionDep, notification_id: UUID) -> Notification:
261
+ """Find one notification, or refuse with the id that named nothing."""
262
+ row = await find_notification(session, notification_id)
263
+ if row is None:
264
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no notification {notification_id}")
265
+ return row
266
+
267
+
268
+ async def _rule_or_404(session: SessionDep, code: str) -> AlertRule:
269
+ """Find one alert rule, or refuse with the code that named nothing."""
270
+ rule = await find_rule(session, code)
271
+ if rule is None:
272
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no alert rule coded {code!r}")
273
+ return rule
274
+
275
+
276
+ async def _pipeline_code(session: SessionDep, rule: AlertRule) -> str | None:
277
+ """Resolve the code of the pipeline a scoped rule watches."""
278
+ if rule.pipeline_id is None:
279
+ return None
280
+ pipeline = await session.get(Pipeline, rule.pipeline_id)
281
+ return pipeline.code if pipeline else None
282
+
283
+
284
+ async def _connection_code(session: SessionDep, connection_id: UUID | None) -> str | None:
285
+ """Resolve the code of the connection a channel delivers through."""
286
+ if connection_id is None:
287
+ return None
288
+ connection = await session.get(Connection, connection_id)
289
+ return connection.code if connection else None
@@ -0,0 +1,218 @@
1
+ """Logging in, logging out, and asking who you are."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Request, Response, status
4
+
5
+ from dirigent_client.enums import TokenKind
6
+ from dirigent_client.schemas import (
7
+ Identity,
8
+ IssuedTokenOut,
9
+ LoginRequest,
10
+ Page,
11
+ PasswordChangeRequest,
12
+ TokenOut,
13
+ TokenRequest,
14
+ )
15
+ from dirigent_core.auth import (
16
+ SESSION_LIFETIME,
17
+ Principal,
18
+ WeakPassword,
19
+ WrongPassword,
20
+ authenticate,
21
+ change_password,
22
+ issue_token,
23
+ list_tokens,
24
+ revoke_session,
25
+ revoke_token,
26
+ )
27
+ from dirigent_core.auth import find_user as find_user_row
28
+ from dirigent_core.models import User
29
+ from dirigent_core.ratelimit import TokenBucket
30
+ from dirigent_server.dependencies import SessionDep, SettingsDep
31
+ from dirigent_server.logging import get_logger
32
+ from dirigent_server.pagination import DEFAULT_PAGE, AfterParam, LimitParam, clip, uuid_cursor
33
+ from dirigent_server.security import (
34
+ AdminDep,
35
+ PrincipalDep,
36
+ clear_session_cookie,
37
+ presented_secret,
38
+ set_session_cookie,
39
+ )
40
+ from dirigent_server.transactions import Transactional
41
+
42
+ router = APIRouter(route_class=Transactional, tags=["auth"])
43
+
44
+ _logger = get_logger("auth")
45
+
46
+ #: Mounted outside the principal dependency; login is the only unauthenticated route.
47
+ public_router = APIRouter(route_class=Transactional, tags=["auth"])
48
+
49
+
50
+ LOGIN_BUCKETS = TokenBucket()
51
+
52
+
53
+ @public_router.post(
54
+ "/auth/login",
55
+ operation_id="login",
56
+ summary="Log in and receive a session cookie",
57
+ response_model=Identity,
58
+ responses={429: {"description": "Too many login attempts from this address or for this account."}},
59
+ )
60
+ async def login(
61
+ payload: LoginRequest,
62
+ request: Request,
63
+ response: Response,
64
+ session: SessionDep,
65
+ settings: SettingsDep,
66
+ ) -> Identity:
67
+ """Verify a username and password, and set an http-only session cookie."""
68
+ _limit_login(request, payload.username, per_minute=settings.login_rate_per_minute)
69
+ user = await authenticate(session, payload.username, payload.password.get_secret_value())
70
+ if user is None:
71
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password")
72
+ issued = await issue_token(session, user, name="session", kind=TokenKind.SESSION, lifetime=SESSION_LIFETIME)
73
+ set_session_cookie(
74
+ response,
75
+ issued.secret.get_secret_value(),
76
+ max_age=int(SESSION_LIFETIME.total_seconds()),
77
+ secure=settings.environment != "local",
78
+ )
79
+ return Identity(user_id=user.id, username=user.username, role=user.role, via=TokenKind.SESSION)
80
+
81
+
82
+ def _limit_login(request: Request, username: str, *, per_minute: int) -> None:
83
+ """Refuse a caller attempting logins faster than the instance is willing to hash.
84
+
85
+ Login is unauthenticated and runs Argon2id, so an unlimited caller can spend the whole
86
+ process's memory and CPU. Both keys have to pass: the address bound stops one machine
87
+ from flooding the process, and the username bound stops a stuffing run spread across
88
+ many addresses from concentrating on one account. Refusal must stay ahead of the hash.
89
+ """
90
+ address = request.client.host if request.client else "unknown"
91
+ for scope, key in (("address", f"address:{address}"), ("account", f"user:{username}")):
92
+ if not LOGIN_BUCKETS.allow(key, per_minute=per_minute):
93
+ _logger.warning("login rate limited", scope=scope, address=address, limit=per_minute)
94
+ raise HTTPException(
95
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
96
+ detail=f"too many login attempts; this instance accepts {per_minute} a minute",
97
+ headers={"Retry-After": "60"},
98
+ )
99
+
100
+
101
+ @router.post("/auth/logout", operation_id="logout", summary="Log out", status_code=status.HTTP_204_NO_CONTENT)
102
+ async def logout(request: Request, session: SessionDep, principal: PrincipalDep) -> Response:
103
+ """Revoke the presented session and clear its cookie.
104
+
105
+ The cookie is cleared on the response this returns. Clearing it on the injected one and
106
+ returning another discards the header, because a route that returns a Response returns
107
+ that Response and nothing is merged into it.
108
+ """
109
+ secret = presented_secret(request)
110
+ if secret is not None and principal.via is TokenKind.SESSION:
111
+ await revoke_session(session, secret)
112
+ response = Response(status_code=status.HTTP_204_NO_CONTENT)
113
+ clear_session_cookie(response)
114
+ return response
115
+
116
+
117
+ @router.post(
118
+ "/auth/password",
119
+ operation_id="changePassword",
120
+ summary="Change your own password",
121
+ status_code=status.HTTP_204_NO_CONTENT,
122
+ )
123
+ async def change_own_password(
124
+ payload: PasswordChangeRequest,
125
+ session: SessionDep,
126
+ principal: PrincipalDep,
127
+ ) -> Response:
128
+ """Replace the caller's own password, ending every other session the account holds.
129
+
130
+ The credential this request arrived on survives, so the change does not log the person
131
+ out of the page they made it from. An API token is not a session and is untouched.
132
+ """
133
+ user = await _require_user(session, principal.username)
134
+ try:
135
+ await change_password(
136
+ session,
137
+ user,
138
+ payload.current_password.get_secret_value(),
139
+ payload.new_password.get_secret_value(),
140
+ keep=principal.token_id,
141
+ )
142
+ except WrongPassword as error:
143
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(error)) from error
144
+ except WeakPassword as error:
145
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(error)) from error
146
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
147
+
148
+
149
+ @router.get("/auth/me", operation_id="whoami", summary="Describe the authenticated caller", response_model=Identity)
150
+ async def whoami(principal: PrincipalDep) -> Identity:
151
+ """Report who this request is acting as, and which credential said so."""
152
+ return identity_of(principal)
153
+
154
+
155
+ def identity_of(principal: Principal) -> Identity:
156
+ """Render a principal for the API, which never exposes the credential itself."""
157
+ return Identity(
158
+ user_id=principal.user_id,
159
+ username=principal.username,
160
+ role=principal.role,
161
+ via=principal.via,
162
+ token_name=principal.token_name,
163
+ )
164
+
165
+
166
+ @router.get("/tokens", operation_id="listTokens", summary="List API tokens", response_model=Page[TokenOut])
167
+ async def list_api_tokens(
168
+ session: SessionDep,
169
+ principal: AdminDep,
170
+ after: AfterParam = None,
171
+ limit: LimitParam = DEFAULT_PAGE,
172
+ ) -> Page[TokenOut]:
173
+ """List every API token, without its secret; sessions are not listed."""
174
+ rows = await list_tokens(session, after=uuid_cursor(after), limit=limit + 1)
175
+ found = [TokenOut.model_validate(token, from_attributes=True) for token in rows]
176
+ items, following = clip(found, limit, lambda row: row.id)
177
+ return Page(items=items, next=following)
178
+
179
+
180
+ @router.post(
181
+ "/tokens",
182
+ operation_id="createToken",
183
+ summary="Create an API token",
184
+ response_model=IssuedTokenOut,
185
+ status_code=status.HTTP_201_CREATED,
186
+ )
187
+ async def create_api_token(payload: TokenRequest, session: SessionDep, principal: AdminDep) -> IssuedTokenOut:
188
+ """Mint a bearer token for automation and return its secret exactly once."""
189
+ user = await _require_user(session, principal.username)
190
+ issued = await issue_token(session, user, name=payload.name)
191
+ return IssuedTokenOut(
192
+ id=issued.id,
193
+ name=issued.name,
194
+ username=issued.username,
195
+ prefix=issued.prefix,
196
+ token=issued.secret.get_secret_value(),
197
+ )
198
+
199
+
200
+ @router.delete(
201
+ "/tokens/{name}",
202
+ operation_id="revokeToken",
203
+ summary="Revoke one of your own API tokens",
204
+ status_code=status.HTTP_204_NO_CONTENT,
205
+ )
206
+ async def revoke_api_token(name: str, session: SessionDep, principal: AdminDep) -> Response:
207
+ """Revoke the caller's live tokens of the given name."""
208
+ if not await revoke_token(session, user_id=principal.user_id, name=name):
209
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no live token named {name!r}")
210
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
211
+
212
+
213
+ async def _require_user(session: SessionDep, username: str) -> User:
214
+ """Read the account a principal names."""
215
+ user = await find_user_row(session, username)
216
+ if user is None: # pragma: no cover - a resolved principal always has its row
217
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="the authenticated account is gone")
218
+ return user
@@ -0,0 +1,39 @@
1
+ """The block catalog every installed plugin contributes to."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, HTTPException, Query, status
6
+
7
+ from dirigent_client.schemas import BlockEntry, BlockKind, Catalog
8
+ from dirigent_server.dependencies import ServicesDep
9
+ from dirigent_server.security import PrincipalDep
10
+ from dirigent_server.transactions import Transactional
11
+
12
+ router = APIRouter(route_class=Transactional, tags=["blocks"])
13
+
14
+
15
+ @router.get("/blocks", operation_id="getCatalog", summary="The block catalog", response_model=Catalog)
16
+ async def get_catalog(
17
+ services: ServicesDep,
18
+ principal: PrincipalDep,
19
+ kind: Annotated[BlockKind | None, Query(description="Return only operators, or only sensors.")] = None,
20
+ ) -> Catalog:
21
+ """Serve every contributed operator, sensor, storage scheme, notifier, and connection kind."""
22
+ catalog = services.host.catalog()
23
+ if kind is None:
24
+ return catalog
25
+ return catalog.model_copy(update={"blocks": [block for block in catalog.blocks if block.kind is kind]})
26
+
27
+
28
+ @router.get(
29
+ "/blocks/{block_id}",
30
+ operation_id="getBlock",
31
+ summary="One block's schemas",
32
+ response_model=BlockEntry,
33
+ )
34
+ async def get_block(block_id: str, services: ServicesDep, principal: PrincipalDep) -> BlockEntry:
35
+ """Serve one catalog entry."""
36
+ entry = services.host.catalog().block(block_id)
37
+ if entry is None:
38
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"no block {block_id!r} is installed")
39
+ return entry