From ef3cc31cdb75d7f38da63998df7407380dc734e7 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Sun, 23 Aug 2026 00:43:08 +0200 Subject: [PATCH] feat(api): isolate structured managed secrets --- api/oss/src/apis/fastapi/vault/router.py | 20 ++- api/oss/src/core/secrets/dtos.py | 14 +- api/oss/src/core/secrets/interfaces.py | 3 + api/oss/src/core/secrets/managed.py | 32 ++++ api/oss/src/core/secrets/redaction.py | 9 +- api/oss/src/core/secrets/services.py | 62 ++++++- api/oss/src/dbs/postgres/secrets/dao.py | 17 +- api/oss/src/dbs/postgres/secrets/mappings.py | 26 ++- .../unit/secrets/test_managed_secrets.py | 156 ++++++++++++++++++ .../pytest/unit/vault/test_managed_routes.py | 111 +++++++++++++ docs/design/managed-secrets/README.md | 31 ++++ 11 files changed, 460 insertions(+), 21 deletions(-) create mode 100644 api/oss/src/core/secrets/managed.py create mode 100644 api/oss/tests/pytest/unit/secrets/test_managed_secrets.py create mode 100644 api/oss/tests/pytest/unit/vault/test_managed_routes.py create mode 100644 docs/design/managed-secrets/README.md diff --git a/api/oss/src/apis/fastapi/vault/router.py b/api/oss/src/apis/fastapi/vault/router.py index be759aef27c..cf82b269e7e 100644 --- a/api/oss/src/apis/fastapi/vault/router.py +++ b/api/oss/src/apis/fastapi/vault/router.py @@ -17,6 +17,7 @@ SecretResponseDTO, PublicSecretResponseDTO, ) +from oss.src.core.secrets.managed import ManagedSecretReadOnlyError from oss.src.core.secrets.redaction import project_secret_response from oss.src.core.access.permissions.types import Permission @@ -233,6 +234,12 @@ async def update_secret( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=e.message ) from e + except ManagedSecretReadOnlyError as e: + # 409, not 400: the payload is well-formed; the stored row's managed state is + # what forbids the change. + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=e.message + ) from e if secrets_dto is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found" @@ -254,8 +261,13 @@ async def delete_secret(self, request: Request, secret_id: str): status_code=403, ) - await self.service.delete_secret( - project_id=UUID(request.state.project_id), - secret_id=UUID(secret_id), - ) + try: + await self.service.delete_secret( + project_id=UUID(request.state.project_id), + secret_id=UUID(secret_id), + ) + except ManagedSecretReadOnlyError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=e.message + ) from e return status.HTTP_204_NO_CONTENT diff --git a/api/oss/src/core/secrets/dtos.py b/api/oss/src/core/secrets/dtos.py index 5f26183303f..9d0b69a526b 100644 --- a/api/oss/src/core/secrets/dtos.py +++ b/api/oss/src/core/secrets/dtos.py @@ -1,6 +1,11 @@ from typing import Optional, Union, List, Dict, Any -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from oss.src.core.secrets.managed import ( + PublicSecretManagementDTO, + SecretManagementDTO, +) from oss.src.core.secrets.enums import ( SecretKind, @@ -258,6 +263,8 @@ def validate_secret_data_based_on_kind(cls, values: Dict[str, Any]): class CreateSecretDTO(Slug, BaseModel): + model_config = ConfigDict(extra="forbid") + header: Header secret: SecretDTO write_only: bool = True @@ -319,6 +326,8 @@ def validate_secret_data_based_on_kind(cls, values: Dict[str, Any]): class UpdateSecretDTO(BaseModel): + model_config = ConfigDict(extra="forbid") + header: Optional[Header] = None secret: Optional[UpdateSecretPayloadDTO] = None @@ -375,8 +384,11 @@ def build_up_model_keys(self): class SecretResponseDTO(_SecretResponseBaseDTO): """Trusted internal representation. Credential material remains available.""" + management: Optional[SecretManagementDTO] = None + class PublicSecretResponseDTO(_SecretResponseBaseDTO): """Caller-facing representation after grant-aware value projection.""" + management: Optional[PublicSecretManagementDTO] = None value_status: SecretValueStatus diff --git a/api/oss/src/core/secrets/interfaces.py b/api/oss/src/core/secrets/interfaces.py index 0b848836496..1b5e488b1ac 100644 --- a/api/oss/src/core/secrets/interfaces.py +++ b/api/oss/src/core/secrets/interfaces.py @@ -6,6 +6,7 @@ UpdateSecretDTO, SecretResponseDTO, ) +from oss.src.core.secrets.managed import SecretManagementDTO class SecretsDAOInterface: @@ -18,6 +19,7 @@ async def create( project_id: Optional[UUID] = None, organization_id: Optional[UUID] = None, create_secret_dto: CreateSecretDTO, + management: Optional[SecretManagementDTO] = None, ) -> SecretResponseDTO: raise NotImplementedError @@ -64,5 +66,6 @@ async def delete( secret_id: UUID, project_id: Optional[UUID] = None, organization_id: Optional[UUID] = None, + authorize_delete: Optional[Callable[[SecretResponseDTO], None]] = None, ) -> None: raise NotImplementedError diff --git a/api/oss/src/core/secrets/managed.py b/api/oss/src/core/secrets/managed.py new file mode 100644 index 00000000000..2dc7a28fc43 --- /dev/null +++ b/api/oss/src/core/secrets/managed.py @@ -0,0 +1,32 @@ +from enum import Enum + +from pydantic import BaseModel, ConfigDict + + +class SecretManager(str, Enum): + STARTER_CREDITS_BRIDGE = "starter-credits-bridge" + + +class SecretManagementPolicy(str, Enum): + MANAGER_ONLY = "manager_only" + + +class SecretManagementDTO(BaseModel): + model_config = ConfigDict(extra="forbid") + + manager: SecretManager + policy: SecretManagementPolicy = SecretManagementPolicy.MANAGER_ONLY + + +class PublicSecretManagementDTO(BaseModel): + model_config = ConfigDict(extra="forbid") + + policy: SecretManagementPolicy + + +class ManagedSecretReadOnlyError(Exception): + def __init__(self): + self.message = ( + "This secret is managed by Agenta and cannot be changed or deleted." + ) + super().__init__(self.message) diff --git a/api/oss/src/core/secrets/redaction.py b/api/oss/src/core/secrets/redaction.py index 4f3fdebcd4d..94c06b4cc36 100644 --- a/api/oss/src/core/secrets/redaction.py +++ b/api/oss/src/core/secrets/redaction.py @@ -94,11 +94,12 @@ def project_secret_response( reveal_write_only: bool, ) -> PublicSecretResponseDTO: """Build the public response, optionally retaining a write-only value for runtime.""" + public_data = secret.model_dump(mode="python", exclude={"management"}) + if secret.management is not None: + public_data["management"] = {"policy": secret.management.policy} + projected = PublicSecretResponseDTO.model_validate( - { - **secret.model_dump(mode="python"), - "value_status": _value_status(secret), - } + {**public_data, "value_status": _value_status(secret)} ) if not secret.write_only or reveal_write_only: diff --git a/api/oss/src/core/secrets/services.py b/api/oss/src/core/secrets/services.py index f4babb4f829..2876986ec5c 100644 --- a/api/oss/src/core/secrets/services.py +++ b/api/oss/src/core/secrets/services.py @@ -26,6 +26,11 @@ UpdateSecretDTO, ) +from oss.src.core.secrets.managed import ( + ManagedSecretReadOnlyError, + SecretManagementDTO, +) + _BLANK_CREDENTIAL_VALUE_MESSAGE = ( "Credential values cannot be blank. Omit an unchanged credential field or provide a new " @@ -197,6 +202,9 @@ def _resolve_update( another kind's or another provider's credential — and that decision reads the same stored row, so it belongs under the same lock. """ + if stored_secret_dto.management is not None: + raise ManagedSecretReadOnlyError() + resolved_update = requested_update.model_copy(deep=True) if resolved_update.secret is None: return UpdateSecretDTO.model_validate(resolved_update.model_dump(mode="python")) @@ -234,6 +242,11 @@ def _resolve_update( return UpdateSecretDTO.model_validate(resolved_update.model_dump(mode="python")) +def _authorize_delete(stored_secret_dto: SecretResponseDTO) -> None: + if stored_secret_dto.management is not None: + raise ManagedSecretReadOnlyError() + + def _carry_over_saved_policy(*, stored_data: Any, update_data: Any) -> None: """Fill an update payload's omitted ``models``/``harnesses`` from the stored record. @@ -262,6 +275,36 @@ async def create_secret( project_id: UUID | None = None, organization_id: UUID | None = None, create_secret_dto: CreateSecretDTO, + ): + return await self._create_secret( + project_id=project_id, + organization_id=organization_id, + create_secret_dto=create_secret_dto, + management=None, + ) + + async def create_managed_secret( + self, + *, + project_id: UUID | None = None, + organization_id: UUID | None = None, + create_secret_dto: CreateSecretDTO, + management: SecretManagementDTO, + ): + return await self._create_secret( + project_id=project_id, + organization_id=organization_id, + create_secret_dto=create_secret_dto, + management=management, + ) + + async def _create_secret( + self, + *, + project_id: UUID | None = None, + organization_id: UUID | None = None, + create_secret_dto: CreateSecretDTO, + management: SecretManagementDTO | None, ): # custom_secret and custom_provider are addressed by slug; derive one from the name when # absent so the record keeps its identity when the display name later changes. @@ -287,11 +330,19 @@ async def create_secret( with set_data_encryption_key( data_encryption_key=self._data_encryption_key, ): - secret_dto = await self.secrets_dao.create( - project_id=project_id, - organization_id=organization_id, - create_secret_dto=create_secret_dto, - ) + if management is None: + secret_dto = await self.secrets_dao.create( + project_id=project_id, + organization_id=organization_id, + create_secret_dto=create_secret_dto, + ) + else: + secret_dto = await self.secrets_dao.create( + project_id=project_id, + organization_id=organization_id, + create_secret_dto=create_secret_dto, + management=management, + ) if project_id is not None: await invalidate_cache(project_id=str(project_id)) @@ -436,6 +487,7 @@ async def delete_secret( secret_id=secret_id, project_id=project_id, organization_id=organization_id, + authorize_delete=_authorize_delete, ) if project_id is not None: diff --git a/api/oss/src/dbs/postgres/secrets/dao.py b/api/oss/src/dbs/postgres/secrets/dao.py index 556177a5987..987c2282a52 100644 --- a/api/oss/src/dbs/postgres/secrets/dao.py +++ b/api/oss/src/dbs/postgres/secrets/dao.py @@ -3,6 +3,7 @@ from oss.src.dbs.postgres.secrets.dbes import SecretsDBE from oss.src.core.secrets.interfaces import SecretsDAOInterface +from oss.src.core.secrets.managed import SecretManagementDTO from oss.src.dbs.postgres.shared.engine import ( TransactionsEngine, @@ -50,12 +51,14 @@ async def create( project_id: UUID | None, organization_id: UUID | None, create_secret_dto: CreateSecretDTO, + management: SecretManagementDTO | None = None, ): self._validate_scope(project_id, organization_id) secrets_dbe = map_secrets_dto_to_dbe( project_id=project_id, organization_id=organization_id, secret_dto=create_secret_dto, + management=management, ) async with self.engine.session() as session: session.add(secrets_dbe) @@ -174,17 +177,25 @@ async def delete( secret_id: UUID, project_id: UUID | None, organization_id: UUID | None, + authorize_delete: Optional[Callable[[SecretResponseDTO], None]] = None, ): async with self.engine.session() as session: scope_filter = self._scope_filter(project_id, organization_id) - stmt = select(SecretsDBE).filter_by( - id=secret_id, - **scope_filter, + stmt = ( + select(SecretsDBE) + .filter_by( + id=secret_id, + **scope_filter, + ) + .with_for_update() ) result = await session.execute(stmt) # type: ignore vault_secret_dbe = result.scalar() if vault_secret_dbe is None: return + if authorize_delete is not None: + authorize_delete(map_secrets_dbe_to_dto(secrets_dbe=vault_secret_dbe)) + await session.delete(vault_secret_dbe) await session.commit() diff --git a/api/oss/src/dbs/postgres/secrets/mappings.py b/api/oss/src/dbs/postgres/secrets/mappings.py index b23d19ef1be..fbfa8edb83b 100644 --- a/api/oss/src/dbs/postgres/secrets/mappings.py +++ b/api/oss/src/dbs/postgres/secrets/mappings.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from oss.src.dbs.postgres.secrets.dbes import SecretsDBE +from oss.src.core.secrets.managed import SecretManagementDTO from oss.src.core.secrets.dtos import ( Header, SecretKind, @@ -13,23 +14,28 @@ ) -# The server-controlled write_only attribute rides inside the encrypted `data` JSON, -# as a sibling of the payload fields, so no schema migration is needed. It is popped back -# out in `map_secrets_dbe_to_dto`, so payload DTOs never see it; rows without the key -# read as write_only=False (legacy rows). +# Server-controlled metadata rides inside encrypted JSON, so no schema migration is needed. +# Rows without the keys read as write_only=False and management=None. _WRITE_ONLY_KEY = "write_only" +_MANAGEMENT_KEY = "management" def _data_payload( data_json: dict, *, write_only: bool, + management: SecretManagementDTO | None = None, ) -> str: if write_only: data_json[_WRITE_ONLY_KEY] = True else: data_json.pop(_WRITE_ONLY_KEY, None) + if management is not None: + data_json[_MANAGEMENT_KEY] = management.model_dump(mode="json") + else: + data_json.pop(_MANAGEMENT_KEY, None) + return json.dumps(data_json) @@ -38,6 +44,7 @@ def map_secrets_dto_to_dbe( project_id: uuid.UUID | None, organization_id: uuid.UUID | None, secret_dto: CreateSecretDTO, + management: SecretManagementDTO | None = None, ) -> SecretsDBE: vault_secret_dbe = SecretsDBE( slug=secret_dto.slug, @@ -49,6 +56,7 @@ def map_secrets_dto_to_dbe( data=_data_payload( secret_dto.secret.data.model_dump(exclude_none=True), write_only=bool(secret_dto.write_only), + management=management, ), ) return vault_secret_dbe @@ -72,6 +80,13 @@ def map_secrets_dto_to_dbe_update( stored_data = json.loads(secrets_dbe.data) write_only = bool(stored_data.get(_WRITE_ONLY_KEY)) + management_data = stored_data.get(_MANAGEMENT_KEY) + management = ( + SecretManagementDTO.model_validate(management_data) + if management_data is not None + else None + ) + if update_secret_dto.secret: for key, value in update_secret_dto.secret.model_dump( exclude_none=True @@ -80,6 +95,7 @@ def map_secrets_dto_to_dbe_update( secrets_dbe.data = _data_payload( update_secret_dto.secret.data.model_dump(), write_only=write_only, + management=management, ) elif hasattr(secrets_dbe, key): setattr(secrets_dbe, key, value) @@ -88,6 +104,7 @@ def map_secrets_dto_to_dbe_update( def map_secrets_dbe_to_dto(*, secrets_dbe: SecretsDBE) -> SecretResponseDTO: data = json.loads(secrets_dbe.data) # type: ignore write_only = bool(data.pop(_WRITE_ONLY_KEY, False)) + management = data.pop(_MANAGEMENT_KEY, None) vault_secret_dto = SecretResponseDTO( id=secrets_dbe.id, # type: ignore @@ -100,6 +117,7 @@ def map_secrets_dbe_to_dto(*, secrets_dbe: SecretsDBE) -> SecretResponseDTO: updated_at=str(secrets_dbe.updated_at), ), write_only=write_only, + management=management, ) return vault_secret_dto diff --git a/api/oss/tests/pytest/unit/secrets/test_managed_secrets.py b/api/oss/tests/pytest/unit/secrets/test_managed_secrets.py new file mode 100644 index 00000000000..61300647be5 --- /dev/null +++ b/api/oss/tests/pytest/unit/secrets/test_managed_secrets.py @@ -0,0 +1,156 @@ +from uuid import uuid4 +import json + +import pytest +from pydantic import ValidationError + +from oss.src.core.secrets.dtos import ( + CreateSecretDTO, + SecretResponseDTO, + UpdateSecretDTO, +) +from oss.src.core.secrets.managed import ( + ManagedSecretReadOnlyError, + SecretManagementDTO, + SecretManagementPolicy, + SecretManager, +) +from oss.src.core.secrets.redaction import project_secret_response +from oss.src.core.secrets.services import VaultService +from oss.src.dbs.postgres.secrets.mappings import ( + map_secrets_dbe_to_dto, + map_secrets_dto_to_dbe, +) + + +PROJECT_ID = uuid4() + + +def _create(*, write_only=False): + return CreateSecretDTO( + header={"name": "Managed"}, + secret={ + "kind": "provider_key", + "data": {"kind": "openai", "provider": {"key": "sk-managed"}}, + }, + write_only=write_only, + ) + + +def _management(): + return SecretManagementDTO(manager=SecretManager.STARTER_CREDITS_BRIDGE) + + +class _DAO: + def __init__(self): + self.record = None + + async def create( + self, project_id, organization_id, create_secret_dto, management=None + ): + self.record = SecretResponseDTO( + id=uuid4(), + slug=create_secret_dto.slug, + kind=create_secret_dto.secret.kind, + data=create_secret_dto.secret.data, + header=create_secret_dto.header, + write_only=create_secret_dto.write_only, + management=management, + ) + return self.record + + async def list(self, project_id, organization_id): + return [self.record] if self.record else [] + + async def update( + self, + secret_id, + update_secret_dto, + project_id, + organization_id, + user_id=None, + resolve_update=None, + ): + if resolve_update: + update_secret_dto = resolve_update(self.record, update_secret_dto) + return self.record + + async def delete( + self, secret_id, project_id, organization_id, authorize_delete=None + ): + if authorize_delete: + authorize_delete(self.record) + self.record = None + + +def test_management_models_are_exact(): + management = _management() + assert management.manager is SecretManager.STARTER_CREDITS_BRIDGE + assert management.policy is SecretManagementPolicy.MANAGER_ONLY + + +@pytest.mark.parametrize("dto", [CreateSecretDTO, UpdateSecretDTO]) +def test_public_write_models_reject_management_fields(dto): + payload = {"management": {"manager": "starter-credits-bridge"}} + if dto is CreateSecretDTO: + payload.update(_create().model_dump()) + with pytest.raises(ValidationError): + dto.model_validate(payload) + + +@pytest.mark.asyncio +async def test_readable_managed_secret_is_allowed_and_locked(): + service = VaultService(_DAO()) + created = await service.create_managed_secret( + project_id=PROJECT_ID, + create_secret_dto=_create(write_only=False), + management=_management(), + ) + assert created.write_only is False + assert created.management == _management() + + with pytest.raises(ManagedSecretReadOnlyError): + await service.update_secret( + created.id, + UpdateSecretDTO(header={"name": "No"}), + project_id=PROJECT_ID, + ) + with pytest.raises(ManagedSecretReadOnlyError): + await service.delete_secret(created.id, project_id=PROJECT_ID) + + +def test_public_projection_exposes_policy_but_not_manager(): + secret = SecretResponseDTO( + id=uuid4(), + slug="managed", + kind="provider_key", + data={"kind": "openai", "provider": {"key": "sk-managed"}}, + header={"name": "Managed"}, + write_only=False, + management=_management(), + ) + public = project_secret_response(secret, reveal_write_only=True) + dumped = public.model_dump(mode="json", exclude_none=True) + assert dumped["management"] == {"policy": "manager_only"} + assert "manager" not in dumped["management"] + + +def test_mapping_round_trip_uses_structured_management_without_flat_fallback(): + dbe = map_secrets_dto_to_dbe( + project_id=PROJECT_ID, + organization_id=None, + secret_dto=_create(write_only=False), + management=_management(), + ) + assert '"management"' in dbe.data + assert '"managed_by"' not in dbe.data + restored = map_secrets_dbe_to_dto(secrets_dbe=dbe) + assert restored.management == _management() + assert restored.write_only is False + + payload = json.loads(dbe.data) + payload.pop("management") + payload["managed_by"] = "starter-credits-bridge" + dbe.data = json.dumps(payload) + legacy = map_secrets_dbe_to_dto(secrets_dbe=dbe) + assert legacy.management is None diff --git a/api/oss/tests/pytest/unit/vault/test_managed_routes.py b/api/oss/tests/pytest/unit/vault/test_managed_routes.py new file mode 100644 index 00000000000..0fcdfc6b510 --- /dev/null +++ b/api/oss/tests/pytest/unit/vault/test_managed_routes.py @@ -0,0 +1,111 @@ +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from oss.src.apis.fastapi.vault import router as router_module +from oss.src.apis.fastapi.vault.router import VaultRouter +from oss.src.core.secrets.dtos import SecretResponseDTO +from oss.src.core.secrets.managed import SecretManagementDTO, SecretManager +from oss.src.core.secrets.services import VaultService + + +PROJECT_ID = str(uuid4()) +USER_ID = str(uuid4()) + + +class _DAO: + def __init__(self): + self.record = SecretResponseDTO( + id=uuid4(), + slug="managed", + kind="provider_key", + data={"kind": "openai", "provider": {"key": "sk-managed"}}, + header={"name": "Managed"}, + write_only=False, + management=SecretManagementDTO( + manager=SecretManager.STARTER_CREDITS_BRIDGE + ), + ) + + async def list(self, project_id, organization_id): + return [self.record] + + async def get_by_id(self, secret_id, project_id, organization_id): + return self.record if secret_id == self.record.id else None + + async def get_by_slug(self, secret_slug, project_id, organization_id): + return self.record if secret_slug == self.record.slug else None + + async def update( + self, + secret_id, + update_secret_dto, + project_id, + organization_id, + user_id=None, + resolve_update=None, + ): + if resolve_update: + resolve_update(self.record, update_secret_dto) + return self.record + + async def delete( + self, secret_id, project_id, organization_id, authorize_delete=None + ): + if authorize_delete: + authorize_delete(self.record) + + +@pytest.fixture +def client(monkeypatch): + async def allow(**kwargs): + return True + + monkeypatch.setattr(router_module, "check_action_access", allow) + app = FastAPI() + + @app.middleware("http") + async def principal(request, call_next): + request.state.user_id = USER_ID + request.state.project_id = PROJECT_ID + return await call_next(request) + + app.include_router(VaultRouter(vault_service=VaultService(_DAO())).router) + return TestClient(app) + + +def test_public_response_exposes_policy_without_manager(client): + response = client.get("/secrets/") + assert response.status_code == 200 + management = response.json()[0]["management"] + assert management == {"policy": "manager_only"} + assert "manager" not in management + + +def test_managed_update_and_delete_are_conflicts(client): + secret = client.get("/secrets/").json()[0] + update = client.put( + f"/secrets/{secret['id']}", + json={"header": {"name": "No"}}, + ) + delete = client.delete(f"/secrets/{secret['id']}") + assert update.status_code == 409 + assert delete.status_code == 409 + + +@pytest.mark.parametrize("field", ["management", "managed_by"]) +def test_public_requests_reject_management_fields(client, field): + response = client.post( + "/secrets/", + json={ + "header": {"name": "Mine"}, + "secret": { + "kind": "provider_key", + "data": {"kind": "openai", "provider": {"key": "sk-mine"}}, + }, + field: {"manager": "starter-credits-bridge"}, + }, + ) + assert response.status_code == 422 diff --git a/docs/design/managed-secrets/README.md b/docs/design/managed-secrets/README.md new file mode 100644 index 00000000000..5c0c3cd5349 --- /dev/null +++ b/docs/design/managed-secrets/README.md @@ -0,0 +1,31 @@ +# Managed secrets + +Managed secrets are vault rows provisioned and owned by a platform component. Their +management policy is separate from whether their value is write-only. + +The internal contract stores a structured `management` object in the encrypted vault +JSON: + +```json +{ + "management": { + "manager": "starter-credits-bridge", + "policy": "manager_only" + } +} +``` + +This requires no database migration. There is deliberately no compatibility fallback for +the former flat `managed_by` experiment. + +Public create and update payloads do not accept management fields. Trusted components use +`VaultService.create_managed_secret` and provide `SecretManagementDTO` separately. Public +responses expose only `management.policy`; the manager identity remains internal. + +`manager_only` means public update and delete operations are rejected after the DAO locks +the current row. There is no owner bypass in the general vault service. A future manager +workflow that needs reconciliation should use a dedicated manager-specific operation. + +Management and value visibility are independent. A managed secret may explicitly be +readable when its product flow requires that behavior, while write-only managed secrets +continue to use the normal runtime grant and redaction rules.