-
Notifications
You must be signed in to change notification settings - Fork 668
[feat] Write-only vault secrets (values never readable back by users) #6164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 26b8a15
feat(api): gate the write-only default behind AGENTA_VAULT_WRITE_ONLY…
mmabrouk 22fc179
fix(api,sdk): close write-only leak paths from review (shared classif…
mmabrouk f470762
fix(api): build the update-path secret payload at every vault update …
mmabrouk 9fb88ef
fix(api): keep webhook signing secrets readable to the subscriber
mmabrouk 58b74c9
refactor(api): stop caching the vault secrets list
mmabrouk 876233d
fix(sdk): use this run's own provider key when the vault redacts a wr…
mmabrouk c7ed707
fix(api): stop the permissions exchange from minting the secret-resol…
mmabrouk 8c10305
fix(api): carry a secret's kept credential over from the locked row
mmabrouk 0604378
fix(sdk): accept every credential channel a redacted connection could…
mmabrouk 15b2978
fix(api): require a client secret when an SSO secret is created
mmabrouk 0598972
chore: allow four unit-test credential fixtures that survive in history
mmabrouk d8b2577
fix(api): keep the SSO client secret plaintext where it authenticates
mmabrouk 9dcb402
fix(api): treat a custom secret's format as part of its identity
mmabrouk 71a2857
fix(api,sdk): issue the run credential's grant to the platform runtim…
mmabrouk 037feb0
fix(api,sdk): refuse the placeholder as proof of being the platform r…
mmabrouk dbae9c1
test(services): pin what the agent app sends when it exchanges a call…
mmabrouk 6adce72
fix(sdk): say when the platform runtime key is missing, instead of bl…
mmabrouk d373758
chore: exempt two dead fixture strings by value, not by fingerprint
mmabrouk c21b86b
feat(api): warn at startup when write-only secrets have no runtime key
mmabrouk e697aa2
fix(api): do not assume the EE bridge config exists at startup
mmabrouk 5353e2f
fix(api): enforce the write-only secrets contract
mmabrouk 44c16bf
refactor(api): separate write-only secret DTO roles
mmabrouk 8fb95b8
fix(api): fail startup without the runtime key
mmabrouk fb05f40
fix(api): preserve unauthorized grant failures
mmabrouk 2c9b0c1
test(api): isolate grant exchange authorization
mmabrouk 6be5ebf
fix(api): keep managed secrets in their own PR
mmabrouk 1a2a5de
fix(sdk): consume value status for redacted secrets
mmabrouk afcb1c8
refactor(api): keep webhook secrets explicitly readable
mmabrouk 324e20b
fix(api): clarify blank secret updates
mmabrouk 52d3ca5
fix(secrets): isolate custom gateway credentials
mmabrouk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
197 changes: 197 additions & 0 deletions
197
api/ee/tests/pytest/unit/test_write_only_provider_settings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.