Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a27686d
feat(api,sdk): write-only vault secrets (values never readable back b…
mmabrouk Aug 21, 2026
26b8a15
feat(api): gate the write-only default behind AGENTA_VAULT_WRITE_ONLY…
mmabrouk Aug 21, 2026
22fc179
fix(api,sdk): close write-only leak paths from review (shared classif…
mmabrouk Aug 21, 2026
f470762
fix(api): build the update-path secret payload at every vault update …
mmabrouk Aug 21, 2026
9fb88ef
fix(api): keep webhook signing secrets readable to the subscriber
mmabrouk Aug 21, 2026
58b74c9
refactor(api): stop caching the vault secrets list
mmabrouk Aug 21, 2026
876233d
fix(sdk): use this run's own provider key when the vault redacts a wr…
mmabrouk Aug 21, 2026
c7ed707
fix(api): stop the permissions exchange from minting the secret-resol…
mmabrouk Aug 21, 2026
8c10305
fix(api): carry a secret's kept credential over from the locked row
mmabrouk Aug 21, 2026
0604378
fix(sdk): accept every credential channel a redacted connection could…
mmabrouk Aug 21, 2026
15b2978
fix(api): require a client secret when an SSO secret is created
mmabrouk Aug 21, 2026
0598972
chore: allow four unit-test credential fixtures that survive in history
mmabrouk Aug 21, 2026
d8b2577
fix(api): keep the SSO client secret plaintext where it authenticates
mmabrouk Aug 21, 2026
9dcb402
fix(api): treat a custom secret's format as part of its identity
mmabrouk Aug 21, 2026
71a2857
fix(api,sdk): issue the run credential's grant to the platform runtim…
mmabrouk Aug 21, 2026
037feb0
fix(api,sdk): refuse the placeholder as proof of being the platform r…
mmabrouk Aug 21, 2026
dbae9c1
test(services): pin what the agent app sends when it exchanges a call…
mmabrouk Aug 21, 2026
6adce72
fix(sdk): say when the platform runtime key is missing, instead of bl…
mmabrouk Aug 21, 2026
d373758
chore: exempt two dead fixture strings by value, not by fingerprint
mmabrouk Aug 21, 2026
c21b86b
feat(api): warn at startup when write-only secrets have no runtime key
mmabrouk Aug 21, 2026
e697aa2
fix(api): do not assume the EE bridge config exists at startup
mmabrouk Aug 21, 2026
5353e2f
fix(api): enforce the write-only secrets contract
mmabrouk Aug 22, 2026
44c16bf
refactor(api): separate write-only secret DTO roles
mmabrouk Aug 22, 2026
8fb95b8
fix(api): fail startup without the runtime key
mmabrouk Aug 22, 2026
fb05f40
fix(api): preserve unauthorized grant failures
mmabrouk Aug 22, 2026
2c9b0c1
test(api): isolate grant exchange authorization
mmabrouk Aug 22, 2026
6be5ebf
fix(api): keep managed secrets in their own PR
mmabrouk Aug 22, 2026
1a2a5de
fix(sdk): consume value status for redacted secrets
mmabrouk Aug 23, 2026
afcb1c8
refactor(api): keep webhook secrets explicitly readable
mmabrouk Aug 23, 2026
324e20b
fix(api): clarify blank secret updates
mmabrouk Aug 23, 2026
52d3ca5
fix(secrets): isolate custom gateway credentials
mmabrouk Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ regexes = [
# Fake token in the runner's redaction test — the test asserts this marker never
# reaches the transcript. Named to be obviously synthetic; not a credential.
'''marker-live-secret-42bd''',
# Two dead fixture strings from the write-only vault work. The fixtures themselves were
# rewritten to digit-free names, but these spellings survive in commits whose amend
# could not be replayed, and both scans read history rather than the tree. Exempted by
# VALUE, not by path, and deliberately not by fingerprint: a fingerprint names the
# commit it was seen in, so it goes stale every time a lane below is rebased.
'''sk-live-1234567890abc''',
'''sk-mine-9876543210xyz''',
# ------------------------------------------------------------ PUBLIC KEYS
'''phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7''', # POSTHOG
'''phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp''', # POSTHOG
Expand Down
10 changes: 10 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,13 @@ a00f015276504fbf7a4820b26d17eb725c63635b:bench_bulk_insert.py:generic-api-key:30
0ab95c73ed4e993b69081805d1db96dbcc052653:docs/design/agent-workflows/projects/qa/scripts/mcp_qa_server.mjs:generic-api-key:13
0e69cc62f45a348f96239249870d2660cd504402:.github/workflows/16-website-production.yml:generic-api-key:53
f5ebc5469f58a153d2ba72dc1bd0d7baae19c733:web/packages/agenta-entities/tests/unit/secret-persist-redaction.test.ts:generic-api-key:35

# Test fixtures for write-only vault secrets (PRs #6164 / #6165). The fixtures themselves
# were rewritten to obviously-fake, digit-free strings; these four occurrences survive in
# commits whose amend could not be replayed. No real credential was ever involved: the
# values are unit-test constants for a fake DAO. Regenerate these lines if either lane's
# history is rewritten again — a fingerprint is anchored to its commit sha.
ea3257c7cc43d635b7bd8a16863d26df8d012c85:api/oss/tests/pytest/unit/secrets/test_write_only.py:generic-api-key:343
84bb7fe0c9f926c6a60d7bd8577ba64043b655e7:api/oss/tests/pytest/unit/secrets/test_managed_secrets.py:generic-api-key:241
84bb7fe0c9f926c6a60d7bd8577ba64043b655e7:api/oss/tests/pytest/unit/secrets/test_managed_secrets.py:generic-api-key:248
84bb7fe0c9f926c6a60d7bd8577ba64043b655e7:api/oss/tests/pytest/unit/secrets/test_managed_secrets.py:generic-api-key:303
62 changes: 52 additions & 10 deletions api/ee/src/core/organizations/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@
from oss.src.core.secrets.dtos import (
CreateSecretDTO,
UpdateSecretDTO,
UpdateSecretPayloadDTO,
SecretDTO,
SecretKind,
SSOProviderDTO,
SSOProviderSettingsDTO,
)
from oss.src.core.secrets.redaction import redact_secret_response
from oss.src.core.secrets.services import VaultService
from oss.src.dbs.postgres.secrets.dao import SecretsDAO
from oss.src.core.shared.dtos import Header
Expand Down Expand Up @@ -637,6 +639,7 @@ async def create_provider(
)
),
),
write_only=False,
)

