Add SNMP credential profiles

This commit is contained in:
Keith Smith
2026-05-23 20:11:09 -06:00
parent 19d4c6e603
commit 0cbc6b6ea8
10 changed files with 535 additions and 16 deletions
+101
View File
@@ -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()
+2 -1
View File
@@ -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)
+32
View File
@@ -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
+125
View File
@@ -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() == []
+1 -1
View File
@@ -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
+22 -13
View File
@@ -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
+18
View File
@@ -10,6 +10,9 @@ import type {
NotificationChannelCreate,
NotificationChannelUpdate,
PingMonitorCreate,
SnmpCredentialProfile,
SnmpCredentialProfileCreate,
SnmpCredentialProfileUpdate,
TcpMonitorCreate,
User,
WebsiteMonitorCreate,
@@ -121,4 +124,19 @@ export const api = {
request<void>(`/notifications/channels/${channelId}`, token, {
method: "DELETE",
}),
snmpCredentialProfiles: (token: string) => request<SnmpCredentialProfile[]>("/credentials/snmp", token),
createSnmpCredentialProfile: (token: string, payload: SnmpCredentialProfileCreate) =>
request<SnmpCredentialProfile>("/credentials/snmp", token, {
method: "POST",
body: JSON.stringify(payload),
}),
updateSnmpCredentialProfile: (token: string, profileId: number, payload: SnmpCredentialProfileUpdate) =>
request<SnmpCredentialProfile>(`/credentials/snmp/${profileId}`, token, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteSnmpCredentialProfile: (token: string, profileId: number) =>
request<void>(`/credentials/snmp/${profileId}`, token, {
method: "DELETE",
}),
};
+2 -1
View File
@@ -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" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
{page === "credentials" ? <ListPage title="Credentials" description="Encrypted reusable credentials with masked secrets." /> : null}
{page === "credentials" ? <CredentialsPage token={auth.token} /> : null}
{page === "notifications" ? <NotificationsPage token={auth.token} /> : null}
{page === "admin" ? <ListPage title="Admin" description="Users, roles, authentication settings, and global configuration." /> : null}
</Shell>
+201
View File
@@ -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<SnmpCredentialProfile[]>([]);
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<number | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(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 (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Credentials</h1>
<p className="mt-2 text-sm text-slate-400">Reusable SNMP profiles for guided device discovery.</p>
</div>
<Button variant="ghost" onClick={refresh}>
<RefreshCw size={16} />
Refresh
</Button>
</div>
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={submit}>
<div className="flex items-center gap-2">
<KeyRound size={18} className="text-pulse" />
<h2 className="text-base font-semibold">{editingProfileId ? "Edit SNMP Profile" : "Add SNMP Profile"}</h2>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Name</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Version</span>
<select className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value="2c" disabled>
<option value="2c">SNMPv2c</option>
</select>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Community</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={community} onChange={(event) => setCommunity(event.target.value)} required={!editingProfileId} type="password" />
{editingProfileId ? <span className="text-xs text-slate-500">Leave blank to keep the saved community string.</span> : null}
</label>
<div className="grid gap-3 sm:grid-cols-3">
<label className="block space-y-2">
<span className="text-sm text-slate-300">Port</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={port} onChange={(event) => setPort(Number(event.target.value))} min={1} max={65535} type="number" />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Timeout</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={timeoutSeconds} onChange={(event) => setTimeoutSeconds(Number(event.target.value))} min={1} max={120} type="number" />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Retries</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={retries} onChange={(event) => setRetries(Number(event.target.value))} min={0} max={10} type="number" />
</label>
</div>
{message ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">{message}</div> : null}
<div className="flex gap-2">
{editingProfileId ? (
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
<X size={16} />
Cancel
</Button>
) : null}
<Button className="flex-1" disabled={submitting} type="submit">
<Plus size={16} />
{submitting ? "Saving..." : editingProfileId ? "Save Profile" : "Create Profile"}
</Button>
</div>
</form>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">SNMP Profiles</h2>
</div>
<div className="divide-y divide-line">
{profiles.length ? (
profiles.map((profile) => (
<div key={profile.id} className="grid gap-3 p-4 md:grid-cols-[1fr_90px_160px_190px] md:items-center">
<div>
<div className="font-medium">{profile.name}</div>
<div className="text-sm text-slate-400">SNMPv{profile.version} on port {profile.port}</div>
<div className="text-xs text-slate-500">{profile.has_secret ? "Community stored" : "No community stored"}</div>
</div>
<span className="text-sm uppercase text-slate-400">SNMP</span>
<div className="text-sm text-slate-300">{profile.timeout_seconds}s timeout, {profile.retries} retries</div>
<div className="flex flex-wrap gap-2 md:justify-end">
<Button className="h-8 px-3" disabled={busyId === profile.id} onClick={() => startEdit(profile)} title="Edit profile" type="button" variant="ghost">
<Pencil className="text-slate-100" size={15} />
Edit
</Button>
<Button className="h-8 px-3 text-red-100" disabled={busyId === profile.id} onClick={() => deleteProfile(profile.id)} title="Delete profile" type="button" variant="ghost">
<Trash2 className="text-red-200" size={15} />
Delete
</Button>
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">No SNMP credential profiles yet.</div>
)}
</div>
</div>
</section>
</div>
);
}
+31
View File
@@ -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;