diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py new file mode 100644 index 0000000..8360767 --- /dev/null +++ b/backend/app/api/credentials.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth.dependencies import get_current_user, require_role +from app.core.secrets import encrypt_secret +from app.db.session import get_db +from app.models import Credential, User +from app.schemas.core import SnmpCredentialProfileCreate, SnmpCredentialProfileRead, SnmpCredentialProfileUpdate + +router = APIRouter(prefix="/credentials", tags=["credentials"]) + +SNMP_CREDENTIAL_TYPE = "snmp" + + +def _snmp_profile_to_read(profile: Credential) -> SnmpCredentialProfileRead: + extra = dict(profile.extra or {}) + return SnmpCredentialProfileRead( + id=profile.id, + name=profile.name, + credential_type=profile.credential_type, + version=str(extra.get("version") or "2c"), + port=int(extra.get("port") or 161), + timeout_seconds=int(extra.get("timeout_seconds") or 5), + retries=int(extra.get("retries") or 1), + has_secret=bool(profile.encrypted_secret), + created_at=profile.created_at, + updated_at=profile.updated_at, + ) + + +@router.get("/snmp", response_model=list[SnmpCredentialProfileRead]) +def list_snmp_profiles(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[SnmpCredentialProfileRead]: + profiles = db.scalars( + select(Credential).where(Credential.credential_type == SNMP_CREDENTIAL_TYPE).order_by(Credential.name) + ).all() + return [_snmp_profile_to_read(profile) for profile in profiles] + + +@router.post("/snmp", response_model=SnmpCredentialProfileRead) +def create_snmp_profile( + payload: SnmpCredentialProfileCreate, + _: User = Depends(require_role("admin")), + db: Session = Depends(get_db), +) -> SnmpCredentialProfileRead: + profile = Credential( + name=payload.name, + credential_type=SNMP_CREDENTIAL_TYPE, + encrypted_secret=encrypt_secret(payload.community), + extra={ + "version": payload.version, + "port": payload.port, + "timeout_seconds": payload.timeout_seconds, + "retries": payload.retries, + }, + ) + db.add(profile) + db.commit() + db.refresh(profile) + return _snmp_profile_to_read(profile) + + +@router.patch("/snmp/{profile_id}", response_model=SnmpCredentialProfileRead) +def update_snmp_profile( + profile_id: int, + payload: SnmpCredentialProfileUpdate, + _: User = Depends(require_role("admin")), + db: Session = Depends(get_db), +) -> SnmpCredentialProfileRead: + profile = db.get(Credential, profile_id) + if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE: + raise HTTPException(status_code=404, detail="SNMP credential profile not found") + + changes = payload.model_dump(exclude_unset=True) + community = changes.pop("community", None) + if community is not None: + profile.encrypted_secret = encrypt_secret(community) + if "name" in changes: + profile.name = changes.pop("name") + + next_extra = dict(profile.extra or {}) + for field, value in changes.items(): + next_extra[field] = value + profile.extra = next_extra + + db.commit() + db.refresh(profile) + return _snmp_profile_to_read(profile) + + +@router.delete("/snmp/{profile_id}", status_code=204) +def delete_snmp_profile( + profile_id: int, + _: User = Depends(require_role("admin")), + db: Session = Depends(get_db), +) -> None: + profile = db.get(Credential, profile_id) + if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE: + raise HTTPException(status_code=404, detail="SNMP credential profile not found") + db.delete(profile) + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 7aee9fa..581796d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.exc import SQLAlchemyError -from app.api import alerts, assets, auth, health, monitors, notifications +from app.api import alerts, assets, auth, credentials, health, monitors, notifications from app.core.config import settings from app.db.session import SessionLocal from app.services.bootstrap import ensure_initial_admin @@ -45,3 +45,4 @@ app.include_router(assets.router) app.include_router(monitors.router) app.include_router(alerts.router) app.include_router(notifications.router) +app.include_router(credentials.router) diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py index 1d22681..e68c60e 100644 --- a/backend/app/schemas/core.py +++ b/backend/app/schemas/core.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any +from typing import Literal from pydantic import BaseModel, Field @@ -179,3 +180,34 @@ class NotificationChannelRead(BaseModel): updated_at: datetime model_config = {"from_attributes": True} + + +class SnmpCredentialProfileCreate(BaseModel): + name: str = Field(min_length=1, max_length=160) + version: Literal["2c"] = "2c" + community: str = Field(min_length=1, max_length=255) + port: int = Field(default=161, ge=1, le=65535) + timeout_seconds: int = Field(default=5, ge=1, le=120) + retries: int = Field(default=1, ge=0, le=10) + + +class SnmpCredentialProfileUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=160) + version: Literal["2c"] | None = None + community: str | None = Field(default=None, min_length=1, max_length=255) + port: int | None = Field(default=None, ge=1, le=65535) + timeout_seconds: int | None = Field(default=None, ge=1, le=120) + retries: int | None = Field(default=None, ge=0, le=10) + + +class SnmpCredentialProfileRead(BaseModel): + id: int + name: str + credential_type: str + version: str + port: int + timeout_seconds: int + retries: int + has_secret: bool + created_at: datetime + updated_at: datetime diff --git a/backend/tests/test_credentials.py b/backend/tests/test_credentials.py new file mode 100644 index 0000000..801706e --- /dev/null +++ b/backend/tests/test_credentials.py @@ -0,0 +1,125 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.core.secrets import decrypt_secret +from app.models import Credential + + +def test_create_snmp_profile_encrypts_community_and_masks_secret(client: TestClient, db_session: Session) -> None: + response = client.post( + "/credentials/snmp", + json={ + "name": "Core Switch Read Only", + "version": "2c", + "community": "public-read", + "port": 161, + "timeout_seconds": 7, + "retries": 2, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "Core Switch Read Only" + assert body["credential_type"] == "snmp" + assert body["version"] == "2c" + assert body["port"] == 161 + assert body["timeout_seconds"] == 7 + assert body["retries"] == 2 + assert body["has_secret"] is True + assert "community" not in body + assert "encrypted_secret" not in body + + profile = db_session.get(Credential, body["id"]) + assert profile is not None + assert profile.credential_type == "snmp" + assert profile.encrypted_secret != "public-read" + assert decrypt_secret(profile.encrypted_secret) == "public-read" + assert profile.extra == {"version": "2c", "port": 161, "timeout_seconds": 7, "retries": 2} + + +def test_update_snmp_profile_without_community_preserves_saved_secret(client: TestClient, db_session: Session) -> None: + create_response = client.post( + "/credentials/snmp", + json={ + "name": "Access Switch", + "community": "access-read", + }, + ) + profile_id = create_response.json()["id"] + original_secret = db_session.get(Credential, profile_id).encrypted_secret + + update_response = client.patch( + f"/credentials/snmp/{profile_id}", + json={ + "name": "Access Switches", + "timeout_seconds": 9, + "retries": 3, + }, + ) + + assert update_response.status_code == 200 + body = update_response.json() + assert body["name"] == "Access Switches" + assert body["timeout_seconds"] == 9 + assert body["retries"] == 3 + assert body["has_secret"] is True + assert "community" not in body + assert "encrypted_secret" not in body + + profile = db_session.get(Credential, profile_id) + assert profile is not None + assert profile.encrypted_secret == original_secret + assert decrypt_secret(profile.encrypted_secret) == "access-read" + + +def test_update_snmp_profile_can_rotate_community(client: TestClient, db_session: Session) -> None: + create_response = client.post( + "/credentials/snmp", + json={ + "name": "Router", + "community": "old-community", + }, + ) + profile_id = create_response.json()["id"] + + update_response = client.patch( + f"/credentials/snmp/{profile_id}", + json={ + "community": "new-community", + }, + ) + + assert update_response.status_code == 200 + assert update_response.json()["has_secret"] is True + + profile = db_session.get(Credential, profile_id) + assert profile is not None + assert decrypt_secret(profile.encrypted_secret) == "new-community" + + +def test_list_and_delete_snmp_profiles(client: TestClient) -> None: + create_response = client.post( + "/credentials/snmp", + json={ + "name": "Distribution Switch", + "community": "dist-read", + }, + ) + profile_id = create_response.json()["id"] + + list_response = client.get("/credentials/snmp") + assert list_response.status_code == 200 + profiles = list_response.json() + assert len(profiles) == 1 + assert profiles[0]["id"] == profile_id + assert profiles[0]["has_secret"] is True + assert "community" not in profiles[0] + assert "encrypted_secret" not in profiles[0] + + delete_response = client.delete(f"/credentials/snmp/{profile_id}") + assert delete_response.status_code == 204 + + list_after_delete_response = client.get("/credentials/snmp") + assert list_after_delete_response.status_code == 200 + assert list_after_delete_response.json() == [] diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d26ce29..e92e8de 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -76,7 +76,7 @@ Issue source docs: - `docs/progress.md` - `docs/roadmap.md` -Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, and webhook notification channels. The next recommended implementation issue is SNMP credential profiles and guided SNMP discovery, followed by SNMP monitor selection. +Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, and SNMPv2c credential profiles. The next recommended implementation issue is SNMP device discovery API, followed by guided SNMP discovery UI and monitor selection. ## Guardrails diff --git a/docs/progress.md b/docs/progress.md index 860a6bc..1e3160f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -57,15 +57,23 @@ Implemented monitor and notification test coverage: - Notification channel tests verify saved webhook URLs are encrypted and are not returned by create, list, or update responses. - Worker scheduler tests cover alert threshold incident opening, recovery resolution, notification history deduplication, and alert cooldown behavior. +Implemented SNMP credential profile slice: + +- Backend API supports reusable SNMP credential profiles at `/credentials/snmp`. +- Initial profile support is SNMPv2c community credentials with port, timeout, and retry settings. +- Community strings are encrypted at rest and are not returned by create, list, or update responses. +- Credentials page can create, edit, rotate, and delete SNMP profiles. +- Backend tests cover SNMP profile secret masking, encryption, update preservation, rotation, listing, and deletion. + ## Known Gaps -- Credential vault UI and real credential encryption workflows are not complete. +- General credential vault workflows beyond SNMP profiles are not complete. - Audit logging tables exist, but events are not consistently written yet. - User management UI is not implemented. - Role management is basic and needs full admin flows. - Richer alert condition editing is not implemented yet. - Guided SNMP device discovery and friendly SNMP monitor selection are not implemented yet. -- SNMP credential profiles, interface status, traffic counters, errors, uptime, CPU, and memory checks are not implemented yet. +- SNMP interface status, traffic counters, errors, uptime, CPU, and memory checks are not implemented yet. - Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications. - Email/SMTP notifications are not implemented yet. - Graphing exists only as placeholders; metric visualization is not implemented. @@ -75,17 +83,18 @@ Implemented monitor and notification test coverage: ## Recommended Next Work -1. Add SNMP credential profiles and guided SNMP device discovery. -2. Add SNMP discovery selection UI to choose what to monitor and alert on. -3. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection. -4. Add notification policy/routing controls. -5. Add email/SMTP notification channel. -6. Add audit event writes for auth, monitor, credential, notification, and incident actions. -7. Build credential vault UI with masked secret handling. -8. Add user administration UI. -9. Add graphs for website response time and monitor status history. -10. Add richer alert condition editing. -11. Add frontend coverage for monitor, alert, and notification workflows. +1. Add SNMP device discovery API. +2. Add guided SNMP discovery UI. +3. Create monitors from SNMP discovery selections. +4. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection. +5. Add notification policy/routing controls. +6. Add email/SMTP notification channel. +7. Add audit event writes for auth, monitor, credential, notification, and incident actions. +8. Build general credential vault workflows with masked secret handling. +9. Add user administration UI. +10. Add graphs for website response time and monitor status history. +11. Add richer alert condition editing. +12. Add frontend coverage for monitor, alert, and notification workflows. ## Operational Notes diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a7c26af..3dc6969 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,6 +10,9 @@ import type { NotificationChannelCreate, NotificationChannelUpdate, PingMonitorCreate, + SnmpCredentialProfile, + SnmpCredentialProfileCreate, + SnmpCredentialProfileUpdate, TcpMonitorCreate, User, WebsiteMonitorCreate, @@ -121,4 +124,19 @@ export const api = { request(`/notifications/channels/${channelId}`, token, { method: "DELETE", }), + snmpCredentialProfiles: (token: string) => request("/credentials/snmp", token), + createSnmpCredentialProfile: (token: string, payload: SnmpCredentialProfileCreate) => + request("/credentials/snmp", token, { + method: "POST", + body: JSON.stringify(payload), + }), + updateSnmpCredentialProfile: (token: string, profileId: number, payload: SnmpCredentialProfileUpdate) => + request(`/credentials/snmp/${profileId}`, token, { + method: "PATCH", + body: JSON.stringify(payload), + }), + deleteSnmpCredentialProfile: (token: string, profileId: number) => + request(`/credentials/snmp/${profileId}`, token, { + method: "DELETE", + }), }; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1fea0a6..dcd5fb0 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -4,6 +4,7 @@ import { api } from "../api/client"; import { Shell, type PageId } from "../components/Shell"; import { useAuth } from "../hooks/useAuth"; import { AlertsPage } from "../pages/AlertsPage"; +import { CredentialsPage } from "../pages/CredentialsPage"; import { DashboardPage } from "../pages/DashboardPage"; import { ListPage } from "../pages/ListPage"; import { LoginPage } from "../pages/LoginPage"; @@ -88,7 +89,7 @@ export function App() { ) : null} {page === "discovery" ? : null} {page === "graphs" ? : null} - {page === "credentials" ? : null} + {page === "credentials" ? : null} {page === "notifications" ? : null} {page === "admin" ? : null} diff --git a/frontend/src/pages/CredentialsPage.tsx b/frontend/src/pages/CredentialsPage.tsx new file mode 100644 index 0000000..a4e3e96 --- /dev/null +++ b/frontend/src/pages/CredentialsPage.tsx @@ -0,0 +1,201 @@ +import { FormEvent, useEffect, useState } from "react"; +import { KeyRound, Pencil, Plus, RefreshCw, Trash2, X } from "lucide-react"; + +import { api } from "../api/client"; +import { Button } from "../components/Button"; +import type { SnmpCredentialProfile } from "../types/api"; + +interface CredentialsPageProps { + token: string; +} + +export function CredentialsPage({ token }: CredentialsPageProps) { + const [profiles, setProfiles] = useState([]); + const [name, setName] = useState(""); + const [community, setCommunity] = useState(""); + const [port, setPort] = useState(161); + const [timeoutSeconds, setTimeoutSeconds] = useState(5); + const [retries, setRetries] = useState(1); + const [editingProfileId, setEditingProfileId] = useState(null); + const [busyId, setBusyId] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [message, setMessage] = useState(null); + + async function refresh() { + setProfiles(await api.snmpCredentialProfiles(token)); + } + + useEffect(() => { + refresh().catch(() => setProfiles([])); + }, [token]); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setMessage(null); + try { + if (editingProfileId) { + await api.updateSnmpCredentialProfile(token, editingProfileId, { + name, + version: "2c", + community: community.trim() ? community : undefined, + port, + timeout_seconds: timeoutSeconds, + retries, + }); + } else { + await api.createSnmpCredentialProfile(token, { + name, + version: "2c", + community, + port, + timeout_seconds: timeoutSeconds, + retries, + }); + } + resetForm(); + await refresh(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Could not save SNMP profile"); + } finally { + setSubmitting(false); + } + } + + function startEdit(profile: SnmpCredentialProfile) { + setEditingProfileId(profile.id); + setName(profile.name); + setCommunity(""); + setPort(profile.port); + setTimeoutSeconds(profile.timeout_seconds); + setRetries(profile.retries); + setMessage(null); + } + + function resetForm() { + setEditingProfileId(null); + setName(""); + setCommunity(""); + setPort(161); + setTimeoutSeconds(5); + setRetries(1); + } + + async function deleteProfile(profileId: number) { + setBusyId(profileId); + setMessage(null); + try { + await api.deleteSnmpCredentialProfile(token, profileId); + await refresh(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Could not delete SNMP profile"); + } finally { + setBusyId(null); + } + } + + return ( +
+
+
+

Credentials

+

Reusable SNMP profiles for guided device discovery.

+
+ +
+ +
+
+
+ +

{editingProfileId ? "Edit SNMP Profile" : "Add SNMP Profile"}

+
+ + + + + + + +
+ + + +
+ + {message ?
{message}
: null} + +
+ {editingProfileId ? ( + + ) : null} + +
+
+ +
+
+

SNMP Profiles

+
+
+ {profiles.length ? ( + profiles.map((profile) => ( +
+
+
{profile.name}
+
SNMPv{profile.version} on port {profile.port}
+
{profile.has_secret ? "Community stored" : "No community stored"}
+
+ SNMP +
{profile.timeout_seconds}s timeout, {profile.retries} retries
+
+ + +
+
+ )) + ) : ( +
No SNMP credential profiles yet.
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 3484e64..e9919f2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -109,6 +109,37 @@ export interface NotificationChannelUpdate { is_enabled?: boolean; } +export interface SnmpCredentialProfile { + id: number; + name: string; + credential_type: string; + version: string; + port: number; + timeout_seconds: number; + retries: number; + has_secret: boolean; + created_at: string; + updated_at: string; +} + +export interface SnmpCredentialProfileCreate { + name: string; + version: "2c"; + community: string; + port: number; + timeout_seconds: number; + retries: number; +} + +export interface SnmpCredentialProfileUpdate { + name?: string; + version?: "2c"; + community?: string; + port?: number; + timeout_seconds?: number; + retries?: number; +} + export interface WebsiteMonitorCreate { name: string; url: string;