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.remote_access import router as remote_access_router
|
||||||
from app.routers.winbox_remote import router as winbox_remote_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.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.sites import router as sites_router
|
||||||
from app.routers.links import router as links_router
|
from app.routers.links import router as links_router
|
||||||
from app.routers.sectors import router as sectors_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(remote_access_router, prefix="/api")
|
||||||
app.include_router(winbox_remote_router, prefix="/api")
|
app.include_router(winbox_remote_router, prefix="/api")
|
||||||
app.include_router(snmp_profiles_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(sites_router, prefix="/api")
|
||||||
app.include_router(links_router, prefix="/api")
|
app.include_router(links_router, prefix="/api")
|
||||||
app.include_router(sectors_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
|
latitude: Optional[float] = None
|
||||||
longitude: Optional[float] = None
|
longitude: Optional[float] = None
|
||||||
tls_mode: Optional[str] = None
|
tls_mode: Optional[str] = None
|
||||||
|
credential_profile_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
@field_validator("tls_mode")
|
@field_validator("tls_mode")
|
||||||
@classmethod
|
@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:
|
if data.tls_mode is not None:
|
||||||
device.tls_mode = data.tls_mode
|
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
|
# Re-encrypt credentials if new ones are provided
|
||||||
credentials_changed = False
|
credentials_changed = False
|
||||||
if data.password is not None:
|
if data.password is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user