-
-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #31 from nitrictech/feature/secrets
feat: add secrets service support
- Loading branch information
Showing
4 changed files
with
250 additions
and
1 deletion.
There are no files selected for viewing
Submodule contracts
updated
from ae6424 to 43e281
This file contains 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 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,141 @@ | ||
# | ||
# Copyright (c) 2021 Nitric Technologies Pty Ltd. | ||
# | ||
# This file is part of Nitric Python 3 SDK. | ||
# See https://github.com/nitrictech/python-sdk for further info. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# | ||
from __future__ import annotations | ||
from dataclasses import dataclass | ||
from typing import Union | ||
|
||
from nitric.utils import new_default_channel | ||
from nitric.proto.nitric.secret.v1 import SecretServiceStub, Secret as SecretMessage, SecretVersion as VersionMessage | ||
|
||
|
||
class Secrets(object): | ||
""" | ||
Nitric secrets management client. | ||
This client insulates application code from stack specific secrets managements services. | ||
""" | ||
|
||
def __init__(self): | ||
"""Construct a Nitric Storage Client.""" | ||
self._channel = new_default_channel() | ||
self._secrets_stub = SecretServiceStub(channel=self._channel) | ||
|
||
def __del__(self): | ||
# close the channel when this client is destroyed | ||
if self._channel is not None: | ||
self._channel.close() | ||
|
||
def secret(self, name: str): | ||
"""Return a reference to a secret container from the connected secrets management service.""" | ||
return SecretContainer(_secrets=self, name=name) | ||
|
||
|
||
def _secret_to_wire(secret: SecretContainer) -> SecretMessage: | ||
return SecretMessage(name=secret.name) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class SecretContainer(object): | ||
"""A reference to a secret container, used to store and retrieve secret versions.""" | ||
|
||
_secrets: Secrets | ||
name: str | ||
|
||
async def put(self, value: Union[str, bytes]) -> SecretVersion: | ||
""" | ||
Create a new secret version, making it the latest and storing the provided value. | ||
:param value: the secret value to store | ||
""" | ||
if isinstance(value, str): | ||
value = bytes(value, "utf-8") | ||
|
||
secret_message = _secret_to_wire(self) | ||
|
||
response = await self._secrets._secrets_stub.put(secret=secret_message, value=value) | ||
return self.version(version=response.secret_version.version) | ||
|
||
def version(self, version: str): | ||
""" | ||
Return a reference to a specific version of a secret. | ||
Can be used to retrieve the secret value associated with the version. | ||
""" | ||
return SecretVersion(_secrets=self._secrets, secret=self, id=version) | ||
|
||
def latest(self): | ||
""" | ||
Return a reference to the 'latest' secret version. | ||
Note: using 'access' on this reference may return different values between requests if a | ||
new version is created between access calls. | ||
""" | ||
return self.version("latest") | ||
|
||
|
||
def _secret_version_to_wire(version: SecretVersion) -> VersionMessage: | ||
return VersionMessage(_secret_to_wire(version.secret), version=version.id) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class SecretVersion(object): | ||
"""A reference to a version of a secret, used to access the value of the version.""" | ||
|
||
_secrets: Secrets | ||
secret: SecretContainer | ||
id: str | ||
|
||
async def access(self) -> SecretValue: | ||
"""Return the value stored against this version of the secret.""" | ||
version_message = _secret_version_to_wire(self) | ||
response = await self._secrets._secrets_stub.access(secret_version=version_message) | ||
|
||
# Construct a new SecretVersion if the response version id doesn't match this reference. | ||
# This ensures calls to access from the 'latest' version return new version objects | ||
# with a fixed version id. | ||
static_version = ( | ||
self | ||
if response.secret_version.version == self.id | ||
else SecretVersion(_secrets=self._secrets, secret=self.secret, id=response.secret_version.version) | ||
) | ||
|
||
return SecretValue(version=static_version, value=response.value) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class SecretValue(object): | ||
"""Represents the value of a secret, tied to a specific version.""" | ||
|
||
# The version containing this value. Never 'latest', always a specific version. | ||
version: SecretVersion | ||
value: bytes | ||
|
||
def __str__(self) -> str: | ||
return self.value.decode("utf-8") | ||
|
||
def __bytes__(self) -> bytes: | ||
return self.value | ||
|
||
def as_string(self): | ||
"""Return the content of this secret value as a string.""" | ||
return str(self) | ||
|
||
def as_bytes(self): | ||
"""Return the content of this secret value.""" | ||
return bytes(self) |
This file contains 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,106 @@ | ||
# | ||
# Copyright (c) 2021 Nitric Technologies Pty Ltd. | ||
# | ||
# This file is part of Nitric Python 3 SDK. | ||
# See https://github.com/nitrictech/python-sdk for further info. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
# You may obtain a copy of the License at | ||
# | ||
# http://www.apache.org/licenses/LICENSE-2.0 | ||
# | ||
# Unless required by applicable law or agreed to in writing, software | ||
# distributed under the License is distributed on an "AS IS" BASIS, | ||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
# See the License for the specific language governing permissions and | ||
# limitations under the License. | ||
# | ||
from unittest import IsolatedAsyncioTestCase | ||
from unittest.mock import patch, AsyncMock | ||
|
||
from nitric.api import Secrets | ||
from nitric.api.secrets import SecretValue | ||
from nitric.proto.nitric.secret.v1 import SecretPutResponse, SecretVersion, Secret, SecretAccessResponse | ||
|
||
|
||
class Object(object): | ||
pass | ||
|
||
|
||
class SecretsClientTest(IsolatedAsyncioTestCase): | ||
async def test_put(self): | ||
mock_put = AsyncMock() | ||
mock_response = SecretPutResponse( | ||
secret_version=SecretVersion(secret=Secret(name="test-secret"), version="test-version") | ||
) | ||
mock_put.return_value = mock_response | ||
|
||
with patch("nitric.proto.nitric.secret.v1.SecretServiceStub.put", mock_put): | ||
secret = Secrets().secret("test-secret") | ||
result = await secret.put(b"a test secret value") | ||
|
||
# Check expected values were passed to Stub | ||
mock_put.assert_called_once() | ||
assert mock_put.call_args.kwargs["secret"] == Secret(name="test-secret") | ||
assert mock_put.call_args.kwargs["value"] == b"a test secret value" | ||
|
||
# Check the returned value | ||
assert result.id == "test-version" | ||
assert result.secret.name == "test-secret" | ||
|
||
async def test_put_string(self): | ||
mock_put = AsyncMock() | ||
mock_response = SecretPutResponse( | ||
secret_version=SecretVersion(secret=Secret(name="test-secret"), version="test-version") | ||
) | ||
mock_put.return_value = mock_response | ||
|
||
with patch("nitric.proto.nitric.secret.v1.SecretServiceStub.put", mock_put): | ||
secret = Secrets().secret("test-secret") | ||
await secret.put("a test secret value") # string, not bytes | ||
|
||
# Check expected values were passed to Stub | ||
mock_put.assert_called_once() | ||
assert mock_put.call_args.kwargs["value"] == b"a test secret value" # value should still be bytes when sent. | ||
|
||
async def test_latest(self): | ||
version = Secrets().secret("test-secret").latest() | ||
|
||
assert version.secret.name == "test-secret" | ||
assert version.id == "latest" | ||
|
||
async def test_access(self): | ||
mock_access = AsyncMock() | ||
mock_response = SecretAccessResponse( | ||
secret_version=SecretVersion(secret=Secret(name="test-secret"), version="response-version"), | ||
value=b"super secret value", | ||
) | ||
mock_access.return_value = mock_response | ||
|
||
with patch("nitric.proto.nitric.secret.v1.SecretServiceStub.access", mock_access): | ||
version = Secrets().secret("test-secret").latest() | ||
result = await version.access() | ||
|
||
# Check expected values were passed to Stub | ||
mock_access.assert_called_once() | ||
assert mock_access.call_args.kwargs["secret_version"] == SecretVersion( | ||
secret=Secret(name="test-secret"), | ||
version="latest", | ||
) | ||
|
||
# Check the returned value | ||
assert result.version.id == "response-version" | ||
assert result.value == b"super secret value" | ||
|
||
async def test_value_to_string(self): | ||
value = SecretValue(version=None, value=b"secret value") | ||
|
||
assert value.as_string() == "secret value" | ||
assert str(value) == "secret value" | ||
|
||
async def test_value_to_bytes(self): | ||
value = SecretValue(version=None, value=b"secret value") | ||
|
||
assert value.as_bytes() == b"secret value" | ||
assert bytes(value) == b"secret value" |