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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@

### Fixed

- **`ankra cluster encrypt manifest --set` can rotate a value that is
already sealed.** Setting a new value on a sealed key (for example a
registry robot's `.dockerconfigjson`) failed with `encryption failed:
Failed to encrypt content`, so the only way to rotate a secret was to
decrypt it into Git first or create a new manifest. The CLI now unseals
the manifest in memory, applies the `--set` edits, and seals the result
again, keeping every key the previous seal covered sealed. The plaintext
never reaches Git.

- **`ankra application ship` no longer deploys a branch you did not ask
for.** It reused any application already registered for the repository, so
`ship . --branch main` on a repository whose application tracked
Expand Down
85 changes: 85 additions & 0 deletions cmd/cluster_encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -1160,11 +1160,18 @@ func runEncryptManifestCluster(cmd *cobra.Command, manifestName string, leafKeys
return fmt.Errorf("fetch current manifest content: %w", err)
}

var previouslySealedKeys []string
if len(encryptSetEntries) > 0 {
assignments, assignErr := collectSetAssignments(encryptSetEntries, nil, nil)
if assignErr != nil {
return assignErr
}
unsealedB64, sealedKeys, unsealErr := unsealManifestForSet(encoded)
if unsealErr != nil {
return unsealErr
}
encoded = unsealedB64
previouslySealedKeys = sealedKeys
mutatedB64, setErr := applyManifestSet(encoded, assignments, "", "")
if setErr != nil {
return setErr
Expand All @@ -1190,6 +1197,11 @@ func runEncryptManifestCluster(cmd *cobra.Command, manifestName string, leafKeys
}
leafKeys = plaintextKeys
}
stillPresentSealedKeys, presenceErr := keysPresentInManifest(decoded, previouslySealedKeys)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Confirm decoded reflects the post-set manifest content

keysPresentInManifest is called on decoded, but the diff does not show where decoded is assigned relative to the encoded = unsealedB64 / applyManifestSet mutations above. If decoded is derived from the pre-set or pre-unseal content, the presence filter would run against the wrong document (e.g. still-sealed ENC[...] scalars, which would make every previously sealed key look present). Please verify decoded is decoded from the final mutated base64.

Ankra AI review. Read-only and advisory.

if presenceErr != nil {
return presenceErr
}
leafKeys = unionEncryptedPaths(leafKeys, stillPresentSealedKeys)

_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Encrypting %s in manifest %q (stack %q)...\n", describeEncryptKeys(leafKeys), manifestName, stack.Name)
pathsToSeal := pathsToSealWithDeclared(cmd.OutOrStdout(), leafKeys, manifest.EncryptedPaths)
Expand Down Expand Up @@ -1239,6 +1251,79 @@ func runEncryptManifestCluster(cmd *cobra.Command, manifestName string, leafKeys
return nil
}

// unsealManifestForSet decrypts an already sealed manifest in memory so --set
// can replace a sealed value. Editing the sealed document directly leaves the
// old sops metadata and MAC over a value that is now plaintext, which the
// platform cannot decrypt to re-seal ("Failed to encrypt content"). The keys
// the previous seal covered are returned so the caller seals them again; the
// plaintext only travels to the platform's encrypt endpoint, never to Git.
// A manifest with no sops metadata is returned unchanged.
func unsealManifestForSet(manifestBase64 string) (unsealedBase64 string, sealedKeys []string, err error) {
rawManifest, err := base64.StdEncoding.DecodeString(manifestBase64)
if err != nil {
return "", nil, fmt.Errorf("base64-decode manifest content: %w", err)
}
sealedKeys, isSopsDocument, err := deriveSopsEncryptedPaths(rawManifest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Behavior of deriveSopsEncryptedPaths and unionEncryptedPaths not visible in diff

The correctness of the fix rests on deriveSopsEncryptedPaths returning the exact set of paths the previous seal covered (and reliably distinguishing sops documents from plaintext) and on unionEncryptedPaths deduplicating/normalizing paths consistently with how leafKeys are built elsewhere. Neither helper is shown in this diff, so I cannot verify edge cases such as nested paths, duplicate entries, or a sops document whose metadata lists paths differently from the --key flag format.

Ankra AI review. Read-only and advisory.

if err != nil {
return "", nil, err
}
if !isSopsDocument {
return manifestBase64, nil, nil
}
decrypted, err := apiClient.DecryptYAML(string(rawManifest))
if err != nil {
return "", nil, fmt.Errorf("decrypt sealed manifest before applying --set: %w", err)
}
return base64.StdEncoding.EncodeToString([]byte(decrypted)), sealedKeys, nil
}

// keysPresentInManifest keeps the keys that still name a scalar value in the
// manifest, so a key the previous seal covered but a --set edit removed is not
// handed to encrypt as a path to seal.
func keysPresentInManifest(manifestYAML []byte, keys []string) ([]string, error) {
if len(keys) == 0 {
return nil, nil
}
presentKeys := map[string]bool{}
decoder := yaml.NewDecoder(bytes.NewReader(manifestYAML))
for {
var document yaml.Node
decodeErr := decoder.Decode(&document)
if errors.Is(decodeErr, io.EOF) {
break
}
if decodeErr != nil {
return nil, fmt.Errorf("parse manifest YAML: %w", decodeErr)
}
collectScalarKeyNames(&document, presentKeys)
}
kept := make([]string, 0, len(keys))
for _, key := range keys {
if presentKeys[key] {
kept = append(kept, key)
}
}
return kept, nil
}

func collectScalarKeyNames(node *yaml.Node, presentKeys map[string]bool) {
switch node.Kind {
case yaml.DocumentNode, yaml.SequenceNode:
for _, childNode := range node.Content {
collectScalarKeyNames(childNode, presentKeys)
}
case yaml.MappingNode:
for entryIndex := 0; entryIndex+1 < len(node.Content); entryIndex += 2 {
valueNode := node.Content[entryIndex+1]
if valueNode.Kind == yaml.ScalarNode {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] Null-valued keys count as present and will be re-sealed

A mapping entry whose value is an explicit null (password: with no value) is a ScalarNode, so it is treated as present and kept in the seal list. Sealing a null path is likely to fail or produce a confusing encrypted_paths entry; consider skipping scalar nodes with the null tag.

Ankra AI review. Read-only and advisory.

presentKeys[node.Content[entryIndex].Value] = true
Comment on lines +1309 to +1319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] Presence check matches key names globally, not the edited path

collectScalarKeyNames records every scalar mapping key by bare name across all documents and nesting levels, so keysPresentInManifest keeps a previously sealed key if that name appears anywhere in the manifest - e.g. a password field in an unrelated ConfigMap document or a nested config section - even when the --set edit removed the actual sealed entry under data. The key would then be handed to encrypt as a path to seal and recorded in encrypted_paths although the intended value is gone (and a same-named but different value may get sealed instead). Consider tracking the full path of sealed keys (or at least scoping the presence check to the same parent path the seal covered) rather than matching on the leaf name alone.

Ankra AI review. Read-only and advisory.

continue
}
collectScalarKeyNames(valueNode, presentKeys)
}
}
}

func runEncryptAddonCluster(cmd *cobra.Command, addonName string, leafKeys []string) error {
// The command ends in a partial-stack PATCH, which the backend serves
// synchronously (DB transaction + a full GitOps commit/push when the
Expand Down
155 changes: 155 additions & 0 deletions cmd/cluster_encrypt_set_sealed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package cmd

import (
"bytes"
"encoding/base64"
"errors"
"strings"
"testing"
)

// A --set on a manifest whose value is already sealed edited the sealed
// document in place, leaving the old sops metadata and MAC over a value that
// was now plaintext; the platform could not decrypt it to re-seal and every
// rotation failed with "Failed to encrypt content". The CLI now unseals the
// manifest in memory first and seals the edited plaintext afresh.

func runEncryptManifestOnSealedSecret(t *testing.T, mock *upgradeMock, extraArgs ...string) (string, error) {
t.Helper()
setMockClient(t, mock)
resetUpgradeCommandFlags(t)

cmd := rootCmd
out := new(bytes.Buffer)
cmd.SetOut(out)
cmd.SetErr(out)
arguments := append([]string{"cluster", "encrypt", "manifest", "demo-namespace", "--cluster", fakeClusterUUID}, extraArgs...)
cmd.SetArgs(arguments)
executeError := cmd.Execute()
return out.String(), executeError
}

func TestRunEncryptManifest_SetRotatesAnAlreadySealedValue(t *testing.T) {
mock := &upgradeMock{
iac: sampleIaCYAMLForCmd,
manifestB64: base64.StdEncoding.EncodeToString([]byte(encryptedSecretManifestYAML)),
decryptResult: plainSecretManifestYAML,
encryptResult: encryptedSecretManifestYAML,
}
output, executeError := runEncryptManifestOnSealedSecret(t, mock,
"--key", "password",
"--set", "data.password=cm90YXRlZA==",
)
if executeError != nil {
t.Fatalf("execute failed: %v\noutput: %s", executeError, output)
}

if len(mock.encryptCalls) != 1 {
t.Fatalf("expected exactly one EncryptYAML call, got %d", len(mock.encryptCalls))
}
sentContent := mock.encryptCalls[0].YamlContent
if strings.Contains(sentContent, "sops:") || strings.Contains(sentContent, "ENC[") {
t.Errorf("the content sent to encrypt still carries the old seal:\n%s", sentContent)
}
if !strings.Contains(sentContent, "password: cm90YXRlZA==") {
t.Errorf("the content sent to encrypt does not carry the new value:\n%s", sentContent)
}
if len(mock.capturedRequests) != 1 {
t.Fatalf("expected one PATCH, got %d", len(mock.capturedRequests))
}
}

func TestRunEncryptManifest_SetOnASealedManifestKeepsEveryPreviouslySealedKeySealed(t *testing.T) {
mock := &upgradeMock{
iac: sampleIaCYAMLForCmd,
manifestB64: base64.StdEncoding.EncodeToString([]byte(encryptedSecretManifestYAML)),
decryptResult: plainSecretManifestYAML,
encryptResult: bothKeysEncryptedSecretManifestYAML,
}
output, executeError := runEncryptManifestOnSealedSecret(t, mock,
"--key", "username",
"--set", "data.username=cm9vdA==",
)
if executeError != nil {
t.Fatalf("execute failed: %v\noutput: %s", executeError, output)
}

callPaths := mock.encryptCalls[0].EncryptedPaths
if !containsEncryptedPath(callPaths, "username") || !containsEncryptedPath(callPaths, "password") {
t.Errorf("encrypted paths = %v, want the requested key and the previously sealed password", callPaths)
}
manifest := mock.capturedRequests[0].Body.Spec.Stacks[0].Manifests[0]
if !containsEncryptedPath(manifest.EncryptedPaths, "password") {
t.Errorf("manifest.encrypted_paths = %v, want password recorded", manifest.EncryptedPaths)
}
}

func TestRunEncryptManifest_SetOnAPlaintextManifestDoesNotDecrypt(t *testing.T) {
mock := &upgradeMock{
iac: sampleIaCYAMLForCmd,
manifestB64: base64.StdEncoding.EncodeToString([]byte(plainSecretManifestYAML)),
decryptErr: errors.New("decrypt must not be called"),
encryptResult: encryptedSecretManifestYAML,
}
output, executeError := runEncryptManifestOnSealedSecret(t, mock,
"--key", "password",
"--set", "data.password=bmV3",
)
if executeError != nil {
t.Fatalf("execute failed: %v\noutput: %s", executeError, output)
}
if paths := mock.encryptCalls[0].EncryptedPaths; len(paths) != 1 || paths[0] != "password" {
t.Errorf("encrypted paths = %v, want [password]", paths)
}
}

func TestRunEncryptManifest_SetOnASealedManifestReportsADecryptFailure(t *testing.T) {
mock := &upgradeMock{
iac: sampleIaCYAMLForCmd,
manifestB64: base64.StdEncoding.EncodeToString([]byte(encryptedSecretManifestYAML)),
decryptErr: errors.New("key mismatch"),
}
output, executeError := runEncryptManifestOnSealedSecret(t, mock,
"--key", "password",
"--set", "data.password=bmV3",
)
if executeError == nil {
t.Fatalf("expected an error, got none\noutput: %s", output)
}
if !strings.Contains(executeError.Error(), "decrypt sealed manifest before applying --set") {
t.Errorf("error = %v, want it to name the decrypt step", executeError)
}
if len(mock.encryptCalls) != 0 || len(mock.capturedRequests) != 0 {
t.Errorf("nothing should be encrypted or pushed after a failed decrypt")
}
}

const sealedSecretWithDroppedPasswordYAML = `apiVersion: v1
kind: Secret
metadata:
name: my-secret
namespace: web
type: Opaque
data:
username: YWRtaW4=
`

func TestRunEncryptManifest_SetDoesNotSealAPreviouslySealedKeyTheEditRemoved(t *testing.T) {
mock := &upgradeMock{
iac: sampleIaCYAMLForCmd,
manifestB64: base64.StdEncoding.EncodeToString([]byte(encryptedSecretManifestYAML)),
decryptResult: sealedSecretWithDroppedPasswordYAML,
encryptResult: bothKeysEncryptedSecretManifestYAML,
}
output, executeError := runEncryptManifestOnSealedSecret(t, mock,
"--key", "username",
"--set", "data.username=cm9vdA==",
)
if executeError != nil {
t.Fatalf("execute failed: %v\noutput: %s", executeError, output)
}
callPaths := mock.encryptCalls[0].EncryptedPaths
if containsEncryptedPath(callPaths, "password") {
t.Errorf("encrypted paths = %v, want password left out: the edited manifest no longer has it", callPaths)
}
}
Loading