Files
govoplan-core/src/govoplan_core/server/credentials.py

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