Skip to content

Commit eae32b5

Browse files
committed
feat: Add email contact annotation
1 parent 4b4d89c commit eae32b5

24 files changed

Lines changed: 819 additions & 83 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Add escalation_warning_days to global_settings
2+
3+
Revision ID: 005
4+
Revises: 004
5+
Create Date: 2026-03-02 00:00:00.000000
6+
"""
7+
from alembic import op
8+
import sqlalchemy as sa
9+
10+
revision = "005"
11+
down_revision = "004"
12+
branch_labels = None
13+
depends_on = None
14+
15+
16+
def upgrade() -> None:
17+
op.add_column(
18+
"global_settings",
19+
sa.Column("escalation_warning_days", sa.Integer(), nullable=False, server_default="3"),
20+
)
21+
22+
23+
def downgrade() -> None:
24+
op.drop_column("global_settings", "escalation_warning_days")
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Add namespace_contacts table for escalation emails
2+
3+
Revision ID: 006
4+
Revises: 005
5+
Create Date: 2026-03-02 00:00:00.000000
6+
"""
7+
from alembic import op
8+
import sqlalchemy as sa
9+
10+
revision = "006"
11+
down_revision = "005"
12+
branch_labels = None
13+
depends_on = None
14+
15+
16+
def upgrade() -> None:
17+
op.create_table(
18+
"namespace_contacts",
19+
sa.Column("id", sa.Uuid(), primary_key=True),
20+
sa.Column("namespace", sa.String(255), nullable=False, index=True),
21+
sa.Column("cluster_name", sa.String(255), nullable=False),
22+
sa.Column("escalation_email", sa.String(255), nullable=False),
23+
sa.Column("updated_at", sa.DateTime(), nullable=True),
24+
sa.UniqueConstraint("namespace", "cluster_name", name="uq_namespace_contact_ns_cluster"),
25+
)
26+
27+
28+
def downgrade() -> None:
29+
op.drop_table("namespace_contacts")

backend/app/auth/middleware.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ..config import settings
99
from ..database import AppSessionLocal
10+
from ..models.namespace_contact import NamespaceContact
1011
from ..models.user import User, UserRole
1112

1213
logger = logging.getLogger(__name__)
@@ -28,6 +29,50 @@ def _parse_namespaces_header(raw: str) -> list[tuple[str, str]]:
2829
return pairs
2930

3031

32+
def _parse_namespace_emails_header(raw: str) -> list[tuple[str, str, str]]:
33+
"""Parse 'ns1:cluster1=email@x.com,ns2:cluster2=email@y.com' into [(ns, cluster, email), ...]."""
34+
if not raw.strip():
35+
return []
36+
result = []
37+
for entry in raw.split(","):
38+
entry = entry.strip()
39+
if "=" not in entry:
40+
continue
41+
ns_cluster, email = entry.rsplit("=", 1)
42+
email = email.strip()
43+
if ":" not in ns_cluster or not email:
44+
continue
45+
ns, cluster = ns_cluster.split(":", 1)
46+
ns, cluster = ns.strip(), cluster.strip()
47+
if ns and cluster:
48+
result.append((ns, cluster, email))
49+
return result
50+
51+
52+
async def _upsert_namespace_contacts(
53+
session: AsyncSession, contacts: list[tuple[str, str, str]]
54+
) -> None:
55+
"""Upsert namespace escalation email contacts. Only writes if data changed."""
56+
if not contacts:
57+
return
58+
for ns, cluster, email in contacts:
59+
result = await session.execute(
60+
select(NamespaceContact).where(
61+
NamespaceContact.namespace == ns,
62+
NamespaceContact.cluster_name == cluster,
63+
)
64+
)
65+
existing = result.scalar_one_or_none()
66+
if existing:
67+
if existing.escalation_email != email:
68+
existing.escalation_email = email
69+
else:
70+
session.add(NamespaceContact(
71+
namespace=ns, cluster_name=cluster, escalation_email=email,
72+
))
73+
await session.commit()
74+
75+
3176
class CurrentUser:
3277
def __init__(self, id: str, username: str, email: str, role: UserRole, namespaces: list[tuple[str, str]]):
3378
self.id = id
@@ -94,6 +139,11 @@ def _to_current_user(user: User, namespaces: list[tuple[str, str]]) -> CurrentUs
94139
async def _handle_dev_mode(session: AsyncSession) -> CurrentUser:
95140
namespaces = _parse_namespaces_header(settings.dev_user_namespaces)
96141