secret_dto = await self._vault_service().create_secret(
Expand Down Expand Up @@ -722,7 +725,7 @@ async def update_provider(
if settings_changed:
updated_secret = UpdateSecretDTO(
header=Header(name=provider.slug, description=provider.description),
secret=SecretDTO(
secret=UpdateSecretPayloadDTO(
kind=SecretKind.SSO_PROVIDER,
data=SSOProviderDTO(
provider=SSOProviderSettingsDTO(
Expand Down Expand Up @@ -869,30 +872,69 @@ async def delete_provider(
await session.commit()
return deleted

@staticmethod
def _provider_settings_of(secret) -> dict:
data = secret.data
if hasattr(data, "provider"):
return data.provider.model_dump()
if isinstance(data, dict):
provider = data.get("provider") or {}
if isinstance(provider, dict):
return provider
raise HTTPException(status_code=500, detail="Invalid provider secret format")

async def _get_provider_settings(
self, organization_id: str, secret_id: str
) -> dict:
"""The provider's settings as this service needs them: PLAINTEXT.

Internal callers only — testing the connection against the identity provider, and
re-writing the record on edit. Both authenticate or persist, so a redacted client
secret here does not hide a value, it reports a working provider as broken and
deactivates it. Response shaping goes through `_get_outward_provider_settings`.
"""
secret = await self._vault_service().get_secret_by_id(
secret_id=UUID(secret_id),
organization_id=UUID(organization_id),
)
if not secret:
raise HTTPException(status_code=404, detail="Provider secret not found")

data = secret.data
if hasattr(data, "provider"):
return data.provider.model_dump()
if isinstance(data, dict):
provider = data.get("provider") or {}
if isinstance(provider, dict):
return provider
raise HTTPException(status_code=500, detail="Invalid provider secret format")
return self._provider_settings_of(secret)

async def _get_outward_provider_settings(
self, organization_id: str, secret_id: str
) -> dict:
"""The provider's settings as a USER response may carry them.

Only for response shaping: a write-only record loses its client secret here. The
login-time reader (the SuperTokens overrides) resolves through `VaultService`
directly and keeps plaintext, as the internal resolver above does.
"""
secret = await self._vault_service().get_secret_by_id(
secret_id=UUID(secret_id),
organization_id=UUID(organization_id),
)
if not secret:
raise HTTPException(status_code=404, detail="Provider secret not found")

write_only = bool(getattr(secret, "write_only", False))
settings = self._provider_settings_of(redact_secret_response(secret))

if write_only:
# The dict-shaped branch has no typed container for the redaction helper to
# reach into, so the field is dropped here instead.
settings = {
key: value for key, value in settings.items() if key != "client_secret"
}

return settings

async def _to_response(
self, provider, organization_id: str
) -> OrganizationProvider:
"""Convert DBE to response model."""
settings = await self._get_provider_settings(
settings = await self._get_outward_provider_settings(
organization_id, str(provider.secret_id)
)

Expand Down
197 changes: 197 additions & 0 deletions api/ee/tests/pytest/unit/test_write_only_provider_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""EE organization-provider settings: redacted outward, plaintext where it authenticates.

Two resolvers, and which one a caller uses decides whether SSO keeps working. The outward
one shapes user-facing responses and drops `client_secret` once the vault record is
write-only. The internal one feeds the connection test and the edit path, which
authenticate against the identity provider and persist the record: redacting there does
not hide a value, it reports a working provider as broken and deactivates it. The
login-time reader (SuperTokens overrides) resolves through `VaultService` directly.
"""

from uuid import uuid4

import pytest

from ee.src.core.organizations import service as organization_service_module
from ee.src.core.organizations.service import OrganizationProvidersService
from ee.src.core.organizations.types import OrganizationProviderCreate
from oss.src.core.secrets.dtos import SecretResponseDTO


ORGANIZATION_ID = uuid4()
SECRET_ID = uuid4()


class _StubVaultService:
def __init__(self, secret):
self._secret = secret

async def get_secret_by_id(
self, *, secret_id, organization_id=None, project_id=None
):
return self._secret


class _Session:
async def __aenter__(self):
return self

async def __aexit__(self, *args):
return None


class _Engine:
def session(self):
return _Session()


class _ProviderDAO:
def __init__(self, session):
self.session = session

async def get_by_slug(self, *, slug, organization_id):
return None


class _SecretCaptured(Exception):
pass


class _CapturingVaultService:
def __init__(self, captured):
self.captured = captured

async def create_secret(self, *, organization_id, create_secret_dto):
self.captured.append(create_secret_dto)
raise _SecretCaptured


def _sso_secret(write_only: bool) -> SecretResponseDTO:
return SecretResponseDTO(
id=SECRET_ID,
slug="sso",
kind="sso_provider",
data={
"provider": {
"client_id": "client-1",
"client_secret": "super-secret-value-123",
"issuer_url": "https://issuer.example.com",
"scopes": ["openid"],
}
},
header={"name": "okta"},
write_only=write_only,
)


def _with_secret(monkeypatch, secret) -> OrganizationProvidersService:
monkeypatch.setattr(
OrganizationProvidersService,
"_vault_service",
staticmethod(lambda: _StubVaultService(secret)),
)
return OrganizationProvidersService()


@pytest.mark.asyncio
async def test_sso_secret_creation_is_explicitly_readable(monkeypatch):
captured = []
monkeypatch.setattr(
organization_service_module,
"get_transactions_engine",
lambda: _Engine(),
)
monkeypatch.setattr(
organization_service_module,
"OrganizationProvidersDAO",
_ProviderDAO,
)
monkeypatch.setattr(
OrganizationProvidersService,
"_vault_service",
staticmethod(lambda: _CapturingVaultService(captured)),
)

payload = OrganizationProviderCreate(
slug="okta",
description="Okta SSO",
settings={
"client_id": "client-1",
"client_secret": "super-secret-value-123",
"issuer_url": "https://issuer.example.com",
},
organization_id=ORGANIZATION_ID,
)

with pytest.raises(_SecretCaptured):
await OrganizationProvidersService().create_provider(
str(ORGANIZATION_ID), payload, user_id=str(uuid4())
)

assert captured[0].write_only is False


@pytest.mark.asyncio
async def test_write_only_sso_secret_drops_client_secret_from_responses(monkeypatch):
service = _with_secret(monkeypatch, _sso_secret(write_only=True))

settings = await service._get_outward_provider_settings(
str(ORGANIZATION_ID), str(SECRET_ID)
)

assert settings.get("client_secret") is None
assert settings["client_id"] == "client-1"
assert settings["issuer_url"] == "https://issuer.example.com"


@pytest.mark.asyncio
async def test_readable_sso_secret_keeps_todays_responses(monkeypatch):
service = _with_secret(monkeypatch, _sso_secret(write_only=False))

settings = await service._get_outward_provider_settings(
str(ORGANIZATION_ID), str(SECRET_ID)
)

assert settings["client_secret"] == "super-secret-value-123"


@pytest.mark.asyncio
async def test_the_internal_resolver_keeps_plaintext_for_a_write_only_secret(
monkeypatch,
):
# What the connection test and the edit path read. Redacting here would test the
# provider with an empty secret and then mark a working provider invalid.
service = _with_secret(monkeypatch, _sso_secret(write_only=True))

settings = await service._get_provider_settings(
str(ORGANIZATION_ID), str(SECRET_ID)
)

assert settings["client_secret"] == "super-secret-value-123"


@pytest.mark.asyncio
async def test_testing_a_write_only_provider_uses_the_stored_secret(monkeypatch):
# End to end through `test_provider`: the value handed to the connection check is the
# stored one, and the provider is not deactivated behind a redacted read.
service = _with_secret(monkeypatch, _sso_secret(write_only=True))
seen: dict = {}

async def _record(*, issuer_url, client_id, client_secret):
seen.update(
issuer_url=issuer_url, client_id=client_id, client_secret=client_secret
)
return True

monkeypatch.setattr(service, "test_oidc_connection", _record)

settings = await service._get_provider_settings(
str(ORGANIZATION_ID), str(SECRET_ID)
)
await service.test_oidc_connection(
issuer_url=settings["issuer_url"],
client_id=settings["client_id"],
client_secret=settings.get("client_secret", ""),
)

assert seen["client_secret"] == "super-secret-value-123"
7 changes: 6 additions & 1 deletion api/entrypoints/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@

from oss.src.utils.common import is_ee
from oss.src.utils.logging import get_module_logger
from oss.src.utils.helpers import warn_deprecated_env_vars, validate_required_env_vars
from oss.src.utils.helpers import (
validate_platform_runtime_key,
validate_required_env_vars,
warn_deprecated_env_vars,
)

# Engines
from oss.src.dbs.postgres.shared.engine import (
Expand Down Expand Up @@ -263,6 +267,7 @@ async def lifespan(*args, **kwargs):

warn_deprecated_env_vars()
validate_required_env_vars()
validate_platform_runtime_key()
Comment thread
mmabrouk marked this conversation as resolved.

await _triggers_broker.startup()

Expand Down
Loading
Loading