aevum-spiffe 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aevum_spiffe-0.4.0/.gitignore +49 -0
- aevum_spiffe-0.4.0/PKG-INFO +107 -0
- aevum_spiffe-0.4.0/README.md +85 -0
- aevum_spiffe-0.4.0/pyproject.toml +61 -0
- aevum_spiffe-0.4.0/src/aevum/spiffe/__init__.py +23 -0
- aevum_spiffe-0.4.0/src/aevum/spiffe/complication.py +184 -0
- aevum_spiffe-0.4.0/src/aevum/spiffe/py.typed +0 -0
- aevum_spiffe-0.4.0/tests/test_spiffe_complication.py +225 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Build
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
site/
|
|
13
|
+
|
|
14
|
+
# Tools
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.cache/
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Verify scripts (run locally, never commit)
|
|
32
|
+
verify_*.py
|
|
33
|
+
scripts/verify_*.py
|
|
34
|
+
|
|
35
|
+
# Aevum development — never commit (Phase 0+)
|
|
36
|
+
aevum_principles.key
|
|
37
|
+
signed_principles_draft.yaml
|
|
38
|
+
tools/sign_principles.py
|
|
39
|
+
|
|
40
|
+
# Private keys — never commit
|
|
41
|
+
*.key
|
|
42
|
+
*.pem
|
|
43
|
+
|
|
44
|
+
# OpenSSF Scorecard output (Phase 0+)
|
|
45
|
+
results.sarif
|
|
46
|
+
verify_phase3.py
|
|
47
|
+
verify_phase7.py
|
|
48
|
+
verify_phase8.py
|
|
49
|
+
verify_phase*.py
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-spiffe
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Aevum — SPIFFE/SPIRE cryptographic agent identity complication.
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: aevum-core
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
19
|
+
Provides-Extra: spiffe
|
|
20
|
+
Requires-Dist: spiffe>=0.2.3; extra == 'spiffe'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# aevum-spiffe
|
|
24
|
+
|
|
25
|
+
SPIFFE/SPIRE agent identity complication for Aevum.
|
|
26
|
+
|
|
27
|
+
Provides cryptographically-attested agent identity via JWT-SVIDs from the
|
|
28
|
+
SPIFFE Workload API. When `on_approved()` is called, emits a `spiffe.attested`
|
|
29
|
+
AuditEvent recording the SPIFFE ID and SVID metadata in the sigchain.
|
|
30
|
+
|
|
31
|
+
Requires SPIRE or a compatible SPIFFE Workload API (Vault SPIFFE secrets
|
|
32
|
+
engine, KUDO, etc.) to be running at attestation time.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install aevum-spiffe[spiffe]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from aevum.core import Engine
|
|
40
|
+
from aevum.spiffe import SpiffeComplication
|
|
41
|
+
|
|
42
|
+
engine = Engine()
|
|
43
|
+
comp = SpiffeComplication(
|
|
44
|
+
socket_path="unix:///run/spire/sockets/agent.sock", # optional
|
|
45
|
+
audience=["aevum"], # optional
|
|
46
|
+
)
|
|
47
|
+
engine.install_complication(comp)
|
|
48
|
+
engine.approve_complication("aevum-spiffe")
|
|
49
|
+
comp.on_approved(engine) # emits spiffe.attested into the sigchain
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The chain now contains a signed `spiffe.attested` event:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"event_type": "spiffe.attested",
|
|
57
|
+
"actor": "aevum-spiffe",
|
|
58
|
+
"payload": {
|
|
59
|
+
"spiffe_id": "spiffe://example.org/billing-agent",
|
|
60
|
+
"trust_domain": "example.org",
|
|
61
|
+
"audience": ["aevum"],
|
|
62
|
+
"svid_type": "jwt",
|
|
63
|
+
"source": "workload-api",
|
|
64
|
+
"socket": "unix:///run/spire/sockets/agent.sock",
|
|
65
|
+
"expiry": "2026-05-06T15:00:00+00:00"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Lifecycle note
|
|
71
|
+
|
|
72
|
+
The Aevum Engine does not call lifecycle hooks automatically at approval time.
|
|
73
|
+
After `engine.approve_complication("aevum-spiffe")`, callers must invoke
|
|
74
|
+
`comp.on_approved(engine)` explicitly to trigger attestation. This is the
|
|
75
|
+
correct pattern for all complications that need to act at approval time.
|
|
76
|
+
|
|
77
|
+
## Downstream use
|
|
78
|
+
|
|
79
|
+
Other complications can read the attested SPIFFE ID:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
spiffe_comp = engine.get_active_complication_by_capability("spiffe-identity")
|
|
83
|
+
spiffe_id = spiffe_comp.get_actor_spiffe_id() if spiffe_comp else None
|
|
84
|
+
payload = {"actor_spiffe_id": spiffe_id, ...}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Without SPIRE
|
|
88
|
+
|
|
89
|
+
If the SPIFFE socket is unavailable or py-spiffe is not installed,
|
|
90
|
+
`on_approved()` logs a warning and continues without attestation.
|
|
91
|
+
Engine startup is never blocked.
|
|
92
|
+
|
|
93
|
+
## Trust boundary
|
|
94
|
+
|
|
95
|
+
The SPIFFE ID in the `spiffe.attested` event is cryptographically attested
|
|
96
|
+
by SPIRE's attestation plugins. It is NOT caller-asserted (unlike the `actor`
|
|
97
|
+
field). An auditor can verify the attestation by checking the SVID's parent
|
|
98
|
+
trust chain against the SPIFFE trust bundle.
|
|
99
|
+
|
|
100
|
+
The JWT token itself is NOT stored in the AuditEvent — it expires (typically
|
|
101
|
+
1 hour) and is large. Only the SPIFFE ID string and metadata are recorded.
|
|
102
|
+
|
|
103
|
+
## See also
|
|
104
|
+
|
|
105
|
+
- [ADR-006: SPIFFE integration](../../docs/adrs/adr-006-spiffe-integration.md)
|
|
106
|
+
- [py-spiffe](https://github.com/HewlettPackard/py-spiffe)
|
|
107
|
+
- [SPIFFE specification](https://spiffe.io)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# aevum-spiffe
|
|
2
|
+
|
|
3
|
+
SPIFFE/SPIRE agent identity complication for Aevum.
|
|
4
|
+
|
|
5
|
+
Provides cryptographically-attested agent identity via JWT-SVIDs from the
|
|
6
|
+
SPIFFE Workload API. When `on_approved()` is called, emits a `spiffe.attested`
|
|
7
|
+
AuditEvent recording the SPIFFE ID and SVID metadata in the sigchain.
|
|
8
|
+
|
|
9
|
+
Requires SPIRE or a compatible SPIFFE Workload API (Vault SPIFFE secrets
|
|
10
|
+
engine, KUDO, etc.) to be running at attestation time.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install aevum-spiffe[spiffe]
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from aevum.core import Engine
|
|
18
|
+
from aevum.spiffe import SpiffeComplication
|
|
19
|
+
|
|
20
|
+
engine = Engine()
|
|
21
|
+
comp = SpiffeComplication(
|
|
22
|
+
socket_path="unix:///run/spire/sockets/agent.sock", # optional
|
|
23
|
+
audience=["aevum"], # optional
|
|
24
|
+
)
|
|
25
|
+
engine.install_complication(comp)
|
|
26
|
+
engine.approve_complication("aevum-spiffe")
|
|
27
|
+
comp.on_approved(engine) # emits spiffe.attested into the sigchain
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The chain now contains a signed `spiffe.attested` event:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"event_type": "spiffe.attested",
|
|
35
|
+
"actor": "aevum-spiffe",
|
|
36
|
+
"payload": {
|
|
37
|
+
"spiffe_id": "spiffe://example.org/billing-agent",
|
|
38
|
+
"trust_domain": "example.org",
|
|
39
|
+
"audience": ["aevum"],
|
|
40
|
+
"svid_type": "jwt",
|
|
41
|
+
"source": "workload-api",
|
|
42
|
+
"socket": "unix:///run/spire/sockets/agent.sock",
|
|
43
|
+
"expiry": "2026-05-06T15:00:00+00:00"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Lifecycle note
|
|
49
|
+
|
|
50
|
+
The Aevum Engine does not call lifecycle hooks automatically at approval time.
|
|
51
|
+
After `engine.approve_complication("aevum-spiffe")`, callers must invoke
|
|
52
|
+
`comp.on_approved(engine)` explicitly to trigger attestation. This is the
|
|
53
|
+
correct pattern for all complications that need to act at approval time.
|
|
54
|
+
|
|
55
|
+
## Downstream use
|
|
56
|
+
|
|
57
|
+
Other complications can read the attested SPIFFE ID:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
spiffe_comp = engine.get_active_complication_by_capability("spiffe-identity")
|
|
61
|
+
spiffe_id = spiffe_comp.get_actor_spiffe_id() if spiffe_comp else None
|
|
62
|
+
payload = {"actor_spiffe_id": spiffe_id, ...}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Without SPIRE
|
|
66
|
+
|
|
67
|
+
If the SPIFFE socket is unavailable or py-spiffe is not installed,
|
|
68
|
+
`on_approved()` logs a warning and continues without attestation.
|
|
69
|
+
Engine startup is never blocked.
|
|
70
|
+
|
|
71
|
+
## Trust boundary
|
|
72
|
+
|
|
73
|
+
The SPIFFE ID in the `spiffe.attested` event is cryptographically attested
|
|
74
|
+
by SPIRE's attestation plugins. It is NOT caller-asserted (unlike the `actor`
|
|
75
|
+
field). An auditor can verify the attestation by checking the SVID's parent
|
|
76
|
+
trust chain against the SPIFFE trust bundle.
|
|
77
|
+
|
|
78
|
+
The JWT token itself is NOT stored in the AuditEvent — it expires (typically
|
|
79
|
+
1 hour) and is large. Only the SPIFFE ID string and metadata are recorded.
|
|
80
|
+
|
|
81
|
+
## See also
|
|
82
|
+
|
|
83
|
+
- [ADR-006: SPIFFE integration](../../docs/adrs/adr-006-spiffe-integration.md)
|
|
84
|
+
- [py-spiffe](https://github.com/HewlettPackard/py-spiffe)
|
|
85
|
+
- [SPIFFE specification](https://spiffe.io)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aevum-spiffe"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Aevum — SPIFFE/SPIRE cryptographic agent identity complication."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 3 - Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: Apache Software License",
|
|
12
|
+
"Programming Language :: Python :: 3.11",
|
|
13
|
+
"Typing :: Typed",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"aevum-core",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
spiffe = ["spiffe>=0.2.3"]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"mypy>=1.10",
|
|
24
|
+
"ruff>=0.9",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.entry-points."aevum.complications"]
|
|
28
|
+
spiffe = "aevum.spiffe.complication:SpiffeComplication"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://aevum.build"
|
|
32
|
+
Repository = "https://github.com/aevum-labs/aevum"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/aevum"]
|
|
40
|
+
|
|
41
|
+
[tool.uv.sources]
|
|
42
|
+
aevum-core = { workspace = true }
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
addopts = "--tb=short"
|
|
47
|
+
pythonpath = ["src", "../../packages/aevum-core/src"]
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
strict = true
|
|
51
|
+
python_version = "3.11"
|
|
52
|
+
mypy_path = "src"
|
|
53
|
+
explicit_package_bases = true
|
|
54
|
+
ignore_missing_imports = true
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 100
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
|
|
61
|
+
ignore = ["ANN401"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
aevum.spiffe — SPIFFE/SPIRE cryptographic agent identity complication.
|
|
3
|
+
|
|
4
|
+
Provides cryptographically-attested agent identity via SPIFFE JWT-SVIDs.
|
|
5
|
+
Emits a spiffe.attested AuditEvent when on_approved() is called, recording
|
|
6
|
+
the SPIFFE ID and SVID metadata (not the JWT itself).
|
|
7
|
+
|
|
8
|
+
Requires: pip install aevum-spiffe[spiffe] (installs py-spiffe 0.2.3+)
|
|
9
|
+
|
|
10
|
+
Without py-spiffe installed: importing this module succeeds but
|
|
11
|
+
SpiffeComplication.on_approved() will warn and skip gracefully.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from aevum.spiffe import SpiffeComplication
|
|
15
|
+
engine.install_complication(comp)
|
|
16
|
+
engine.approve_complication("aevum-spiffe")
|
|
17
|
+
comp.on_approved(engine) # emit spiffe.attested event
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from aevum.spiffe.complication import SpiffeComplication
|
|
21
|
+
|
|
22
|
+
__version__ = "0.4.0"
|
|
23
|
+
__all__ = ["SpiffeComplication"]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SpiffeComplication — SPIFFE agent identity via JWT-SVIDs.
|
|
3
|
+
|
|
4
|
+
ADR-006 implementation. See docs/adrs/adr-006-spiffe-integration.md.
|
|
5
|
+
|
|
6
|
+
Lifecycle note: the Engine has no on_approved callback mechanism. Callers
|
|
7
|
+
must invoke comp.on_approved(engine) explicitly after
|
|
8
|
+
engine.approve_complication("aevum-spiffe") to trigger attestation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_SOCKET = "unix:///tmp/spire-agent/public/api.sock"
|
|
21
|
+
_DEFAULT_AUDIENCE = ["aevum"]
|
|
22
|
+
|
|
23
|
+
# Module-level sentinel. Not a top-level import — py-spiffe is imported lazily
|
|
24
|
+
# inside _fetch_svid(). This name exists so tests can patch
|
|
25
|
+
# aevum.spiffe.complication.WorkloadApiClient without triggering a real import.
|
|
26
|
+
WorkloadApiClient: Any = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpiffeComplication:
|
|
30
|
+
"""
|
|
31
|
+
SPIFFE/SPIRE agent identity complication.
|
|
32
|
+
|
|
33
|
+
When on_approved() is called, fetches a JWT-SVID from the SPIFFE Workload
|
|
34
|
+
API and emits a spiffe.attested AuditEvent recording the SPIFFE ID and
|
|
35
|
+
metadata. The JWT token itself is never stored.
|
|
36
|
+
|
|
37
|
+
Failure modes (all non-fatal):
|
|
38
|
+
- py-spiffe not installed: warn and skip
|
|
39
|
+
- SPIFFE socket unavailable: warn and skip
|
|
40
|
+
- Invalid SVID: warn and skip
|
|
41
|
+
|
|
42
|
+
The complication never prevents Engine startup or operation.
|
|
43
|
+
|
|
44
|
+
Engine integration (no automatic lifecycle hook):
|
|
45
|
+
engine.install_complication(comp)
|
|
46
|
+
engine.approve_complication("aevum-spiffe")
|
|
47
|
+
comp.on_approved(engine) # caller must invoke this explicitly
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str = "aevum-spiffe"
|
|
51
|
+
version: str = "0.4.0"
|
|
52
|
+
capabilities: list[str] = ["spiffe-identity"]
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
socket_path: str | None = None,
|
|
57
|
+
audience: list[str] | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._socket = socket_path or os.environ.get(
|
|
60
|
+
"AEVUM_SPIFFE_SOCKET", _DEFAULT_SOCKET
|
|
61
|
+
)
|
|
62
|
+
self._audience = audience or list(_DEFAULT_AUDIENCE)
|
|
63
|
+
self._spiffe_id: str | None = None
|
|
64
|
+
self._trust_domain: str | None = None
|
|
65
|
+
self._attested: bool = False
|
|
66
|
+
|
|
67
|
+
def manifest(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"name": self.name,
|
|
70
|
+
"version": self.version,
|
|
71
|
+
"description": "SPIFFE/SPIRE cryptographic agent identity via JWT-SVIDs",
|
|
72
|
+
"capabilities": list(self.capabilities),
|
|
73
|
+
"classification_max": 0,
|
|
74
|
+
"functions": ["commit"],
|
|
75
|
+
"auth": {"scopes_required": [], "public_key": None},
|
|
76
|
+
"schema_version": "1.0",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def on_approved(self, engine: Any) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Call after engine.approve_complication("aevum-spiffe") to trigger
|
|
84
|
+
SPIFFE attestation and emit the spiffe.attested event.
|
|
85
|
+
|
|
86
|
+
The Engine does not call this automatically; callers must invoke it.
|
|
87
|
+
"""
|
|
88
|
+
self._attest_and_emit(engine)
|
|
89
|
+
|
|
90
|
+
def _attest_and_emit(self, engine: Any) -> None:
|
|
91
|
+
try:
|
|
92
|
+
spiffe_id, trust_domain, expiry = self._fetch_svid()
|
|
93
|
+
except ImportError:
|
|
94
|
+
log.warning(
|
|
95
|
+
"aevum-spiffe: py-spiffe not installed. "
|
|
96
|
+
"Install with: pip install aevum-spiffe[spiffe]. "
|
|
97
|
+
"Continuing without agent identity attestation."
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
log.warning(
|
|
102
|
+
"aevum-spiffe: SPIFFE attestation failed (%s). "
|
|
103
|
+
"Is SPIRE running at %s? "
|
|
104
|
+
"Continuing without agent identity attestation.",
|
|
105
|
+
exc,
|
|
106
|
+
self._socket,
|
|
107
|
+
)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
self._spiffe_id = spiffe_id
|
|
111
|
+
self._trust_domain = trust_domain
|
|
112
|
+
self._attested = True
|
|
113
|
+
|
|
114
|
+
self._write_attested_event(engine, spiffe_id, trust_domain, expiry)
|
|
115
|
+
log.info("aevum-spiffe: attested as %s", spiffe_id)
|
|
116
|
+
|
|
117
|
+
def _fetch_svid(self) -> tuple[str, str, str]:
|
|
118
|
+
"""
|
|
119
|
+
Fetch a JWT-SVID from the SPIFFE Workload API.
|
|
120
|
+
Returns (spiffe_id, trust_domain, expiry_iso8601).
|
|
121
|
+
Raises ImportError if py-spiffe is not installed.
|
|
122
|
+
Raises Exception if attestation fails.
|
|
123
|
+
"""
|
|
124
|
+
import datetime
|
|
125
|
+
|
|
126
|
+
# Honour the module-level name so tests can patch it; fall back to lazy import.
|
|
127
|
+
_module = sys.modules.get(__name__)
|
|
128
|
+
wac = getattr(_module, "WorkloadApiClient", None) if _module else None
|
|
129
|
+
if wac is None:
|
|
130
|
+
from spiffe import WorkloadApiClient as _wac # noqa: PLC0415
|
|
131
|
+
wac = _wac
|
|
132
|
+
|
|
133
|
+
with wac(workload_api_address=self._socket) as client:
|
|
134
|
+
svid = client.fetch_jwt_svid(audiences=self._audience)
|
|
135
|
+
|
|
136
|
+
spiffe_id = str(svid.spiffe_id)
|
|
137
|
+
trust_domain = svid.spiffe_id.trust_domain.name
|
|
138
|
+
expiry_dt = datetime.datetime.fromtimestamp(
|
|
139
|
+
svid.expiry, tz=datetime.UTC
|
|
140
|
+
)
|
|
141
|
+
expiry = expiry_dt.isoformat()
|
|
142
|
+
return spiffe_id, trust_domain, expiry
|
|
143
|
+
|
|
144
|
+
def _write_attested_event(
|
|
145
|
+
self,
|
|
146
|
+
engine: Any,
|
|
147
|
+
spiffe_id: str,
|
|
148
|
+
trust_domain: str,
|
|
149
|
+
expiry: str,
|
|
150
|
+
) -> None:
|
|
151
|
+
try:
|
|
152
|
+
engine._ledger.append(
|
|
153
|
+
event_type="spiffe.attested",
|
|
154
|
+
payload={
|
|
155
|
+
"spiffe_id": spiffe_id,
|
|
156
|
+
"trust_domain": trust_domain,
|
|
157
|
+
"audience": self._audience,
|
|
158
|
+
"svid_type": "jwt",
|
|
159
|
+
"source": "workload-api",
|
|
160
|
+
"socket": self._socket,
|
|
161
|
+
"expiry": expiry,
|
|
162
|
+
},
|
|
163
|
+
actor="aevum-spiffe",
|
|
164
|
+
)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
log.error("aevum-spiffe: failed to write spiffe.attested event: %s", exc)
|
|
167
|
+
|
|
168
|
+
# ── Public API for downstream complications ───────────────────────────────
|
|
169
|
+
|
|
170
|
+
def get_actor_spiffe_id(self) -> str | None:
|
|
171
|
+
"""
|
|
172
|
+
Return the attested SPIFFE ID, or None if not yet attested.
|
|
173
|
+
|
|
174
|
+
Downstream complications can call this to add actor_spiffe_id to
|
|
175
|
+
their event payloads:
|
|
176
|
+
spiffe_comp = engine.get_active_complication_by_capability("spiffe-identity")
|
|
177
|
+
spiffe_id = spiffe_comp.get_actor_spiffe_id() if spiffe_comp else None
|
|
178
|
+
"""
|
|
179
|
+
return self._spiffe_id
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_attested(self) -> bool:
|
|
183
|
+
"""True if SPIFFE attestation succeeded."""
|
|
184
|
+
return self._attested
|
|
File without changes
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for SpiffeComplication.
|
|
3
|
+
Uses mocked SPIFFE WorkloadApiClient — no real SPIRE deployment required.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import sys
|
|
9
|
+
import unittest.mock
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _make_mock_svid(spiffe_id_str: str = "spiffe://example.org/billing") -> unittest.mock.MagicMock:
|
|
13
|
+
"""Build a minimal mock JWT-SVID."""
|
|
14
|
+
mock_svid = unittest.mock.MagicMock()
|
|
15
|
+
mock_spiffe_id = unittest.mock.MagicMock()
|
|
16
|
+
mock_spiffe_id.__str__ = lambda self: spiffe_id_str
|
|
17
|
+
mock_trust_domain = unittest.mock.MagicMock()
|
|
18
|
+
mock_trust_domain.name = "example.org"
|
|
19
|
+
mock_spiffe_id.trust_domain = mock_trust_domain
|
|
20
|
+
mock_svid.spiffe_id = mock_spiffe_id
|
|
21
|
+
mock_svid.expiry = datetime.datetime(
|
|
22
|
+
2026, 5, 6, 14, 0, 0, tzinfo=datetime.UTC
|
|
23
|
+
).timestamp()
|
|
24
|
+
return mock_svid
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_mock_client(svid: unittest.mock.MagicMock) -> unittest.mock.MagicMock:
|
|
28
|
+
"""Build a mock WorkloadApiClient context manager."""
|
|
29
|
+
client = unittest.mock.MagicMock()
|
|
30
|
+
client.__enter__ = unittest.mock.MagicMock(return_value=client)
|
|
31
|
+
client.__exit__ = unittest.mock.MagicMock(return_value=False)
|
|
32
|
+
client.fetch_jwt_svid = unittest.mock.MagicMock(return_value=svid)
|
|
33
|
+
return client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_mock_engine() -> unittest.mock.MagicMock:
|
|
37
|
+
"""Minimal engine mock with a ledger."""
|
|
38
|
+
engine = unittest.mock.MagicMock()
|
|
39
|
+
engine._ledger.append = unittest.mock.MagicMock(return_value=None)
|
|
40
|
+
return engine
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestSpiffeComplicationWithMockSPIRE:
|
|
44
|
+
|
|
45
|
+
def test_on_approved_emits_spiffe_attested(self) -> None:
|
|
46
|
+
"""Successful attestation must emit a spiffe.attested event."""
|
|
47
|
+
from aevum.spiffe import SpiffeComplication
|
|
48
|
+
|
|
49
|
+
mock_svid = _make_mock_svid()
|
|
50
|
+
mock_client = _make_mock_client(mock_svid)
|
|
51
|
+
mock_engine = _make_mock_engine()
|
|
52
|
+
|
|
53
|
+
comp = SpiffeComplication(socket_path="unix:///fake/socket")
|
|
54
|
+
|
|
55
|
+
with unittest.mock.patch(
|
|
56
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
57
|
+
return_value=mock_client,
|
|
58
|
+
):
|
|
59
|
+
comp.on_approved(mock_engine)
|
|
60
|
+
|
|
61
|
+
assert comp.is_attested is True
|
|
62
|
+
assert comp.get_actor_spiffe_id() == "spiffe://example.org/billing"
|
|
63
|
+
|
|
64
|
+
mock_engine._ledger.append.assert_called_once()
|
|
65
|
+
call_kwargs = mock_engine._ledger.append.call_args.kwargs
|
|
66
|
+
assert call_kwargs["event_type"] == "spiffe.attested"
|
|
67
|
+
assert call_kwargs["payload"]["spiffe_id"] == "spiffe://example.org/billing"
|
|
68
|
+
assert call_kwargs["payload"]["trust_domain"] == "example.org"
|
|
69
|
+
assert call_kwargs["actor"] == "aevum-spiffe"
|
|
70
|
+
|
|
71
|
+
def test_missing_spiffe_library_degrades_gracefully(self) -> None:
|
|
72
|
+
"""Missing py-spiffe must warn and not raise."""
|
|
73
|
+
from aevum.spiffe import SpiffeComplication
|
|
74
|
+
|
|
75
|
+
mock_engine = _make_mock_engine()
|
|
76
|
+
comp = SpiffeComplication()
|
|
77
|
+
|
|
78
|
+
with unittest.mock.patch.dict(sys.modules, {"spiffe": None}):
|
|
79
|
+
comp.on_approved(mock_engine)
|
|
80
|
+
|
|
81
|
+
assert comp.is_attested is False
|
|
82
|
+
assert comp.get_actor_spiffe_id() is None
|
|
83
|
+
mock_engine._ledger.append.assert_not_called()
|
|
84
|
+
|
|
85
|
+
def test_socket_unavailable_degrades_gracefully(self) -> None:
|
|
86
|
+
"""Unreachable SPIFFE socket must warn and not raise."""
|
|
87
|
+
from aevum.spiffe import SpiffeComplication
|
|
88
|
+
|
|
89
|
+
mock_engine = _make_mock_engine()
|
|
90
|
+
comp = SpiffeComplication(socket_path="unix:///nonexistent/socket")
|
|
91
|
+
|
|
92
|
+
bad_client = unittest.mock.MagicMock()
|
|
93
|
+
bad_client.__enter__ = unittest.mock.MagicMock(
|
|
94
|
+
side_effect=Exception("Connection refused")
|
|
95
|
+
)
|
|
96
|
+
bad_client.__exit__ = unittest.mock.MagicMock(return_value=False)
|
|
97
|
+
|
|
98
|
+
with unittest.mock.patch(
|
|
99
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
100
|
+
return_value=bad_client,
|
|
101
|
+
):
|
|
102
|
+
comp.on_approved(mock_engine)
|
|
103
|
+
|
|
104
|
+
assert comp.is_attested is False
|
|
105
|
+
mock_engine._ledger.append.assert_not_called()
|
|
106
|
+
|
|
107
|
+
def test_get_actor_spiffe_id_before_attestation(self) -> None:
|
|
108
|
+
"""get_actor_spiffe_id() returns None before attestation."""
|
|
109
|
+
from aevum.spiffe import SpiffeComplication
|
|
110
|
+
|
|
111
|
+
comp = SpiffeComplication()
|
|
112
|
+
assert comp.get_actor_spiffe_id() is None
|
|
113
|
+
|
|
114
|
+
def test_spiffe_id_format_preserved(self) -> None:
|
|
115
|
+
"""SPIFFE ID must be stored verbatim as returned by the SVID."""
|
|
116
|
+
from aevum.spiffe import SpiffeComplication
|
|
117
|
+
|
|
118
|
+
spiffe_id = "spiffe://production.example.com/service/billing-agent-v2"
|
|
119
|
+
mock_svid = _make_mock_svid(spiffe_id)
|
|
120
|
+
mock_client = _make_mock_client(mock_svid)
|
|
121
|
+
mock_engine = _make_mock_engine()
|
|
122
|
+
|
|
123
|
+
comp = SpiffeComplication()
|
|
124
|
+
|
|
125
|
+
with unittest.mock.patch(
|
|
126
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
127
|
+
return_value=mock_client,
|
|
128
|
+
):
|
|
129
|
+
comp.on_approved(mock_engine)
|
|
130
|
+
|
|
131
|
+
assert comp.get_actor_spiffe_id() == spiffe_id
|
|
132
|
+
|
|
133
|
+
call_kwargs = mock_engine._ledger.append.call_args.kwargs
|
|
134
|
+
assert call_kwargs["payload"]["spiffe_id"] == spiffe_id
|
|
135
|
+
assert call_kwargs["payload"]["trust_domain"] == "example.org"
|
|
136
|
+
|
|
137
|
+
def test_audience_passed_to_workload_api(self) -> None:
|
|
138
|
+
"""Custom audience must be forwarded to fetch_jwt_svid."""
|
|
139
|
+
from aevum.spiffe import SpiffeComplication
|
|
140
|
+
|
|
141
|
+
mock_svid = _make_mock_svid()
|
|
142
|
+
mock_client = _make_mock_client(mock_svid)
|
|
143
|
+
mock_engine = _make_mock_engine()
|
|
144
|
+
|
|
145
|
+
comp = SpiffeComplication(audience=["billing-service", "audit"])
|
|
146
|
+
|
|
147
|
+
with unittest.mock.patch(
|
|
148
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
149
|
+
return_value=mock_client,
|
|
150
|
+
):
|
|
151
|
+
comp.on_approved(mock_engine)
|
|
152
|
+
|
|
153
|
+
mock_client.fetch_jwt_svid.assert_called_once_with(
|
|
154
|
+
audiences=["billing-service", "audit"]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_svid_jwt_not_stored(self) -> None:
|
|
158
|
+
"""The JWT token itself must NOT appear in the spiffe.attested payload."""
|
|
159
|
+
from aevum.spiffe import SpiffeComplication
|
|
160
|
+
|
|
161
|
+
mock_svid = _make_mock_svid()
|
|
162
|
+
mock_svid.token = "eyJhbGciOiJFZERTQSJ9.SENSITIVE.CONTENT"
|
|
163
|
+
mock_client = _make_mock_client(mock_svid)
|
|
164
|
+
mock_engine = _make_mock_engine()
|
|
165
|
+
|
|
166
|
+
comp = SpiffeComplication()
|
|
167
|
+
|
|
168
|
+
with unittest.mock.patch(
|
|
169
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
170
|
+
return_value=mock_client,
|
|
171
|
+
):
|
|
172
|
+
comp.on_approved(mock_engine)
|
|
173
|
+
|
|
174
|
+
call_kwargs = mock_engine._ledger.append.call_args.kwargs
|
|
175
|
+
payload_str = str(call_kwargs["payload"])
|
|
176
|
+
assert "eyJ" not in payload_str, "JWT token must not appear in payload"
|
|
177
|
+
assert "SENSITIVE" not in payload_str, "JWT token must not appear in payload"
|
|
178
|
+
|
|
179
|
+
def test_manifest_passes_validation(self) -> None:
|
|
180
|
+
"""manifest() must satisfy ManifestValidator (all required fields present)."""
|
|
181
|
+
from aevum.spiffe import SpiffeComplication
|
|
182
|
+
|
|
183
|
+
sys.path.insert(0, "../../packages/aevum-core/src")
|
|
184
|
+
from aevum.core.complications.manifest_validator import ManifestValidator
|
|
185
|
+
|
|
186
|
+
comp = SpiffeComplication()
|
|
187
|
+
errors = ManifestValidator().validate(comp.manifest())
|
|
188
|
+
assert errors == [], f"Manifest validation errors: {errors}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestSpiffeComplicationWithEngine:
|
|
192
|
+
|
|
193
|
+
def test_spiffe_attested_appears_in_sigchain(self) -> None:
|
|
194
|
+
"""spiffe.attested must be a verifiable, signed event in the chain."""
|
|
195
|
+
# Engine lives in aevum-core; pythonpath configured in pyproject.toml
|
|
196
|
+
from aevum.core import Engine
|
|
197
|
+
|
|
198
|
+
from aevum.spiffe import SpiffeComplication
|
|
199
|
+
|
|
200
|
+
mock_svid = _make_mock_svid()
|
|
201
|
+
mock_client = _make_mock_client(mock_svid)
|
|
202
|
+
|
|
203
|
+
comp = SpiffeComplication()
|
|
204
|
+
engine = Engine()
|
|
205
|
+
|
|
206
|
+
with unittest.mock.patch(
|
|
207
|
+
"aevum.spiffe.complication.WorkloadApiClient",
|
|
208
|
+
return_value=mock_client,
|
|
209
|
+
):
|
|
210
|
+
engine.install_complication(comp)
|
|
211
|
+
engine.approve_complication("aevum-spiffe")
|
|
212
|
+
# Engine has no automatic on_approved hook; caller invokes it.
|
|
213
|
+
comp.on_approved(engine)
|
|
214
|
+
|
|
215
|
+
entries = engine.get_ledger_entries()
|
|
216
|
+
event_types = [e["event_type"] for e in entries]
|
|
217
|
+
|
|
218
|
+
assert "spiffe.attested" in event_types, (
|
|
219
|
+
f"spiffe.attested not in chain. Events: {event_types}"
|
|
220
|
+
)
|
|
221
|
+
assert engine.verify_sigchain() is True, "Chain must be intact after attestation"
|
|
222
|
+
|
|
223
|
+
attested = next(e for e in entries if e["event_type"] == "spiffe.attested")
|
|
224
|
+
assert attested["payload"]["spiffe_id"] == "spiffe://example.org/billing"
|
|
225
|
+
assert attested["actor"] == "aevum-spiffe"
|