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:
@@ -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")
|
||||
|
||||
159
backend/app/routers/credential_profiles.py
Normal file
159
backend/app/routers/credential_profiles.py
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
319
backend/app/services/credential_profile_service.py
Normal file
319
backend/app/services/credential_profile_service.py
Normal 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
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user