Skip to content
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
41 changes: 41 additions & 0 deletions product/infra/helm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,47 @@ for c in evolith-tracker-postgres evolith-tracker-api evolith-tracker-web; do
done
```

## Turning on the transparency ledger (GT-588)

The Tracker can sign every governance decision it records — a COSE_Sign1 statement plus a Merkle
receipt, in the shape RFC 9943 (SCITT) defines — so an auditor can verify the record without
trusting the producer. **It ships OFF**, and turning it on is a deployment decision because it
changes what the product claims about its own audit trail and it requires real key material.

The application **refuses to start** with signing enabled and no seeds. That is deliberate: falling
back to a development key would produce a ledger that looks signed and proves nothing, which is
worse than no ledger because it invites trust.

Two Ed25519 seeds are required, and they must be **different**. RFC 9943 places receipt authority in
an entity separate from the issuer; with one key the receipt is a self-assertion and the governance
rule `AUD-TRANSP-04` rejects it.

```bash
# 1. Generate two 32-byte seeds. Keep them in your secret store — never in git.
kubectl create secret generic tracker-transparency \
--from-literal=Transparency__IssuerKeySeedBase64="$(openssl rand -base64 32)" \
--from-literal=Transparency__TransparencyServiceKeySeedBase64="$(openssl rand -base64 32)"

# 2. A PersistentVolumeClaim for the ledger. A ledger on the pod's ephemeral filesystem is erased
# on every restart, and a transparency log that deletes itself is exactly what the gap calls
# decorative — so the chart REFUSES to render without a claim.
kubectl apply -f your-ledger-pvc.yaml

# 3. Enable it.
helm upgrade --install tracker-api product/infra/helm/evolith-tracker-api \
--set transparency.enabled=true \
--set transparency.existingSecretName=tracker-transparency \
--set transparency.persistence.enabled=true \
--set transparency.persistence.existingClaim=tracker-transparency-ledger
```

Verify with `evolith audit verify` against the ledger path. The governance rule `AUD-TRANSP-01`
fails a ledger that is missing or empty, so a signature that silently stopped being emitted shows
up at the next evaluation rather than never.

**What the chart never does:** generate, default or version key material. Every seed comes from the
deployment's own secret store.

## Notes

- **Connection string.** The .NET config system cannot concatenate a password
Expand Down
10 changes: 10 additions & 0 deletions product/infra/helm/evolith-tracker-api/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ data:
Authentication__Ums__Issuer: {{ .Values.auth.ums.issuer | quote }}
Authentication__Ums__RequireHttpsMetadata: {{ .Values.auth.ums.requireHttpsMetadata | quote }}
Cors__Origins__0: {{ .Values.cors.origins | quote }}
{{- if .Values.transparency.enabled }}
# GT-588 — se emite SOLO cuando la firma esta activa, por la misma razon que Otlp: una clave
# presente en el configmap sugiere que hay expediente firmado cuando no lo hay.
Transparency__Enabled: "true"
Transparency__LedgerPath: {{ .Values.transparency.ledgerPath | quote }}
Transparency__Issuer: {{ .Values.transparency.issuer | quote }}
Transparency__IssuerKeyId: {{ .Values.transparency.issuerKeyId | quote }}
Transparency__TransparencyServiceIssuer: {{ .Values.transparency.transparencyServiceIssuer | quote }}
Transparency__TransparencyServiceKeyId: {{ .Values.transparency.transparencyServiceKeyId | quote }}
{{- end }}
{{- if .Values.otlp.endpoint }}
# T-049 / GT-616 — solo se emite cuando hay colector. Emitirlo vacio seria equivalente a no
# emitirlo (la app hace retorno temprano), pero dejaria una clave en el configmap que sugiere
Expand Down
28 changes: 28 additions & 0 deletions product/infra/helm/evolith-tracker-api/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,22 @@ spec:
name: {{ .Values.agentRuntime.existingSecretName }}
key: {{ .Values.agentRuntime.apiKeyKey }}
{{- end }}
{{- if and .Values.transparency.enabled .Values.transparency.existingSecretName }}
# GT-588 — las semillas Ed25519 llegan del deposito de secretos del despliegue, nunca
# del chart. Si `enabled` esta activo y falta el Secret, la aplicacion FALLA AL
# ARRANCAR: es preferible un pod que no levanta a uno que sirve un expediente sin
# firmar mientras el chart dice que lo firma.
- name: Transparency__IssuerKeySeedBase64
valueFrom:
secretKeyRef:
name: {{ .Values.transparency.existingSecretName }}
key: {{ .Values.transparency.issuerKeySeedKey }}
- name: Transparency__TransparencyServiceKeySeedBase64
valueFrom:
secretKeyRef:
name: {{ .Values.transparency.existingSecretName }}
key: {{ .Values.transparency.transparencyServiceKeySeedKey }}
{{- end }}
- name: TMPDIR
value: /tmp
{{- with .Values.containerSecurityContext }}
Expand Down Expand Up @@ -138,9 +154,21 @@ spec:
successThreshold: {{ .Values.probes.startup.successThreshold }}
{{- end }}
volumeMounts:
{{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }}
- name: transparency-ledger
mountPath: {{ dir .Values.transparency.ledgerPath | quote }}
{{- end }}
# readOnlyRootFilesystem is true; give .NET a writable temp dir.
- name: tmp
mountPath: /tmp
volumes:
{{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }}
# GT-588 — el ledger de transparencia NO puede vivir en un emptyDir: se borraria en cada
# reinicio del pod, y un log de transparencia que se borra solo es exactamente lo que la
# ficha llama decorativo. Se exige un PVC que aporta el despliegue.
- name: transparency-ledger
persistentVolumeClaim:
claimName: {{ required "transparency.persistence.existingClaim es obligatorio cuando la persistencia del ledger esta activa" .Values.transparency.persistence.existingClaim }}
{{- end }}
- name: tmp
emptyDir: {}
28 changes: 28 additions & 0 deletions product/infra/helm/evolith-tracker-api/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,31 @@ networkPolicy:
protocol: TCP
- port: 5432
protocol: TCP

# GT-588 — firma del expediente (RFC 9943 / SCITT). DESACTIVADA por defecto y a proposito:
# encenderla cambia lo que el producto promete sobre su propia auditoria, y exige material de
# clave real. La aplicacion se NIEGA a arrancar con `enabled: true` sin semillas, en vez de caer
# a una clave de desarrollo: un ledger que parece firmado y no prueba nada induce confianza y es
# peor que no tener ledger.
#
# Las semillas NO viven aqui. `existingSecretName` apunta a un Secret que crea el despliegue
# (kubectl/sealed-secrets/KMS); este chart nunca genera ni versiona claves.
transparency:
enabled: false
# Ruta del ledger JSONL. DEBE caer en un volumen persistente: un ledger en el filesystem
# efimero del pod desaparece en cada reinicio, y un log de transparencia que se borra solo es
# exactamente lo que la ficha llama decorativo.
ledgerPath: /var/lib/evolith/transparency/ledger.jsonl
# Volumen del ledger. Sin esto la ruta de arriba vive en el contenedor y se pierde.
persistence:
enabled: false
existingClaim: ""
issuer: evolith-tracker
issuerKeyId: tracker-issuer
# El Servicio de Transparencia es una entidad SEPARADA por RFC 9943. Compartir clave con el
# Issuer convierte el recibo en una autoafirmacion, y `AUD-TRANSP-04` lo rechaza.
transparencyServiceIssuer: evolith-tracker-transparency
transparencyServiceKeyId: tracker-ts
existingSecretName: ""
issuerKeySeedKey: Transparency__IssuerKeySeedBase64
transparencyServiceKeySeedKey: Transparency__TransparencyServiceKeySeedBase64
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
namespace Tracker.Tests.Infrastructure.Transparency;

/// <summary>
/// GT-588 — que la firma del expediente se pueda ENCENDER en un despliegue.
///
/// <para>El cable existía y estaba probado: un decorador de <c>IAuditEntryRepository</c>, un árbol
/// de Merkle, COSE_Sign1, un ledger JSONL y su interop cross-lenguaje en CI. Y sin embargo ninguna
/// decisión se firmaba en ningún entorno, por una razón que no estaba en el código: **el chart no
/// declaraba la palanca**. `Transparency` no aparecía en `values.yaml`, ni en el configmap, ni en
/// el deployment, así que un operador con claves en la mano no tenía por dónde activarla salvo
/// inventando un `--set` sobre una clave inexistente — que Helm acepta en silencio y no enciende
/// nada.</para>
///
/// <para>Estas pruebas leen el chart, no el código. Es a propósito: lo que faltaba era la
/// superficie de operación, y una prueba sobre las clases habría seguido verde todo el tiempo que
/// el gap estuvo abierto. Es la misma lección que `ObservabilityConventionTests` dejó escrita para
/// las trazas — un valor que no está DECLARADO no se documenta ni se revisa.</para>
///
/// <para>Ninguna de ellas enciende la firma. Que un entorno la active con claves reales es una
/// decisión de despliegue y de custodia de secretos, no algo que una prueba deba forzar.</para>
/// </summary>
public class TransparencyOperabilityTests
{
private static string Chart() =>
Path.Combine(RepoRoot(), "product/infra/helm/evolith-tracker-api");

private static string Valores() => File.ReadAllText(Path.Combine(Chart(), "values.yaml"));
private static string ConfigMap() => File.ReadAllText(Path.Combine(Chart(), "templates/configmap.yaml"));
private static string Deployment() => File.ReadAllText(Path.Combine(Chart(), "templates/deployment.yaml"));

[Fact]
public void ElChartDeclaraLaPalancaDeFirma()
{
Valores().Should().Contain("transparency:",
"un `--set transparency.enabled=true` sobre una clave que no existe en values.yaml no "
+ "falla, no se documenta y no se revisa: simplemente no enciende nada");
ConfigMap().Should().Contain("Transparency__Enabled",
"sin esta clave el configmap no puede activar la firma ni aunque el operador tenga claves");
}

[Fact]
public void SigueApagadaPorDefecto()
{
// La guarda existe para que encenderla sea POSIBLE, no para encenderla. Activar la firma
// cambia lo que el producto promete sobre su propio expediente y exige claves reales.
Valores().Should().MatchRegex(@"transparency:\s*\r?\n(\s+#[^\r\n]*\r?\n)*\s+enabled:\s*false",
"el defecto sigue siendo APAGADO");
}

[Fact]
public void LasSemillasVienenDeUnSecretYNuncaDelChart()
{
var deployment = Deployment();

deployment.Should().Contain("Transparency__IssuerKeySeedBase64",
"sin esta variable el pod arranca sin material de clave y la aplicacion se niega a "
+ "firmar — correctamente, pero entonces el chart promete algo que no ocurre");
deployment.Should().Contain("secretKeyRef",
"las semillas Ed25519 llegan del deposito de secretos del despliegue");

// Lo que NO debe estar. Una semilla por defecto convertiria el ledger en algo que parece
// firmado y no prueba nada, que es peor que no tener ledger: induce confianza.
Valores().Should().NotMatchRegex(@"KeySeed\w*:\s*[""']?[A-Za-z0-9+/]{16,}",
"el chart NUNCA versiona material de clave");
}

[Fact]
public void ElLedgerExigeVolumenPersistente()
{
var deployment = Deployment();

deployment.Should().Contain("transparency-ledger",
"el ledger necesita su propio volumen");
deployment.Should().Contain("persistentVolumeClaim",
"un ledger en el filesystem efimero del pod se borra en cada reinicio, y un log de "
+ "transparencia que se borra solo es exactamente lo que la ficha llama decorativo");
deployment.Should().Contain("required \"transparency.persistence.existingClaim",
"pedir persistencia sin PVC debe FALLAR el render, no montar silenciosamente un "
+ "emptyDir con nombre de volumen persistente");
}

[Fact]
public void ElCableSigueSiendoUnDecoradorYNoUnCambioEnCadaLlamante()
{
// Si alguien sustituye el decorador por llamadas explicitas, el quinto camino que se
// añada mañana no firmara y nadie lo notara. Esta prueba es sobre la FORMA del cable.
var registro = File.ReadAllText(Path.Combine(
RepoRoot(),
"src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs"));

registro.Should().Contain("Decorate<IAuditEntryRepository, TransparencySigningAuditRepository>",
"el cable firma los caminos que YA existen y el que alguien añada sin acordarse de "
+ "esta ficha; una lista de llamantes explicitos solo firma los que alguien recordo");
}

private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
{
dir = dir.Parent;
}
dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio");
return dir!.FullName;
}
}
Loading