Skip to content
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

Release 0.4.0 #27

Merged
merged 5 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# saltstack-age change log

## 0.4.0

* feat: allow configuration of an identity string using the `AGE_IDENTITY`
environment variable and the `age_identity` configuration directive

## 0.3.0

* fix: add support for nested pillar data
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ daemon configuration file, or in the daemon environment.
| Type | Configuration directive | Environment variable | Expected value |
| ------------ | ----------------------- | -------------------- | ---------------------------- |
| identity | `age_identity_file` | `AGE_IDENTITY_FILE` | Path of an age identity file |
| identity | `age_identity` | `AGE_IDENTITY` | An age identity string |
| passphrase | `age_passphrase` | `AGE_PASSPHRASE` | An age passphrase |

You can check this [example configuration](./example/config/minion).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "saltstack-age"
version = "0.3.0"
version = "0.4.0"
description = "age renderer for Saltstack"
authors = [{ name = "Philippe Muller" }]
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion src/saltstack_age/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ def decrypt(arguments: Namespace) -> None:
)
raise SystemExit(-1)

_ = sys.stdout.write(secure_value.decrypt(arguments.identities[0]))
_ = sys.stdout.write(secure_value.decrypt(identities[0]))

else: # isinstance(secure_value, PassphraseSecureValue)
_ = sys.stdout.write(secure_value.decrypt(get_passphrase(arguments)))
Expand Down
17 changes: 16 additions & 1 deletion src/saltstack_age/identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def read_identity_file(path: Path | str) -> pyrage.x25519.Identity:
return pyrage.x25519.Identity.from_str(identity_string)


def get_identity_from_environment() -> pyrage.x25519.Identity | None:
def get_identity_file_from_environment() -> pyrage.x25519.Identity | None:
path_string = os.environ.get("AGE_IDENTITY_FILE")

if path_string is None:
Expand All @@ -32,3 +32,18 @@ def get_identity_from_environment() -> pyrage.x25519.Identity | None:
raise FileNotFoundError(f"AGE_IDENTITY_FILE does not exist: {path}")

return read_identity_file(path)


def get_identity_string_from_environment() -> pyrage.x25519.Identity | None:
identity_string = os.environ.get("AGE_IDENTITY")

if identity_string is None:
return None

return pyrage.x25519.Identity.from_str(identity_string.strip())


def get_identity_from_environment() -> pyrage.x25519.Identity | None:
return (
get_identity_string_from_environment() or get_identity_file_from_environment()
)
9 changes: 7 additions & 2 deletions src/saltstack_age/renderers/age.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ def __virtual__() -> str | tuple[bool, str]: # noqa: N807


def _get_identity() -> pyrage.x25519.Identity:
# 1. Try to get identity file from Salt configuration
# Try to get identity string from Salt configuration
identity_string: str | None = __salt__["config.get"]("age_identity")
if identity_string:
return pyrage.x25519.Identity.from_str(identity_string.strip())

# Try to get identity file from Salt configuration
identity_file_string: str | None = __salt__["config.get"]("age_identity_file")
if identity_file_string:
identity_file_path = Path(identity_file_string)
Expand All @@ -45,7 +50,7 @@ def _get_identity() -> pyrage.x25519.Identity:

return read_identity_file(identity_file_path)

# 2. Try to get identity from the environment
# Try to get identity from the environment
identity = get_identity_from_environment()
if identity:
return identity
Expand Down
21 changes: 19 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
from pathlib import Path

import pyrage
import pytest
from saltstack_age.identities import read_identity_file

ROOT = Path(__file__).parent.parent
EXAMPLE_PATH = ROOT / "example"


@pytest.fixture()
def example_age_key() -> str:
return str(EXAMPLE_PATH / "config" / "age.key")
def example_age_key_path() -> Path:
return EXAMPLE_PATH / "config" / "age.key"


@pytest.fixture()
def example_age_key_path_str(example_age_key_path: Path) -> str:
return str(example_age_key_path)


@pytest.fixture()
def example_age_key(example_age_key_path: Path) -> pyrage.x25519.Identity:
return read_identity_file(example_age_key_path)


@pytest.fixture()
def example_age_key_str(example_age_key: pyrage.x25519.Identity) -> str:
return str(example_age_key)
26 changes: 22 additions & 4 deletions tests/integration/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ def test_encrypt__passphrase(capsys: pytest.CaptureFixture[str]) -> None:

def test_encrypt__single_recipient(
capsys: pytest.CaptureFixture[str],
example_age_key: str,
example_age_key_path_str: str,
) -> None:
# Run the CLI tool
main(["-i", example_age_key, "enc", "foo"])
main(["-i", example_age_key_path_str, "enc", "foo"])
# Ensure we get an identity secure value string
secure_value_string = capsys.readouterr().out
secure_value = parse_secure_value(secure_value_string)
assert isinstance(secure_value, IdentitySecureValue)
# Ensure we can decrypt it using the same identity
assert secure_value.decrypt(read_identity_file(example_age_key)) == "foo"
assert secure_value.decrypt(read_identity_file(example_age_key_path_str)) == "foo"


def test_encrypt__multiple_recipients(
Expand Down Expand Up @@ -71,7 +71,7 @@ def test_encrypt__multiple_recipients(
@pytest.mark.parametrize(
("environment", "args", "result"),
[
# Test decryption using a single identity file
# Test decryption by using an identity file passed as CLI argument
(
None,
(
Expand All @@ -82,6 +82,24 @@ def test_encrypt__multiple_recipients(
),
"test-secret-value",
),
# Test decryption by using an identity file passed through environment
(
{"AGE_IDENTITY_FILE": "example/config/age.key"},
(
"dec",
"ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]",
),
"test-secret-value",
),
# Test decryption by using an identity string passed through environment
(
{"AGE_IDENTITY": str(read_identity_file("example/config/age.key"))},
(
"dec",
"ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]",
),
"test-secret-value",
),
# Test decryption using a passphrase passed through CLI argument
(
None,
Expand Down
7 changes: 5 additions & 2 deletions tests/integration/test_renderer_identity_from_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@


@pytest.fixture()
def minion(salt_factories: FactoriesManager, example_age_key: str) -> SaltMinion:
def minion(
salt_factories: FactoriesManager,
example_age_key_path_str: str,
) -> SaltMinion:
overrides = MINION_CONFIG.copy()
overrides["age_identity_file"] = example_age_key
overrides["age_identity_file"] = example_age_key_path_str
return salt_factories.salt_minion_daemon(
random_string("minion-"),
overrides=overrides,
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_renderer_identity_from_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
def minion(
salt_factories: FactoriesManager,
monkeypatch: pytest.MonkeyPatch,
example_age_key: str,
example_age_key_path_str: str,
) -> SaltMinion:
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key)
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key_path_str)
return salt_factories.salt_minion_daemon(
random_string("minion-"),
overrides=MINION_CONFIG,
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/renderers/test_identity_file_from_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from types import ModuleType
from typing import Any, Callable

import pytest
from saltstack_age.renderers import age

from tests.unit.renderers import _test_identity


@pytest.fixture()
def config_get(example_age_key_path_str: str) -> Callable[[str], str | None]:
def _config_get(key: str) -> str | None:
if key == "age_identity":
return None
assert key == "age_identity_file"
return example_age_key_path_str

return _config_get


@pytest.fixture()
def configure_loader_modules(
config_get: Callable[[str], str | None],
) -> dict[ModuleType, Any]:
return {age: {"__salt__": {"config.get": config_get}}}


def test() -> None:
_test_identity.test()
9 changes: 7 additions & 2 deletions tests/unit/renderers/test_identity_from_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ def configure_loader_modules() -> dict[ModuleType, Any]:
return {age: {"__salt__": {"config.get": lambda _key: None}}}


def test(monkeypatch: pytest.MonkeyPatch, example_age_key: str) -> None:
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key)
def test_file(monkeypatch: pytest.MonkeyPatch, example_age_key_path_str: str) -> None:
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key_path_str)
_test_identity.test()


def test_string(monkeypatch: pytest.MonkeyPatch, example_age_key_str: str) -> None:
monkeypatch.setenv("AGE_IDENTITY", example_age_key_str)
_test_identity.test()
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@


@pytest.fixture()
def config_get(example_age_key: str) -> Callable[[str], str]:
def config_get(example_age_key_str: str) -> Callable[[str], str]:
def _config_get(key: str) -> str:
assert key == "age_identity_file"
return example_age_key
assert key == "age_identity"
return example_age_key_str

return _config_get

Expand Down