feat(17-01): add credential profile service, router, device assignment

- Service with CRUD + Transit encryption for all new credential writes
- Router with 6 endpoints under /tenants/{tenant_id}/credential-profiles
- Delete returns HTTP 409 with device_count when devices reference profile
- Registered credential_profiles_router in main.py
- DeviceUpdate schema accepts optional credential_profile_id
- update_device validates profile belongs to tenant before assigning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-21 18:54:02 -05:00
parent eb3ea0def3
commit 7354708df2
5 changed files with 498 additions and 0 deletions

View File

@@ -463,6 +463,7 @@ def create_app() -> FastAPI:
from app.routers.remote_access import router as remote_access_router
from app.routers.winbox_remote import router as winbox_remote_router
from app.routers.snmp_profiles import router as snmp_profiles_router
from app.routers.credential_profiles import router as credential_profiles_router
from app.routers.sites import router as sites_router
from app.routers.links import router as links_router
from app.routers.sectors import router as sectors_router
@@ -498,6 +499,7 @@ def create_app() -> FastAPI:
app.include_router(remote_access_router, prefix="/api")
app.include_router(winbox_remote_router, prefix="/api")
app.include_router(snmp_profiles_router, prefix="/api")
app.include_router(credential_profiles_router, prefix="/api")
app.include_router(sites_router, prefix="/api")
app.include_router(links_router, prefix="/api")
app.include_router(sectors_router, prefix="/api")

View File