142+
# Upsert dev namespace email contacts
143+
ns_emails = _parse_namespace_emails_header(settings.dev_namespace_emails)
144+
if ns_emails:
145+
await _upsert_namespace_contacts(session, ns_emails)
146+
97147
user_data = {
98148
"id": settings.dev_user_id,
99149
"username": settings.dev_user_name,
@@ -133,6 +183,12 @@ async def _handle_spoke_proxy(session: AsyncSession, request: Request) -> Curren
133183
user = await _get_or_create_user(session, user_data)
134184
user = await _sync_user_fields(session, user, user_data)
135185

186+
# Upsert namespace escalation email contacts from header
187+
ns_emails_raw = request.headers.get("X-Forwarded-Namespace-Emails", "")
188+
ns_emails = _parse_namespace_emails_header(ns_emails_raw)
189+
if ns_emails:
190+
await _upsert_namespace_contacts(session, ns_emails)
191+
136192
logger.info("Spoke proxy auth: user=%s, role=%s, namespaces=%d", user_id, role.value, len(namespaces))
137193
return _to_current_user(user, namespaces)
138194

backend/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Settings(BaseSettings):
2424
dev_user_email: str = Field(default="dev@example.com")
2525
dev_user_role: str = Field(default="sec_team") # "sec_team" or "team_member"
2626
dev_user_namespaces: str = Field(default="") # format: ns1:cluster1,ns2:cluster2
27+
dev_namespace_emails: str = Field(default="") # format: ns1:cluster1=email@example.com,ns2:cluster2=other@example.com
2728

2829
# OIDC (production)
2930
oidc_issuer: str = Field(default="")

backend/app/mail/service.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ async def send_risk_status_email(
8080
)
8181

8282

83+
async def send_escalation_email(
84+
to_email: str,
85+
cve_id: str,
86+
namespace: str,
87+
cluster_name: str,
88+
level: int,
89+
base_url: str | None = None,
90+
) -> None:
91+
base_url = base_url or settings.app_base_url
92+
tmpl = _jinja_env.get_template("escalation.html")
93+
html = tmpl.render(
94+
cve_id=cve_id,
95+
namespace=namespace,
96+
cluster_name=cluster_name,
97+
level=level,
98+
link=f"{base_url}/eskalationen",
99+
)
100+
await send_email(to_email, f"CVE-Eskalation Stufe {level}: {cve_id}", html)
101+
102+
83103
async def send_weekly_digest(
84104
to_email: str,
85105
stats: dict,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html lang="de">
3+
<head><meta charset="utf-8"><title>Eskalation: {{ cve_id }}</title></head>
4+
<body style="font-family:Arial,sans-serif;background:#f5f5f5;margin:0;padding:20px">
5+
<div style="max-width:600px;margin:auto;background:#fff;border-radius:4px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.1)">
6+
<div style="background:#151515;padding:20px 30px">
7+
<h1 style="color:#fff;margin:0;font-size:18px">RHACS CVE Manager</h1>
8+
</div>
9+
<div style="padding:30px">
10+
<h2 style="color:#c9190b;margin-top:0">Eskalation Stufe {{ level }}</h2>
11+
<p>Die folgende CVE wurde auf <strong>Stufe {{ level }}</strong> eskaliert:</p>
12+
<table style="width:100%;border-collapse:collapse;margin:16px 0">
13+
<tr style="background:#f0f0f0">
14+
<td style="padding:8px 12px;font-weight:bold">CVE</td>
15+
<td style="padding:8px 12px">{{ cve_id }}</td>
16+
</tr>
17+
<tr>
18+
<td style="padding:8px 12px;font-weight:bold">Namespace</td>
19+
<td style="padding:8px 12px">{{ namespace }}</td>
20+
</tr>
21+
<tr style="background:#f0f0f0">
22+
<td style="padding:8px 12px;font-weight:bold">Cluster</td>
23+
<td style="padding:8px 12px">{{ cluster_name }}</td>
24+
</tr>
25+
</table>
26+
<p>Bitte prüfen Sie die CVE und ergreifen Sie entsprechende Maßnahmen.</p>
27+
<a href="{{ link }}" style="display:inline-block;background:#0066cc;color:#fff;padding:10px 20px;text-decoration:none;border-radius:4px;margin-top:10px">Eskalation ansehen</a>
28+
</div>
29+
<div style="background:#f0f0f0;padding:16px 30px;font-size:12px;color:#666">
30+
Dies ist eine automatische Benachrichtigung vom RHACS CVE Manager.
31+
</div>
32+
</div>
33+
</body>
34+
</html>

backend/app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .cve_priority import CvePriority, PriorityLevel
55
from .escalation import Escalation
66
from .global_settings import GlobalSettings
7+
from .namespace_contact import NamespaceContact
78
from .notification import Notification, NotificationType
89
from .risk_acceptance import RiskAcceptance, RiskAcceptanceComment, RiskStatus
910
from .user import User, UserRole
@@ -23,4 +24,5 @@
2324
"Notification",
2425
"NotificationType",
2526
"AuditLog",
27+
"NamespaceContact",
2628
]

backend/app/models/global_settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class GlobalSettings(Base):
4141
escalation_rules: Mapped[list] = mapped_column(
4242
JSONB, nullable=False, default=lambda: DEFAULT_ESCALATION_RULES
4343
)
44+
escalation_warning_days: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
4445
digest_day: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 0=Monday
4546
management_email: Mapped[str] = mapped_column(String(255), nullable=False, default="")
4647
updated_by: Mapped[str | None] = mapped_column(
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from datetime import datetime
2+
from uuid import UUID, uuid4
3+
4+
from sqlalchemy import String, UniqueConstraint
5+
from sqlalchemy.orm import Mapped, mapped_column
6+
7+
from ..database import Base
8+
9+
10+
class NamespaceContact(Base):
11+
__tablename__ = "namespace_contacts"
12+
__table_args__ = (
13+
UniqueConstraint("namespace", "cluster_name", name="uq_namespace_contact_ns_cluster"),
14+
)
15+
16+
id: Mapped[UUID] = mapped_column(primary_key=True, default=uuid4)
17+
namespace: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
18+
cluster_name: Mapped[str] = mapped_column(String(255), nullable=False)
19+
escalation_email: Mapped[str] = mapped_column(String(255), nullable=False)
20+
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)

backend/app/routers/dashboard.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
ThresholdPreview,
2323
)
2424
from ..schemas.cve import CveListItem, SeverityLevel
25+
from ..services.escalation_preview import compute_upcoming_escalations
2526
from ..stackrox import queries as sx
2627

