Skip to content

Commit 4ab8659

Browse files
authored
feat: add user alerts (#1121)
Enables Renku administrators and alerting systems to send alerts to users, which are then displayed in their Renku sessions: - Users can retrieve their active alerts for specific sessions - Admins can manually create and resolve alerts via REST API - Support for creating and resolving alerts from Prometheus Alertmanager webhooks, secured with OAuth2 client credentials flow
1 parent ed341a0 commit 4ab8659

File tree

26 files changed

+1215
-6
lines changed

26 files changed

+1215
-6
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ API_SPECS := \
5252
components/renku_data_services/notebooks/apispec.py \
5353
components/renku_data_services/platform/apispec.py \
5454
components/renku_data_services/data_connectors/apispec.py \
55-
components/renku_data_services/search/apispec.py
55+
components/renku_data_services/search/apispec.py \
56+
components/renku_data_services/notifications/apispec.py
5657

5758
schemas: ${API_SPECS} ## Generate pydantic classes from apispec yaml files
5859
@echo "generated classes based on ApiSpec"

bases/renku_data_services/data_api/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from renku_data_services.data_connectors.blueprints import DataConnectorsBP
2727
from renku_data_services.namespace.blueprints import GroupsBP
2828
from renku_data_services.notebooks.blueprints import NotebooksBP, NotebooksNewBP
29+
from renku_data_services.notifications.blueprints import NotificationsBP
2930
from renku_data_services.platform.blueprints import PlatformConfigBP, PlatformUrlRedirectBP
3031
from renku_data_services.project.blueprints import ProjectsBP, ProjectSessionSecretBP
3132
from renku_data_services.repositories.blueprints import RepositoriesBP
@@ -261,6 +262,13 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
261262
authenticator=dm.authenticator,
262263
metrics=dm.metrics,
263264
)
265+
notifications = NotificationsBP(
266+
name="notifications",
267+
url_prefix=url_prefix,
268+
notifications_repo=dm.notifications_repo,
269+
authenticator=dm.authenticator,
270+
alertmanager_webhook_role=dm.config.alertmanager_webhook_role,
271+
)
264272
app.blueprint(
265273
[
266274
resource_pools.blueprint(),
@@ -289,6 +297,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic:
289297
search.blueprint(),
290298
data_connectors.blueprint(),
291299
platform_redirects.blueprint(),
300+
notifications.blueprint(),
292301
]
293302
)
294303
if builds is not None:

bases/renku_data_services/data_api/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class Config:
4040
gitlab_url: str | None
4141
log_cfg: LoggingConfig
4242
version: str
43+
alertmanager_webhook_role: str
4344

4445
@classmethod
4546
def from_env(cls, db: DBConfig | None = None) -> Self:
@@ -82,4 +83,5 @@ def from_env(cls, db: DBConfig | None = None) -> Self:
8283
server_options=ServerOptionsConfig.from_env(),
8384
gitlab_url=gitlab_url,
8485
log_cfg=LoggingConfig.from_env(),
86+
alertmanager_webhook_role=os.environ.get("ALERTMANAGER_WEBHOOK_ROLE", "alertmanager-webhook"),
8587
)

bases/renku_data_services/data_api/dependencies.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import renku_data_services.connected_services
1515
import renku_data_services.crc
1616
import renku_data_services.data_connectors
17+
import renku_data_services.notifications
1718
import renku_data_services.platform
1819
import renku_data_services.repositories
1920
import renku_data_services.search
@@ -56,6 +57,7 @@
5657
from renku_data_services.notebooks.config import GitProviderHelperProto, get_clusters
5758
from renku_data_services.notebooks.constants import AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK
5859
from renku_data_services.notebooks.image_check import ImageCheckRepository
60+
from renku_data_services.notifications.db import NotificationsRepository
5961
from renku_data_services.platform.db import PlatformRepository, UrlRedirectRepository
6062
from renku_data_services.project.db import (
6163
ProjectMemberRepository,
@@ -147,6 +149,7 @@ class DependencyManager:
147149
shipwright_client: ShipwrightClient | None
148150
url_redirect_repo: UrlRedirectRepository
149151
git_provider_helper: GitProviderHelperProto
152+
notifications_repo: NotificationsRepository
150153
oauth_http_client_factory: OAuthHttpClientFactory
151154

152155
spec: dict[str, Any] = field(init=False, repr=False, default_factory=dict)
@@ -175,6 +178,7 @@ def load_apispec() -> dict[str, Any]:
175178
renku_data_services.platform.__file__,
176179
renku_data_services.data_connectors.__file__,
177180
renku_data_services.search.__file__,
181+
renku_data_services.notifications.__file__,
178182
]
179183