@@ -0,0 +1,159 @@
"""
Credential profile management API endpoints.
Routes: /api/tenants/{tenant_id}/credential-profiles
RBAC:
- viewer: GET (read-only, via require_scope)
- operator: POST, PUT (write)
- tenant_admin/admin: DELETE
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.rbac import (
require_operator_or_above,
require_scope,
require_tenant_admin_or_above,
)
from app.middleware.tenant_context import CurrentUser, get_current_user
from app.routers.devices import _check_tenant_access
from app.schemas.credential_profile import (
CredentialProfileCreate,
CredentialProfileListResponse,
CredentialProfileResponse,
CredentialProfileUpdate,
)
from app.services import credential_profile_service
router = APIRouter(tags=["credential-profiles"])
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/credential-profiles",
response_model=CredentialProfileListResponse,
summary="List credential profiles",
dependencies=[require_scope("devices:read")],
)
async def list_profiles(
tenant_id: uuid.UUID,
credential_type: Optional[str] = Query(None, description="Filter by credential type"),
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> CredentialProfileListResponse:
"""List all credential profiles for a tenant. Viewer role and above."""
await _check_tenant_access(current_user, tenant_id, db)
return await credential_profile_service.get_profiles(
db=db, tenant_id=tenant_id, credential_type=credential_type
)
@router.post(
"/tenants/{tenant_id}/credential-profiles",
response_model=CredentialProfileResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a credential profile",
dependencies=[Depends(require_operator_or_above)],
)
async def create_profile(
tenant_id: uuid.UUID,
data: CredentialProfileCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> CredentialProfileResponse:
"""Create a new credential profile. Requires operator role or above."""
await _check_tenant_access(current_user, tenant_id, db)
return await credential_profile_service.create_profile(
db=db, tenant_id=tenant_id, data=data, user_id=current_user.user_id
)
@router.get(
"/tenants/{tenant_id}/credential-profiles/{profile_id}",
response_model=CredentialProfileResponse,
summary="Get credential profile details",
dependencies=[require_scope("devices:read")],
)
async def get_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> CredentialProfileResponse:
"""Get a single credential profile. Viewer role and above."""
await _check_tenant_access(current_user, tenant_id, db)
return await credential_profile_service.get_profile(
db=db, tenant_id=tenant_id, profile_id=profile_id
)
@router.put(
"/tenants/{tenant_id}/credential-profiles/{profile_id}",
response_model=CredentialProfileResponse,
summary="Update a credential profile",
dependencies=[Depends(require_operator_or_above)],
)
async def update_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
data: CredentialProfileUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> CredentialProfileResponse:
"""Update a credential profile. Requires operator role or above."""
await _check_tenant_access(current_user, tenant_id, db)
return await credential_profile_service.update_profile(
db=db, tenant_id=tenant_id, profile_id=profile_id, data=data,
user_id=current_user.user_id,
)
@router.delete(
"/tenants/{tenant_id}/credential-profiles/{profile_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a credential profile",
dependencies=[Depends(require_tenant_admin_or_above)],
)
async def delete_profile(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a credential profile. Requires tenant_admin or above.
Returns HTTP 409 if devices still reference this profile.
"""
await _check_tenant_access(current_user, tenant_id, db)
await credential_profile_service.delete_profile(
db=db, tenant_id=tenant_id, profile_id=profile_id,
user_id=current_user.user_id,
)
@router.get(
"/tenants/{tenant_id}/credential-profiles/{profile_id}/devices",
summary="List devices using this credential profile",
dependencies=[require_scope("devices:read")],
)
async def list_profile_devices(
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""List devices assigned to a credential profile. Viewer role and above."""
await _check_tenant_access(current_user, tenant_id, db)
return await credential_profile_service.get_profile_devices(
db=db, tenant_id=tenant_id, profile_id=profile_id
)

View File

@@ -35,6 +35,7 @@ class DeviceUpdate(BaseModel):
latitude: Optional[float] = None
longitude: Optional[float] = None
tls_mode: Optional[str] = None
credential_profile_id: Optional[uuid.UUID] = None
@field_validator("tls_mode")
@classmethod

View File

@@ -0,0 +1,319 @@
"""Credential profile service -- business logic for credential profile CRUD.
All functions operate via the app_user engine (RLS enforced).
Tenant isolation is handled automatically by PostgreSQL RLS policies.
Credential policy:
- New writes always use OpenBao Transit encryption (never legacy AES).
- Credential data (passwords, communities, passphrases) is NEVER returned.
- Updating credentials re-encrypts via Transit; linked devices pick up
new creds on their next poll cycle (no device-level update needed).
"""
import json
import uuid
import structlog
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.credential_profile import CredentialProfile
from app.models.device import Device
from app.schemas.credential_profile import (
CredentialProfileCreate,
CredentialProfileListResponse,
CredentialProfileResponse,
CredentialProfileUpdate,
)
from app.services import audit_service
from app.services.crypto import encrypt_credentials_transit
logger = structlog.get_logger("credential_profile_service")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_credential_json(data: CredentialProfileCreate | CredentialProfileUpdate) -> dict:
"""Build the credential JSON dict from schema fields based on credential_type."""
ct = data.credential_type
if ct == "routeros":
return {"type": "routeros", "username": data.username, "password": data.password}
elif ct == "snmp_v1":
return {"type": "snmp_v1", "community": data.community}
elif ct == "snmp_v2c":
return {"type": "snmp_v2c", "community": data.community}
elif ct == "snmp_v3":
cred: dict = {
"type": "snmp_v3",
"username": data.username,
"security_level": data.security_level,
}
if data.auth_protocol:
cred["auth_protocol"] = data.auth_protocol
if data.auth_passphrase:
cred["auth_passphrase"] = data.auth_passphrase
if data.priv_protocol:
cred["priv_protocol"] = data.priv_protocol
if data.priv_passphrase:
cred["priv_passphrase"] = data.priv_passphrase
return cred
else:
raise ValueError(f"Unknown credential_type: {ct}")
def _profile_response(profile: CredentialProfile, device_count: int = 0) -> CredentialProfileResponse:
"""Build a CredentialProfileResponse from an ORM instance."""
return CredentialProfileResponse(
id=profile.id,
name=profile.name,
description=profile.description,
credential_type=profile.credential_type,
device_count=device_count,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
async def _get_profile_or_404(
db: AsyncSession, tenant_id: uuid.UUID, profile_id: uuid.UUID
) -> CredentialProfile:
"""Fetch a credential profile by id and tenant, or raise 404."""
result = await db.execute(
select(CredentialProfile).where(
CredentialProfile.id == profile_id,
CredentialProfile.tenant_id == tenant_id,
)
)
profile = result.scalar_one_or_none()
if not profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credential profile not found",
)
return profile
async def _count_devices(db: AsyncSession, profile_id: uuid.UUID) -> int:
"""Count devices linked to a credential profile."""
result = await db.execute(
select(func.count(Device.id)).where(Device.credential_profile_id == profile_id)
)
return result.scalar() or 0
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
async def get_profiles(
db: AsyncSession,
tenant_id: uuid.UUID,
credential_type: str | None = None,
) -> CredentialProfileListResponse:
"""List all credential profiles for a tenant."""
query = select(CredentialProfile).where(
CredentialProfile.tenant_id == tenant_id
).order_by(CredentialProfile.name)
if credential_type:
query = query.where(CredentialProfile.credential_type == credential_type)
result = await db.execute(query)
profiles = list(result.scalars().all())
# Batch count devices per profile
profile_ids = [p.id for p in profiles]
device_counts: dict[uuid.UUID, int] = {}
if profile_ids:
count_result = await db.execute(
select(
Device.credential_profile_id,
func.count(Device.id).label("cnt"),
)
.where(Device.credential_profile_id.in_(profile_ids))
.group_by(Device.credential_profile_id)
)
for row in count_result:
device_counts[row.credential_profile_id] = row.cnt
responses = [
_profile_response(p, device_count=device_counts.get(p.id, 0))
for p in profiles
]
return CredentialProfileListResponse(profiles=responses)
async def get_profile(
db: AsyncSession, tenant_id: uuid.UUID, profile_id: uuid.UUID
) -> CredentialProfileResponse:
"""Fetch a single credential profile."""
profile = await _get_profile_or_404(db, tenant_id, profile_id)
dc = await _count_devices(db, profile_id)
return _profile_response(profile, device_count=dc)
async def create_profile(
db: AsyncSession,
tenant_id: uuid.UUID,
data: CredentialProfileCreate,
user_id: uuid.UUID,
) -> CredentialProfileResponse:
"""Create a new credential profile with Transit-encrypted credentials."""
# Build credential JSON and encrypt via OpenBao Transit
cred_json = _build_credential_json(data)
encrypted = await encrypt_credentials_transit(json.dumps(cred_json), str(tenant_id))
profile = CredentialProfile(
tenant_id=tenant_id,
name=data.name,
description=data.description,
credential_type=data.credential_type,
encrypted_credentials_transit=encrypted,
# Do NOT set encrypted_credentials (legacy) -- new writes always use Transit
)
db.add(profile)
await db.flush()
await db.refresh(profile)
await audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="credential_profile.create",
resource_type="credential_profile",
resource_id=str(profile.id),
details={"name": profile.name, "type": profile.credential_type},
)
return _profile_response(profile, device_count=0)
async def update_profile(
db: AsyncSession,
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
data: CredentialProfileUpdate,
user_id: uuid.UUID,
) -> CredentialProfileResponse:
"""Update a credential profile. Re-encrypts credentials if changed."""
profile = await _get_profile_or_404(db, tenant_id, profile_id)
# Update name/description if provided
if data.name is not None:
profile.name = data.name
if data.description is not None:
profile.description = data.description
# Determine if credential re-encryption is needed
cred_fields = {
"username", "password", "community",
"security_level", "auth_protocol", "auth_passphrase",
"priv_protocol", "priv_passphrase",
}
has_cred_changes = any(getattr(data, f) is not None for f in cred_fields)
type_changed = data.credential_type is not None
if type_changed or has_cred_changes:
# If type changed, use the new type; otherwise keep the existing one
if type_changed:
profile.credential_type = data.credential_type # type: ignore[assignment]
# Rebuild and re-encrypt credentials
cred_json = _build_credential_json(data if type_changed else _merge_update(data, profile))
encrypted = await encrypt_credentials_transit(json.dumps(cred_json), str(tenant_id))
profile.encrypted_credentials_transit = encrypted
profile.encrypted_credentials = None # Clear legacy
await db.flush()
await db.refresh(profile)
dc = await _count_devices(db, profile_id)
await audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="credential_profile.update",
resource_type="credential_profile",
resource_id=str(profile.id),
details={"name": profile.name, "updated_fields": list(data.model_dump(exclude_unset=True).keys())},
)
return _profile_response(profile, device_count=dc)
def _merge_update(data: CredentialProfileUpdate, profile: CredentialProfile) -> CredentialProfileUpdate:
"""For partial credential updates, overlay data onto existing profile type.
When credential_type is not changing but individual credential fields are,
we need to use the existing credential_type to build the JSON.
"""
# Create a new update object with the existing credential_type set
merged = data.model_copy()
object.__setattr__(merged, "credential_type", profile.credential_type)
return merged
async def delete_profile(
db: AsyncSession,
tenant_id: uuid.UUID,
profile_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
"""Delete a credential profile. Returns 409 if devices reference it."""
profile = await _get_profile_or_404(db, tenant_id, profile_id)
device_count = await _count_devices(db, profile_id)
if device_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"message": "Cannot delete: profile is assigned to devices",
"device_count": device_count,
},
)
profile_name = profile.name
await db.delete(profile)
await db.flush()
await audit_service.log_action(
db=db,
tenant_id=tenant_id,
user_id=user_id,
action="credential_profile.delete",
resource_type="credential_profile",
resource_id=str(profile_id),
details={"name": profile_name},
)
async def get_profile_devices(
db: AsyncSession, tenant_id: uuid.UUID, profile_id: uuid.UUID
) -> list[dict]:
"""Return list of devices using this credential profile."""
# Verify profile exists and belongs to tenant
await _get_profile_or_404(db, tenant_id, profile_id)
result = await db.execute(
select(
Device.id,
Device.hostname,
Device.ip_address,
Device.status,
).where(Device.credential_profile_id == profile_id)
)
return [
{
"id": str(row.id),
"hostname": row.hostname,
"ip_address": row.ip_address,
"status": row.status,
}
for row in result
]

View File

@@ -318,6 +318,23 @@ async def update_device(
if data.tls_mode is not None:
device.tls_mode = data.tls_mode
# Assign credential profile if provided
if data.credential_profile_id is not None:
from app.models.credential_profile import CredentialProfile
cp_result = await db.execute(
select(CredentialProfile).where(
CredentialProfile.id == data.credential_profile_id,
CredentialProfile.tenant_id == tenant_id,
)
)
if not cp_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Credential profile not found",
)
device.credential_profile_id = data.credential_profile_id
# Re-encrypt credentials if new ones are provided
credentials_changed = False
if data.password is not None: