Skip to content

Commit

Permalink
Locally cache the 1Password client on plugin startup to make fetching…
Browse files Browse the repository at this point in the history
… secrets a bit faster
  • Loading branch information
paulfioravanti committed Aug 27, 2024
1 parent 42e6ec0 commit b52adcc
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 67 deletions.
2 changes: 1 addition & 1 deletion plover_1password/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Version attribute
"""
__version__ = "0.2.0"
__version__ = "0.3.0"
11 changes: 8 additions & 3 deletions plover_1password/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
)
from plover.registry import registry

from onepassword.client import Client

from . import (
service_account,
secret,
Expand All @@ -30,8 +32,8 @@ class OnePassword:
Extension class that also registers a meta plugin.
The meta deals with retrieving secrets from 1Password
"""
_client: Client
_engine: StenoEngine
_service_account_token: str
_shell: Optional[str]
_system: str

Expand All @@ -45,10 +47,13 @@ def start(self) -> None:
self._system = platform.system()
if self._system != "Windows":
self._shell = os.getenv("SHELL", _DEFAULT_SHELL).split("/")[-1]
self._service_account_token = service_account.get_token(
service_account_token = service_account.get_token(
self._system,
self._shell
)
self._client = asyncio.run(
secret.init_client(service_account_token)
)
registry.register_plugin(
"meta",
"1PASSWORD",
Expand All @@ -73,7 +78,7 @@ async def _one_password(self, ctx: _Context, argument: str) -> _Action:
argument
)
secret_value: str = await secret.resolve(
self._service_account_token,
self._client,
op_secret_reference
)

Expand Down
6 changes: 5 additions & 1 deletion plover_1password/secret/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
A package dealing with:
- retrieving and resolving a secret from a 1Password vault
"""
from .resolver import resolve
from .resolver import (
init_client,
resolve
)

__all__ = [
"init_client",
"resolve"
]
24 changes: 13 additions & 11 deletions plover_1password/secret/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,29 @@

_INTEGRATION_NAME = "Plover integration"

async def resolve(service_account_token: str, secret_reference: str) -> str:
async def init_client(service_account_token: str) -> Client:
"""
Initialises a 1Password client to retrieve secrets.
"""
client: Client = await Client.authenticate(
auth=service_account_token,
integration_name=_INTEGRATION_NAME,
integration_version=__version__
)

return client

async def resolve(client: Client, secret_reference: str) -> str:
"""
Resolves a single secret from a secret reference URI.
"""
if not secret_reference:
raise ValueError("Secret Reference cannot be blank")

try:
client: Client = await _init_client(service_account_token)
secret: str = await client.secrets.resolve(secret_reference)
except Exception as exc: # pylint: disable=broad-except
error.handle_ffi_error(exc, secret_reference)
raise ValueError(str(exc)) from exc

return secret

async def _init_client(service_account_token: str) -> Client:
client: Client = await Client.authenticate(
auth=service_account_token,
integration_name=_INTEGRATION_NAME,
integration_version=__version__
)

return client
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[pytest]
asyncio_mode=auto
asyncio_default_fixture_loop_scope = function
116 changes: 70 additions & 46 deletions test/secret/test_secret.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,40 @@ def mock_client(mocker):
"onepassword.client.Client.authenticate",
return_value=async_mock
)
return async_mock

@pytest.fixture()
def patch_client_authenticate_error(monkeypatch):
# REF: https://stackoverflow.com/questions/8294618/define-a-lambda-expression-that-raises-an-exception
# REF: https://stackoverflow.com/a/8294654/567863
def raise_(exc):
raise exc

def _method(message=""):
monkeypatch.setattr(
"onepassword.client.Client.authenticate",
lambda **kwargs: raise_(Exception(message))
)
# REF: https://stackoverflow.com/a/44701916/567863
def _method(error_message="", return_value=None):
if error_message:
async_mock.secrets.resolve.side_effect = (
lambda _return_value: raise_(Exception(error_message))
)

if return_value:
async_mock.secrets.resolve.return_value = return_value

return async_mock

return _method

async def test_blank_secret_reference():
async def test_initialising_a_client(mock_client):
assert await secret.init_client("service_account_token") == mock_client()

async def test_blank_secret_reference(mock_client):
with pytest.raises(
ValueError,
match="Secret Reference cannot be blank"
):
await secret.resolve("service_account_token", "")

async def test_service_account_token_invalid(patch_client_authenticate_error):
patch_client_authenticate_error(
message=(
"invalid service account token, please make sure you provide a "
"valid service account token as parameter: service account "
"deserialization failed, please create another token"
)
await secret.resolve(mock_client, "")

async def test_service_account_token_invalid(mock_client):
error_message = (
"invalid service account token, please make sure you provide a "
"valid service account token as parameter: service account "
"deserialization failed, please create another token"
)

with pytest.raises(
Expand All @@ -49,16 +53,15 @@ async def test_service_account_token_invalid(patch_client_authenticate_error):
"Create another token and restart Plover."
)
):
await secret.resolve("service_account_token", "secret_reference")

async def test_service_account_token_invalid_format(
patch_client_authenticate_error
):
patch_client_authenticate_error(
message=(
"invalid user input: encountered the following errors: "
"service account token had invalid format"
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_service_account_token_invalid_format(mock_client):
error_message = (
"invalid user input: encountered the following errors: "
"service account token had invalid format"
)

with pytest.raises(
Expand All @@ -68,10 +71,13 @@ async def test_service_account_token_invalid_format(
"Fix token format or create a new one and restart Plover."
)
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_invalid_format(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"secret reference has invalid format - "
"must be \"op://<vault>/<item>/[section/]field\""
Expand All @@ -85,10 +91,13 @@ async def test_secret_reference_invalid_format(mock_client):
"You provided secret_reference."
)
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_missing_prefix(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"secret reference is not prefixed with \"op://\". "
"You provided secret_reference."
Expand All @@ -98,10 +107,13 @@ async def test_secret_reference_missing_prefix(mock_client):
ValueError,
match="Secret Reference needs to be prefixed with \"op://\""
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_vault_not_found(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"no vault matched the secret reference query"
)
Expand All @@ -110,10 +122,13 @@ async def test_secret_reference_vault_not_found(mock_client):
ValueError,
match="Vault specified not found in Secret Reference secret_reference."
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_item_not_found(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"no item matched the secret reference query"
)
Expand All @@ -122,10 +137,13 @@ async def test_secret_reference_item_not_found(mock_client):
ValueError,
match="Item specified not found in Secret Reference secret_reference."
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_section_not_found(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"no section matched the secret reference query"
)
Expand All @@ -136,10 +154,13 @@ async def test_secret_reference_section_not_found(mock_client):
"Section specified not found in Secret Reference secret_reference."
)
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_secret_reference_field_not_found(mock_client):
mock_client.secrets.resolve.side_effect = Exception(
error_message = (
"error resolving secret reference: "
"the specified field cannot be found within the item"
)
Expand All @@ -148,17 +169,20 @@ async def test_secret_reference_field_not_found(mock_client):
ValueError,
match="Field specified not found in Secret Reference secret_reference."
):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message=error_message),
"secret_reference"
)

async def test_unexpected_exception(mock_client):
mock_client.secrets.resolve.side_effect = Exception("Some exception")

with pytest.raises(ValueError, match="Some exception"):
await secret.resolve("service_account_token", "secret_reference")
await secret.resolve(
mock_client(error_message="Some exception"), "secret_reference"
)

async def test_successful_secret_retrieval(mock_client):
mock_client.secrets.resolve.return_value = "secret"
remote_secret = await secret.resolve(
"service_account_token", "secret_reference"
mock_client(return_value="secret"),
"secret_reference"
)
assert remote_secret == "secret"
10 changes: 5 additions & 5 deletions test/secret_reference/test_secret_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@


# NOTE: Given that the command passed in to `os.popen` will be different
# between Windows and non-Windows...
# between Windows and non-Windows:
#
# `echo $ExecutionContext.InvokeCommand.ExpandString(op://$ENV:VAULT_NAME/$ENV:ITEM_NAME/$ENV:SECTION_NAME/Mobile)`
#
# vs
#
# `bash -ic 'echo op://$VAULT_NAME/$ITEM_NAME/$SECTION_NAME/Mobile'`
#
# ...this version of the mock (compared to the one in
# `test_service_account.py`), handwaves over how that command works and what it
# returns, and instead just gives back a specified `return_value` that we're
# reasonably sure we're expecting.
# This version of the mock (compared to the one in `test_service_account.py`),
# handwaves over how that command works, and what it returns, and instead just
# gives back a the `return_value` passed in that we're reasonably sure we're
# expecting back from `os.popen.read`.
@pytest.fixture()
def mock_popen_read(mocker):
mock = mocker.Mock()
Expand Down

0 comments on commit b52adcc

Please sign in to comment.