2728
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -88,6 +89,7 @@ async def team_dashboard(
8889
return TeamDashboardData(
8990
stat_total_cves=0,
9091
stat_escalations=0,
92+
stat_upcoming_escalations=0,
9193
stat_fixable_critical_cves=0,
9294
stat_open_risk_acceptances=0,
9395
severity_distribution=[], cves_per_namespace=[], priority_cves=[],
@@ -168,6 +170,12 @@ async def team_dashboard(
168170
)
169171
trend = await sx.get_cve_trend(sx_db, ns_list_for_queries)
170172

173+
# Upcoming escalation count
174+
upcoming_escalations = []
175+
if settings:
176+
upcoming_ns = namespaces if (has_scope or not current_user.is_sec_team) else []
177+
upcoming_escalations = await compute_upcoming_escalations(sx_db, app_db, upcoming_ns, settings)
178+
171179
# Deduplicate by cve_id (same CVE can appear across multiple images).
172180
# Keep the entry with the highest epss_probability for each unique CVE.
173181
from datetime import datetime
@@ -191,6 +199,7 @@ async def team_dashboard(
191199
return TeamDashboardData(
192200
stat_total_cves=total,
193201
stat_escalations=escalations,
202+
stat_upcoming_escalations=len(upcoming_escalations),
194203
stat_fixable_critical_cves=fixable_critical,
195204
stat_open_risk_acceptances=open_ra,
196205
severity_distribution=[

0 commit comments

Comments
 (0)