Files
the-other-dude/backend/app/routers/credential_profiles.py
Jason Staack 7354708df2 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>
2026-03-21 18:54:02 -05:00

160 lines
5.3 KiB
Python

"""
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
)