180184
api_specs = []
@@ -394,6 +398,10 @@ def from_env(cls) -> DependencyManager:
394398
project_repo=project_repo,
395399
data_connector_repo=data_connector_repo,
396400
)
401+
notifications_repo = NotificationsRepository(
402+
session_maker=config.db.async_session_maker,
403+
alertmanager_webhook_role=config.alertmanager_webhook_role,
404+
)
397405
return cls(
398406
config,
399407
authenticator=authenticator,
@@ -431,5 +439,6 @@ def from_env(cls) -> DependencyManager:
431439
low_level_user_secrets_repo=low_level_user_secrets_repo,
432440
url_redirect_repo=url_redirect_repo,
433441
git_provider_helper=git_provider_helper,
442+
notifications_repo=notifications_repo,
434443
oauth_http_client_factory=oauth_http_client_factory,
435444
)

components/renku_data_services/authn/dummy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,5 @@ async def authenticate(self, access_token: str, request: Request) -> base_models
7878
email=user_props.get("email", "john.doe@gmail.com") if is_set else None,
7979
full_name=user_props.get("full_name", "John Doe") if is_set else None,
8080
refresh_token=request.headers.get("Renku-Auth-Refresh-Token"),
81+
roles=user_props.get("roles", []),
8182
)

components/renku_data_services/authn/keycloak.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,14 +116,26 @@ async def authenticate(
116116
with suppress(errors.UnauthorizedError, jwt.InvalidTokenError):
117117
token = str(header_value).removeprefix("Bearer ").removeprefix("bearer ")
118118
parsed = self._validate(token)
119-
is_admin = self.admin_role in parsed.get("realm_access", {}).get("roles", [])
119+
roles = parsed.get("realm_access", {}).get("roles", [])
120+
is_admin = self.admin_role in roles
120121
exp = parsed.get("exp")
121122
id = parsed.get("sub")
122123
email = parsed.get("email")
123-
if id is None or email is None:
124+
125+
if email is None:
126+
client_id = parsed.get("azp")
127+
if client_id:
128+
email = f"service-account-{client_id}@renku.local"
129+
else:
130+
raise errors.UnauthorizedError(
131+
message="Your credentials are invalid or expired, please log in again."
132+
) from None
133+
134+
if id is None:
124135
raise errors.UnauthorizedError(
125136
message="Your credentials are invalid or expired, please log in again."
126137
) from None
138+
127139
user = base_models.AuthenticatedAPIUser(
128140
is_admin=is_admin,
129141
id=id,
@@ -134,6 +146,7 @@ async def authenticate(
134146
email=email,
135147
refresh_token=str(refresh_token) if refresh_token else None,
136148
access_token_expires_at=datetime.fromtimestamp(exp) if exp is not None else None,
149+
roles=roles,
137150
)
138151
if user is not None:
139152
return user

components/renku_data_services/base_api/auth.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,36 @@ async def decorated_function(*args: _P.args, **kwargs: _P.kwargs) -> _T:
156156
return response
157157

158158
return decorated_function
159+
160+
161+
def require_role(
162+
role: str,
163+
) -> Callable[
164+
[Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]]],
165+
Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]],
166+
]:
167+
"""Decorator for a Sanic handler that errors out if the user does not have the specified role.
168+
169+
Args:
170+
role: The role name to check for (e.g., "alertmanager-webhook")
171+
"""
172+
173+
def decorator(
174+
f: Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]],
175+
) -> Callable[Concatenate[Request, APIUser, _P], Coroutine[Any, Any, _T]]:
176+
@wraps(f)
177+
async def decorated_function(request: Request, user: APIUser, *args: _P.args, **kwargs: _P.kwargs) -> _T:
178+
if user is None or user.access_token is None:
179+
raise errors.UnauthorizedError(
180+
message="Please provide valid access credentials in the Authorization header."
181+
)
182+
if role not in user.roles and not user.is_admin:
183+
raise errors.ForbiddenError(message=f"You do not have the required role '{role}' for this operation.")
184+
185+
# the user is authenticated and has the required role
186+
response = await f(request, user, *args, **kwargs)
187+
return response
188+
189+
return decorated_function
190+
191+
return decorator

components/renku_data_services/base_models/core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class APIUser:
3333
email: str | None = None
3434
access_token_expires_at: datetime | None = None
3535
is_admin: bool = False
36+
roles: list[str] = field(default_factory=list)
3637

3738
@property
3839
def is_authenticated(self) -> bool:

components/renku_data_services/migrations/env.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from renku_data_services.metrics.orm import BaseORM as metrics
1010
from renku_data_services.migrations.utils import run_migrations
1111
from renku_data_services.namespace.orm import BaseORM as namespaces
12+
from renku_data_services.notifications.orm import BaseORM as notifications
1213
from renku_data_services.platform.orm import BaseORM as platform
1314
from renku_data_services.project.orm import BaseORM as project
1415
from renku_data_services.search.orm import BaseORM as search
@@ -19,20 +20,21 @@
1920

2021
all_metadata = [
2122
authz.metadata,
22-
crc.metadata,
2323
connected_services.metadata,
24+
crc.metadata,
2425
data_connectors.metadata,
2526
events.metadata,
2627
k8s_cache.metadata,
2728
metrics.metadata,
2829
namespaces.metadata,
30+
notifications.metadata,
2931
platform.metadata,
3032
project.metadata,
33+
search.metadata,
3134
secrets.metadata,
3235
sessions.metadata,
3336
storage.metadata,
3437
users.metadata,
35-
search.metadata,
3638
]
3739

3840
run_migrations(all_metadata)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""add alerts table
2+
3+
Revision ID: 5ec28ea89e0a
4+
Revises: 328803606473
5+
Create Date: 2025-11-05 13:41:25.972261
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
from renku_data_services.utils.sqlalchemy import ULIDType
13+
14+
# revision identifiers, used by Alembic.
15+
revision = "5ec28ea89e0a"
16+
down_revision = "328803606473"
17+
branch_labels = None
18+
depends_on = None
19+
20+
21+
def upgrade() -> None:
22+
# ### commands auto generated by Alembic - please adjust! ###
23+
op.create_table(
24+
"alerts",
25+
sa.Column("id", ULIDType(), server_default=sa.text("generate_ulid()"), nullable=False),
26+
sa.Column("title", sa.String(), nullable=False),
27+
sa.Column("message", sa.String(), nullable=False),
28+
sa.Column("event_type", sa.String(), nullable=False),
29+
sa.Column("user_id", sa.String(), nullable=False),
30+
sa.Column("session_name", sa.String(), nullable=True),
31+
sa.Column("creation_date", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
32+
sa.Column("resolved_date", sa.DateTime(timezone=True), nullable=True),
33+
sa.ForeignKeyConstraint(["user_id"], ["users.users.keycloak_id"], ondelete="CASCADE"),
34+
sa.PrimaryKeyConstraint("id"),
35+
schema="notifications",
36+
)
37+
op.create_index(
38+
op.f("ix_notifications_alerts_event_type"), "alerts", ["event_type"], unique=False, schema="notifications"
39+
)
40+
op.create_index(
41+
op.f("ix_notifications_alerts_user_id"), "alerts", ["user_id"], unique=False, schema="notifications"
42+
)
43+
# ### end Alembic commands ###
44+
45+
46+
def downgrade() -> None:
47+
# ### commands auto generated by Alembic - please adjust! ###
48+
op.drop_index(op.f("ix_notifications_alerts_user_id"), table_name="alerts", schema="notifications")
49+
op.drop_index(op.f("ix_notifications_alerts_event_type"), table_name="alerts", schema="notifications")
50+
op.drop_table("alerts", schema="notifications")
51+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)