381 lines
12 KiB
Python
381 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field, SecretStr
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_core.security.credential_envelopes import (
|
|
CredentialEnvelope,
|
|
CredentialEnvelopeError,
|
|
create_credential_envelope,
|
|
credential_envelope_summary,
|
|
get_managed_credential_envelope,
|
|
list_managed_credential_envelopes,
|
|
normalize_credential_scope,
|
|
retire_credential_envelope,
|
|
update_credential_envelope,
|
|
)
|
|
|
|
|
|
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
|
|
|
|
|
|
class CredentialEnvelopeResponse(BaseModel):
|
|
id: str
|
|
tenant_id: str | None = None
|
|
scope_type: CredentialScopeType
|
|
scope_id: str | None = None
|
|
name: str
|
|
description: str | None = None
|
|
credential_kind: str
|
|
public_data: dict[str, Any] = Field(default_factory=dict)
|
|
secret_keys: list[str] = Field(default_factory=list)
|
|
secret_configured: bool = False
|
|
allowed_modules: list[str] = Field(default_factory=list)
|
|
allowed_server_refs: list[str] = Field(default_factory=list)
|
|
inherit_to_lower_scopes: bool = False
|
|
is_active: bool = True
|
|
revision: str
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
deleted_at: datetime | None = None
|
|
|
|
|
|
class CredentialEnvelopeListResponse(BaseModel):
|
|
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
|
|
|
|
|
|
class CredentialEnvelopeCreateRequest(BaseModel):
|
|
scope_type: CredentialScopeType = "tenant"
|
|
scope_id: str | None = Field(default=None, max_length=255)
|
|
name: str = Field(min_length=1, max_length=255)
|
|
description: str | None = None
|
|
credential_kind: str = Field(default="username_password", max_length=40)
|
|
public_data: dict[str, Any] = Field(default_factory=dict)
|
|
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
|
|
allowed_modules: list[str] = Field(default_factory=list)
|
|
allowed_server_refs: list[str] = Field(default_factory=list)
|
|
inherit_to_lower_scopes: bool = False
|
|
is_active: bool = True
|
|
|
|
|
|
class CredentialEnvelopeUpdateRequest(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
description: str | None = None
|
|
credential_kind: str | None = Field(default=None, max_length=40)
|
|
public_data: dict[str, Any] | None = None
|
|
secret_data: dict[str, SecretStr] | None = None
|
|
clear_secret: bool = False
|
|
allowed_modules: list[str] | None = None
|
|
allowed_server_refs: list[str] | None = None
|
|
inherit_to_lower_scopes: bool | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
router = APIRouter(prefix="/credentials", tags=["credentials"])
|
|
|
|
|
|
@router.get("", response_model=CredentialEnvelopeListResponse)
|
|
def list_credentials(
|
|
scope_type: CredentialScopeType = Query(default="tenant"),
|
|
scope_id: str | None = Query(default=None),
|
|
include_inactive: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
) -> CredentialEnvelopeListResponse:
|
|
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
|
|
principal,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
write=False,
|
|
)
|
|
rows = list_managed_credential_envelopes(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
include_inactive=include_inactive,
|
|
)
|
|
return CredentialEnvelopeListResponse(
|
|
credentials=[
|
|
_response(row)
|
|
for row in rows
|
|
if row.scope_type == normalized_scope_type
|
|
and row.scope_id == normalized_scope_id
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=CredentialEnvelopeResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_credential(
|
|
payload: CredentialEnvelopeCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
) -> CredentialEnvelopeResponse:
|
|
tenant_id, scope_type, scope_id = _target_scope(
|
|
principal,
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
write=True,
|
|
)
|
|
try:
|
|
row = create_credential_envelope(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
credential_kind=payload.credential_kind,
|
|
public_data=payload.public_data,
|
|
secret_data=_secret_values(payload.secret_data),
|
|
allowed_modules=payload.allowed_modules,
|
|
allowed_server_refs=payload.allowed_server_refs,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
is_active=payload.is_active,
|
|
user_id=principal.membership_id,
|
|
metadata={"created_by_module": "core"},
|
|
)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return _response(row)
|
|
except CredentialEnvelopeError as exc:
|
|
session.rollback()
|
|
raise _credential_error(exc) from exc
|
|
|
|
|
|
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
|
def update_credential(
|
|
credential_id: str,
|
|
payload: CredentialEnvelopeUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
) -> CredentialEnvelopeResponse:
|
|
try:
|
|
row = _managed_row_for_write(session, principal, credential_id)
|
|
row = update_credential_envelope(
|
|
session,
|
|
row,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
description_supplied="description" in payload.model_fields_set,
|
|
credential_kind=payload.credential_kind,
|
|
public_data=payload.public_data,
|
|
secret_data=(
|
|
_secret_values(payload.secret_data)
|
|
if payload.secret_data is not None
|
|
else None
|
|
),
|
|
clear_secret=payload.clear_secret,
|
|
allowed_modules=payload.allowed_modules,
|
|
allowed_server_refs=payload.allowed_server_refs,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
is_active=payload.is_active,
|
|
user_id=principal.membership_id,
|
|
)
|
|
session.commit()
|
|
session.refresh(row)
|
|
return _response(row)
|
|
except CredentialEnvelopeError as exc:
|
|
session.rollback()
|
|
raise _credential_error(exc) from exc
|
|
|
|
|
|
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
|
def delete_credential(
|
|
credential_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
) -> CredentialEnvelopeResponse:
|
|
try:
|
|
row = _managed_row_for_write(session, principal, credential_id)
|
|
retire_credential_envelope(
|
|
session,
|
|
row,
|
|
user_id=principal.membership_id,
|
|
)
|
|
response = _response(row)
|
|
session.commit()
|
|
return response
|
|
except CredentialEnvelopeError as exc:
|
|
session.rollback()
|
|
raise _credential_error(exc) from exc
|
|
|
|
|
|
def _managed_row_for_write(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
credential_id: str,
|
|
) -> CredentialEnvelope:
|
|
system_row = None
|
|
if _can_manage_system_credentials(principal):
|
|
try:
|
|
system_row = get_managed_credential_envelope(
|
|
session,
|
|
credential_id=credential_id,
|
|
tenant_id=None,
|
|
for_update=True,
|
|
)
|
|
except CredentialEnvelopeError:
|
|
pass
|
|
row = system_row or get_managed_credential_envelope(
|
|
session,
|
|
credential_id=credential_id,
|
|
tenant_id=principal.tenant_id,
|
|
for_update=True,
|
|
)
|
|
_target_scope(
|
|
principal,
|
|
scope_type=row.scope_type,
|
|
scope_id=row.scope_id,
|
|
write=True,
|
|
)
|
|
return row
|
|
|
|
|
|
def _target_scope(
|
|
principal: ApiPrincipal,
|
|
*,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
write: bool,
|
|
) -> tuple[str | None, str, str | None]:
|
|
requested_type = str(scope_type or "tenant").strip().casefold()
|
|
requested_id = str(scope_id).strip() if scope_id else None
|
|
if requested_type == "system":
|
|
if not (
|
|
_can_manage_system_credentials(principal)
|
|
if write
|
|
else _can_read_system_credentials(principal)
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="System credential permission is required.",
|
|
)
|
|
return _normalize_target_scope(
|
|
tenant_id=None,
|
|
scope_type="system",
|
|
scope_id=None,
|
|
)
|
|
|
|
if requested_type == "user" and requested_id == principal.membership_id:
|
|
own_scope = (
|
|
has_scope(principal, "access:credential:manage_own")
|
|
or has_scope(principal, "mail:secret:manage_own")
|
|
)
|
|
if own_scope:
|
|
return _normalize_target_scope(
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=requested_type,
|
|
scope_id=requested_id,
|
|
)
|
|
|
|
allowed = (
|
|
_can_manage_tenant_credentials(principal)
|
|
if write
|
|
else _can_read_tenant_credentials(principal)
|
|
)
|
|
if not allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Tenant credential permission is required.",
|
|
)
|
|
return _normalize_target_scope(
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=requested_type,
|
|
scope_id=requested_id,
|
|
)
|
|
|
|
|
|
def _normalize_target_scope(
|
|
*,
|
|
tenant_id: str | None,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
) -> tuple[str | None, str, str | None]:
|
|
try:
|
|
return normalize_credential_scope(
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise _credential_error(exc) from exc
|
|
|
|
|
|
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
|
|
return any(
|
|
has_scope(principal, scope)
|
|
for scope in (
|
|
"access:system_credential:read",
|
|
"access:system_setting:read",
|
|
"system:settings:read",
|
|
)
|
|
)
|
|
|
|
|
|
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
|
|
return any(
|
|
has_scope(principal, scope)
|
|
for scope in (
|
|
"access:system_credential:write",
|
|
"access:system_setting:write",
|
|
"system:settings:write",
|
|
)
|
|
)
|
|
|
|
|
|
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
|
|
return any(
|
|
has_scope(principal, scope)
|
|
for scope in (
|
|
"access:credential:read",
|
|
"access:setting:read",
|
|
"admin:settings:read",
|
|
"mail_servers:read",
|
|
)
|
|
)
|
|
|
|
|
|
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
|
|
return any(
|
|
has_scope(principal, scope)
|
|
for scope in (
|
|
"access:credential:write",
|
|
"access:setting:write",
|
|
"admin:settings:write",
|
|
"mail_servers:manage_credentials",
|
|
)
|
|
)
|
|
|
|
|
|
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
|
|
return {
|
|
str(key): value.get_secret_value()
|
|
for key, value in values.items()
|
|
if str(key).strip() and value.get_secret_value()
|
|
}
|
|
|
|
|
|
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
|
|
return CredentialEnvelopeResponse.model_validate(
|
|
credential_envelope_summary(row)
|
|
)
|
|
|
|
|
|
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(exc),
|
|
)
|
|
|
|
|
|
__all__ = ["router"]
|