Skip to content

Commit 6efd7cf

Browse files
authored
Merge pull request #25 from pmuller/feature/age_identity-config-and-env
feat: allow configuration of age identities using strings
2 parents 9ca9965 + 64b5ee3 commit 6efd7cf

12 files changed

+117
-19
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# saltstack-age change log
22

3+
## Unreleased
4+
5+
* feat: allow configuration of an identity string using the `AGE_IDENTITY`
6+
environment variable and the `age_identity` configuration directive
7+
38
## 0.3.0
49

510
* fix: add support for nested pillar data

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ daemon configuration file, or in the daemon environment.
4343
| Type | Configuration directive | Environment variable | Expected value |
4444
| ------------ | ----------------------- | -------------------- | ---------------------------- |
4545
| identity | `age_identity_file` | `AGE_IDENTITY_FILE` | Path of an age identity file |
46+
| identity | `age_identity` | `AGE_IDENTITY` | An age identity string |
4647
| passphrase | `age_passphrase` | `AGE_PASSPHRASE` | An age passphrase |
4748

4849
You can check this [example configuration](./example/config/minion).

src/saltstack_age/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ def decrypt(arguments: Namespace) -> None:
172172
)
173173
raise SystemExit(-1)
174174

175-
_ = sys.stdout.write(secure_value.decrypt(arguments.identities[0]))
175+
_ = sys.stdout.write(secure_value.decrypt(identities[0]))
176176

177177
else: # isinstance(secure_value, PassphraseSecureValue)
178178
_ = sys.stdout.write(secure_value.decrypt(get_passphrase(arguments)))

src/saltstack_age/identities.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def read_identity_file(path: Path | str) -> pyrage.x25519.Identity:
2020
return pyrage.x25519.Identity.from_str(identity_string)
2121

2222

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

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

3434
return read_identity_file(path)
35+
36+
37+
def get_identity_string_from_environment() -> pyrage.x25519.Identity | None:
38+
identity_string = os.environ.get("AGE_IDENTITY")
39+
40+
if identity_string is None:
41+
return None
42+
43+
return pyrage.x25519.Identity.from_str(identity_string.strip())
44+
45+
46+
def get_identity_from_environment() -> pyrage.x25519.Identity | None:
47+
return (
48+
get_identity_string_from_environment() or get_identity_file_from_environment()
49+
)

src/saltstack_age/renderers/age.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ def __virtual__() -> str | tuple[bool, str]: # noqa: N807
3333

3434

3535
def _get_identity() -> pyrage.x25519.Identity:
36-
# 1. Try to get identity file from Salt configuration
36+
# Try to get identity string from Salt configuration
37+
identity_string: str | None = __salt__["config.get"]("age_identity")
38+
if identity_string:
39+
return pyrage.x25519.Identity.from_str(identity_string.strip())
40+
41+
# Try to get identity file from Salt configuration
3742
identity_file_string: str | None = __salt__["config.get"]("age_identity_file")
3843
if identity_file_string:
3944
identity_file_path = Path(identity_file_string)
@@ -45,7 +50,7 @@ def _get_identity() -> pyrage.x25519.Identity:
4550

4651
return read_identity_file(identity_file_path)
4752

48-
# 2. Try to get identity from the environment
53+
# Try to get identity from the environment
4954
identity = get_identity_from_environment()
5055
if identity:
5156
return identity

tests/conftest.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
11
from pathlib import Path
22

3+
import pyrage
34
import pytest
5+
from saltstack_age.identities import read_identity_file
46

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

810

911
@pytest.fixture()
10-
def example_age_key() -> str:
11-
return str(EXAMPLE_PATH / "config" / "age.key")
12+
def example_age_key_path() -> Path:
13+
return EXAMPLE_PATH / "config" / "age.key"
14+
15+
16+
@pytest.fixture()
17+
def example_age_key_path_str(example_age_key_path: Path) -> str:
18+
return str(example_age_key_path)
19+
20+
21+
@pytest.fixture()
22+
def example_age_key(example_age_key_path: Path) -> pyrage.x25519.Identity:
23+
return read_identity_file(example_age_key_path)
24+
25+
26+
@pytest.fixture()
27+
def example_age_key_str(example_age_key: pyrage.x25519.Identity) -> str:
28+
return str(example_age_key)

tests/integration/test_cli.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ def test_encrypt__passphrase(capsys: pytest.CaptureFixture[str]) -> None:
2525

2626
def test_encrypt__single_recipient(
2727
capsys: pytest.CaptureFixture[str],
28-
example_age_key: str,
28+
example_age_key_path_str: str,
2929
) -> None:
3030
# Run the CLI tool
31-
main(["-i", example_age_key, "enc", "foo"])
31+
main(["-i", example_age_key_path_str, "enc", "foo"])
3232
# Ensure we get an identity secure value string
3333
secure_value_string = capsys.readouterr().out
3434
secure_value = parse_secure_value(secure_value_string)
3535
assert isinstance(secure_value, IdentitySecureValue)
3636
# Ensure we can decrypt it using the same identity
37-
assert secure_value.decrypt(read_identity_file(example_age_key)) == "foo"
37+
assert secure_value.decrypt(read_identity_file(example_age_key_path_str)) == "foo"
3838

3939

4040
def test_encrypt__multiple_recipients(
@@ -71,7 +71,7 @@ def test_encrypt__multiple_recipients(
7171
@pytest.mark.parametrize(
7272
("environment", "args", "result"),
7373
[
74-
# Test decryption using a single identity file
74+
# Test decryption by using an identity file passed as CLI argument
7575
(
7676
None,
7777
(
@@ -82,6 +82,24 @@ def test_encrypt__multiple_recipients(
8282
),
8383
"test-secret-value",
8484
),
85+
# Test decryption by using an identity file passed through environment
86+
(
87+
{"AGE_IDENTITY_FILE": "example/config/age.key"},
88+
(
89+
"dec",
90+
"ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]",
91+
),
92+
"test-secret-value",
93+
),
94+
# Test decryption by using an identity string passed through environment
95+
(
96+
{"AGE_IDENTITY": str(read_identity_file("example/config/age.key"))},
97+
(
98+
"dec",
99+
"ENC[age-identity,YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWHZYRkU2bjc4M2VtaElEZGxudmkwNW95ZHlNZy84K3U4MmlXejIzRkJNCktPbkhLU0h4VXBFYTZUUDlzbFFzdUx5R1VyaDZhd2doNkE2QnFpUmV6OFEKLS0tIFd3Wlg1UWQ3NHEwKyt6bTZkdmp3bWRCTTZkakppTFovbkhBcDhFeGdJazgKnf48DyGjBm2wOpM11YZ0+1btASDDSdgqXiM4SXXEMHhylmW8G9pSoTtovj0aZu9QVA==]",
100+
),
101+
"test-secret-value",
102+
),
85103
# Test decryption using a passphrase passed through CLI argument
86104
(
87105
None,

tests/integration/test_renderer_identity_from_config.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88

99

1010
@pytest.fixture()
11-
def minion(salt_factories: FactoriesManager, example_age_key: str) -> SaltMinion:
11+
def minion(
12+
salt_factories: FactoriesManager,
13+
example_age_key_path_str: str,
14+
) -> SaltMinion:
1215
overrides = MINION_CONFIG.copy()
13-
overrides["age_identity_file"] = example_age_key
16+
overrides["age_identity_file"] = example_age_key_path_str
1417
return salt_factories.salt_minion_daemon(
1518
random_string("minion-"),
1619
overrides=overrides,

tests/integration/test_renderer_identity_from_environment.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
def minion(
1212
salt_factories: FactoriesManager,
1313
monkeypatch: pytest.MonkeyPatch,
14-
example_age_key: str,
14+
example_age_key_path_str: str,
1515
) -> SaltMinion:
16-
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key)
16+
monkeypatch.setenv("AGE_IDENTITY_FILE", example_age_key_path_str)
1717
return salt_factories.salt_minion_daemon(
1818
random_string("minion-"),
1919
overrides=MINION_CONFIG,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from types import ModuleType
2+
from typing import Any, Callable
3+
4+
import pytest
5+
from saltstack_age.renderers import age
6+
7+
from tests.unit.renderers import _test_identity
8+
9+
10+
@pytest.fixture()
11+
def config_get(example_age_key_path_str: str) -> Callable[[str], str | None]:
12+
def _config_get(key: str) -> str | None:
13+
if key == "age_identity":
14+
return None
15+
assert key == "age_identity_file"
16+
return example_age_key_path_str
17+
18+
return _config_get
19+
20+
21+
@pytest.fixture()
22+
def configure_loader_modules(
23+
config_get: Callable[[str], str | None],
24+
) -> dict[ModuleType, Any]:
25+
return {age: {"__salt__": {"config.get": config_get}}}
26+
27+
28+
def test() -> None:
29+
_test_identity.test()

0 commit comments

Comments
 (0)