diff --git a/backend/app/main.py b/backend/app/main.py index 94566df..32c42b8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/credential_profiles.py b/backend/app/routers/credential_profiles.py new file mode 100644 index 0000000..0342fbc --- /dev/null +++ b/backend/app/routers/credential_profiles.py @@ -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 + ) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index 1455559..6c04c49 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -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 diff --git a/backend/app/services/credential_profile_service.py b/backend/app/services/credential_profile_service.py new file mode 100644 index 0000000..a55144c --- /dev/null +++ b/backend/app/services/credential_profile_service.py @@ -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 + ] diff --git a/backend/app/services/device.py b/backend/app/services/device.py index 76dea39..d5d7cb3 100644 --- a/backend/app/services/device.py +++ b/backend/app/services/device.py @@ -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: