Add shared credential envelope infrastructure
This commit is contained in:
380
src/govoplan_core/server/credentials.py
Normal file
380
src/govoplan_core/server/credentials.py
Normal file
@@ -0,0 +1,380 @@
|
||||
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"]
|
||||
Reference in New Issue
Block a user