Skip to content

feat(secrets): implement native secret management with encryption and… - #2

Merged
rezaqomy merged 3 commits into
masterfrom
feature/secret-manager
May 31, 2026
Merged

feat(secrets): implement native secret management with encryption and…#2
rezaqomy merged 3 commits into
masterfrom
feature/secret-manager

Conversation

@rezaqomy

@rezaqomy rezaqomy commented May 31, 2026

Copy link
Copy Markdown
Owner

… storage

Summary by CodeRabbit

  • New Features

    • Native secret management with envelope encryption, validation rules, immutability controls, secret references, and in-memory and file-backed encrypted stores.
  • Documentation

    • New docs describing secret resource model, encryption approach, storage backends, validation rules, and resolution patterns; README wording updated.
  • Tests

    • Added unit tests covering encryption, storage backends, validation, and redaction behavior.

@rezaqomy

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds FlowForge native secret management: secret resource model and normalization, validation for create/update (including immutability and typed secrets), redaction helper, AES-256-GCM envelope encryption with MasterKeyProvider, EncryptedMemoryStore and EncryptedFileStore backends, tests, and documentation.

Changes

Secret Management Implementation

Layer / File(s) Summary
Data Model, Error Definitions, and Redaction
internal/secrets/model.go, internal/secrets/errors.go, internal/secrets/redact.go, internal/secrets/redact_test.go
Define SecretResource, Metadata, SecretRef, and SecretType constants; implement normalization (merging StringData into Data, defaulting to TypeOpaque); define sentinel errors; add Redactor and test.
Envelope Encryption and Tests
internal/secrets/crypto.go, internal/secrets/crypto_test.go
Implement MasterKeyProvider and StaticKeyProvider; GenerateMasterKey; EnvelopeCipher using AES-256-GCM envelope encryption with authenticated AAD; include tests for round-trip, tamper detection, and key-length validation.
Validation and Tests
internal/secrets/validation.go, internal/secrets/validation_test.go
Implement ValidateCreate and ValidateUpdate enforcing DNS-label naming, data key format, total size limit (MaxSecretSize = 1MB), typed-secret required keys (e.g., api-key), and immutability rules; add tests covering these behaviors.
In-Memory Encrypted Storage
internal/secrets/store.go, internal/secrets/store_test.go
Define Store interface; implement EncryptedMemoryStore with RWMutex-protected encrypted map, normalization/validation, encryption/decryption via EnvelopeCipher, defensive deep copies, Resolve, and tests for CRUD/immutability/copying.
File-Based Encrypted Storage
internal/secrets/file_store.go, internal/secrets/file_store_test.go
Implement EncryptedFileStore persisting EncryptedSecret JSON files with directory creation (0700), atomic temp-file + rename writes, file mode 0600, exclusive-create mapping to ErrSecretExists, path mapping <dir>/<name>.json, and tests verifying encryption, update/delete, and invalid names.
Documentation and README
README.md, docs/architecture.md, docs/secrets.md
Update README wording to “declarative YAML resources”, add internal/secrets/ to project layout, add Secret Management doc link; add architecture note and comprehensive docs/secrets.md covering goals, resource model, validation, envelope encryption, backends, and resolution guidance.

Sequence Diagrams

sequenceDiagram
  participant Input as SecretResource
  participant Cipher as EnvelopeCipher
  participant MasterKey as MasterKeyProvider
  participant Encrypted as EncryptedSecret
  Input->>Cipher: Encrypt(secret)
  Cipher->>Cipher: Normalize & Marshal Data JSON
  Cipher->>Cipher: Generate random DataKey
  Cipher->>MasterKey: MasterKey()
  MasterKey-->>Cipher: Master Key (32 bytes)
  Cipher->>Cipher: Seal DataKey with Master Key (AES-GCM)
  Cipher->>Cipher: Seal plaintext with DataKey (AES-GCM, AAD)
  Cipher-->>Encrypted: EncryptedSecret (ciphertext, nonces, meta)
  Encrypted->>Cipher: Decrypt(encrypted)
  Cipher->>MasterKey: MasterKey()
  MasterKey-->>Cipher: Master Key
  Cipher->>Cipher: Unseal DataKey
  Cipher->>Cipher: Unseal plaintext JSON -> SecretResource
  Cipher-->>Input: SecretResource
Loading
sequenceDiagram
  participant Client
  participant Store as EncryptedMemoryStore
  participant Cipher as EnvelopeCipher
  participant Memory as EncryptedMap
  Client->>Store: Create(SecretResource)
  Store->>Store: Validate & Normalize
  Store->>Cipher: Encrypt(secret)
  Cipher-->>Store: EncryptedSecret
  Store->>Memory: Store by name
  Store-->>Client: ok / error
  Client->>Store: Get(name)
  Store->>Memory: Retrieve EncryptedSecret
  Store->>Cipher: Decrypt(encrypted)
  Cipher-->>Store: SecretResource
  Store->>Store: Return deep copy
  Store-->>Client: SecretResource
  Client->>Store: Resolve(SecretRef)
  Store->>Store: Get(name) -> return key bytes or ErrSecretKeyMissing
  Store-->>Client: []byte or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Soft paws tap the keys,

Secrets wrapped in careful squeeze,
AES hugs each little byte,
Files and memory tucked in tight,
FlowForge hums — sleep safe tonight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically identifies the main feature: native secret management with encryption and storage, which aligns with the core changes across all files in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/secret-manager

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
internal/secrets/file_store.go (1)

131-138: 💤 Low value

Handle or explicitly discard Close() errors in error paths.

Static analysis flags unchecked tmp.Close() calls on lines 132 and 136. While these are in error-return paths where the original error takes precedence, explicitly discarding or logging helps satisfy linters and documents intent.

Proposed fix
 	if _, err := tmp.Write(data); err != nil {
-		tmp.Close()
+		_ = tmp.Close()
 		return err
 	}
 	if err := tmp.Chmod(0600); err != nil {
-		tmp.Close()
+		_ = tmp.Close()
 		return err
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/secrets/file_store.go` around lines 131 - 138, The error paths that
call tmp.Close() after tmp.Write and tmp.Chmod should explicitly handle or
discard the Close() error to satisfy linters and document intent: update the
error-return branches in the function using the tmp file (references: tmp.Write,
tmp.Chmod, tmp.Close) so that Close() is either deferred earlier (e.g., defer
tmp.Close() with proper handling) or its return value is explicitly
ignored/logged (e.g., _ = tmp.Close() or processLogger.Warnf("tmp.Close failed:
%v", err)). Ensure the original error is still returned but the Close() result
is not left unchecked.
internal/secrets/validation_test.go (1)

62-78: ⚡ Quick win

Add a regression test for immutable flag downgrade (true -> false).

Current tests cover data mutation but not removing immutability itself, which should also be rejected.

Suggested test case
+func TestValidateUpdateRejectsImmutableFlagRemoval(t *testing.T) {
+	oldSecret := SecretResource{
+		Metadata:  Metadata{Name: "service-credential"},
+		Data:      map[string][]byte{"api-key": []byte("same")},
+		Immutable: true,
+	}
+	newSecret := SecretResource{
+		Metadata:  Metadata{Name: "service-credential"},
+		Data:      map[string][]byte{"api-key": []byte("same")},
+		Immutable: false,
+	}
+
+	err := ValidateUpdate(oldSecret, newSecret)
+	if !errors.Is(err, ErrSecretImmutable) {
+		t.Fatalf("ValidateUpdate() error = %v, want ErrSecretImmutable", err)
+	}
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/secrets/validation_test.go` around lines 62 - 78, Add a regression
test that ensures downgrading the Immutable flag (true -> false) is rejected:
create an old SecretResource with Immutable: true and a new SecretResource with
the same Metadata/Data but Immutable: false, call ValidateUpdate(oldSecret,
newSecret) and assert the returned error satisfies ErrSecretImmutable; place
this alongside the existing TestValidateUpdateRejectsImmutableDataChange to
cover the immutable-flag-downgrade case using the same ValidateUpdate,
SecretResource, Metadata and ErrSecretImmutable symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/secrets.md`:
- Line 25: Replace the example value for the "api-key" entry (currently
"123456:token") with a clearly fake, non-secret placeholder such as
"<api-key-placeholder>" so readers won't accidentally copy a real-looking token;
update the value for the api-key key in docs/secrets.md to an unmistakable
placeholder string.

In `@internal/secrets/crypto.go`:
- Around line 39-41: NewEnvelopeCipher currently allows creating an
EnvelopeCipher with a nil MasterKeyProvider which later causes a panic when
Encrypt/Decrypt call c.provider.MasterKey(); update NewEnvelopeCipher to
validate the provider argument (in NewEnvelopeCipher(provider
MasterKeyProvider)) and fail fast if nil—either return an error by changing the
signature to NewEnvelopeCipher(provider MasterKeyProvider) (*EnvelopeCipher,
error) or, if API change is unacceptable, panic with a clear message like "nil
MasterKeyProvider passed to NewEnvelopeCipher"; ensure the EnvelopeCipher
construction is only performed when provider != nil and update any callers/tests
to handle the new behavior.

In `@internal/secrets/file_store.go`:
- Around line 21-37: The Create method in EncryptedFileStore has a TOCTOU race
between os.Stat(s.path(...)) and s.write(...)—replace the two-step check+write
with an atomic create: either add a writeExclusive helper (or extend s.write)
that opens the target path with os.OpenFile using O_CREATE|O_EXCL and
appropriate file mode, write the already-encrypted data returned by
s.cipher.Encrypt(normalized) to that file, close it, and return ErrSecretExists
if open fails due to existing file; ensure proper error handling/cleanup on
partial writes and propagate other errors unchanged so EncryptedFileStore.Create
no longer relies on the prior os.Stat check.

In `@internal/secrets/validation.go`:
- Around line 40-45: When oldNormalized.Immutable is true, the current logic
allows newSecret.Immutable to be flipped to false if the data and type are
unchanged; update the check in the validation branch that uses oldNormalized and
newNormalized so it also verifies the Immutable flag matches (i.e., require
newNormalized.Immutable == true when oldNormalized.Immutable == true) and return
ErrSecretImmutable if Immutable differs or any of data/type differ; ensure the
comparison uses the existing variables newSecret.Normalized(), oldNormalized,
newNormalized and still returns ErrSecretImmutable on any mismatch.

---

Nitpick comments:
In `@internal/secrets/file_store.go`:
- Around line 131-138: The error paths that call tmp.Close() after tmp.Write and
tmp.Chmod should explicitly handle or discard the Close() error to satisfy
linters and document intent: update the error-return branches in the function
using the tmp file (references: tmp.Write, tmp.Chmod, tmp.Close) so that Close()
is either deferred earlier (e.g., defer tmp.Close() with proper handling) or its
return value is explicitly ignored/logged (e.g., _ = tmp.Close() or
processLogger.Warnf("tmp.Close failed: %v", err)). Ensure the original error is
still returned but the Close() result is not left unchecked.

In `@internal/secrets/validation_test.go`:
- Around line 62-78: Add a regression test that ensures downgrading the
Immutable flag (true -> false) is rejected: create an old SecretResource with
Immutable: true and a new SecretResource with the same Metadata/Data but
Immutable: false, call ValidateUpdate(oldSecret, newSecret) and assert the
returned error satisfies ErrSecretImmutable; place this alongside the existing
TestValidateUpdateRejectsImmutableDataChange to cover the
immutable-flag-downgrade case using the same ValidateUpdate, SecretResource,
Metadata and ErrSecretImmutable symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 54563f93-0e02-406b-807c-68331422f861

📥 Commits

Reviewing files that changed from the base of the PR and between a7d32e0 and cce4a21.

📒 Files selected for processing (15)
  • README.md
  • docs/architecture.md
  • docs/secrets.md
  • internal/secrets/crypto.go
  • internal/secrets/crypto_test.go
  • internal/secrets/errors.go
  • internal/secrets/file_store.go
  • internal/secrets/file_store_test.go
  • internal/secrets/model.go
  • internal/secrets/redact.go
  • internal/secrets/redact_test.go
  • internal/secrets/store.go
  • internal/secrets/store_test.go
  • internal/secrets/validation.go
  • internal/secrets/validation_test.go

Comment thread docs/secrets.md Outdated
Comment thread internal/secrets/crypto.go
Comment thread internal/secrets/file_store.go
Comment thread internal/secrets/validation.go
@rezaqomy

Copy link
Copy Markdown
Owner Author

@coderabbitai autofix stacked pr

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 4 file(s) based on 4 unresolved review comments.

A stacked PR containing fixes has been created.

  • Stacked PR: #3
  • Files modified:
  • docs/secrets.md
  • internal/secrets/crypto.go
  • internal/secrets/file_store.go
  • internal/secrets/validation.go

Time taken: 2m 16s

Fixed 4 file(s) based on 4 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/secrets/file_store.go (1)

17-19: 💤 Low value

Consider validating that cipher is non-nil.

For consistency with NewEnvelopeCipher which now panics on nil provider, this constructor could similarly guard against a nil cipher to fail fast rather than panicking later in Create/Get/Update when s.cipher.Encrypt or s.cipher.Decrypt is called.

♻️ Optional: Add nil guard
 func NewEncryptedFileStore(dir string, cipher *EnvelopeCipher) *EncryptedFileStore {
+	if cipher == nil {
+		panic("nil EnvelopeCipher passed to NewEncryptedFileStore")
+	}
 	return &EncryptedFileStore{dir: dir, cipher: cipher}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/secrets/file_store.go` around lines 17 - 19, NewEncryptedFileStore
should validate that the provided cipher is non-nil and fail fast; update the
NewEncryptedFileStore constructor to check if cipher == nil and panic (or
otherwise return an error consistent with NewEnvelopeCipher) with a clear
message, so EncryptedFileStore is never constructed with a nil cipher and later
methods like Create/Get/Update won't trigger nil dereference when calling
s.cipher.Encrypt or s.cipher.Decrypt.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/secrets/file_store.go`:
- Around line 154-162: The code currently defers file.Close() and also calls
file.Close() explicitly in the same function (variable name file in
internal/secrets/file_store.go), which can cause a redundant/unchecked second
close; remove the deferred close and instead close the file explicitly after
Write (handle the error from file.Write), then call file.Close() once and handle
its error (ensuring os.Remove(path) is called on any write or close error) so
cleanup and error returns are correct and no double-close occurs.

---

Nitpick comments:
In `@internal/secrets/file_store.go`:
- Around line 17-19: NewEncryptedFileStore should validate that the provided
cipher is non-nil and fail fast; update the NewEncryptedFileStore constructor to
check if cipher == nil and panic (or otherwise return an error consistent with
NewEnvelopeCipher) with a clear message, so EncryptedFileStore is never
constructed with a nil cipher and later methods like Create/Get/Update won't
trigger nil dereference when calling s.cipher.Encrypt or s.cipher.Decrypt.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7786a68-1b0b-4125-a73c-67b6b7db9333

📥 Commits

Reviewing files that changed from the base of the PR and between cce4a21 and 9629445.

📒 Files selected for processing (4)
  • docs/secrets.md
  • internal/secrets/crypto.go
  • internal/secrets/file_store.go
  • internal/secrets/validation.go
✅ Files skipped from review due to trivial changes (1)
  • docs/secrets.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/secrets/validation.go

Comment on lines +154 to +162
defer file.Close()
if _, err := file.Write(data); err != nil {
os.Remove(path)
return err
}
if err := file.Close(); err != nil {
os.Remove(path)
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Double-close bug: file.Close() is called twice.

The defer file.Close() on line 154 will execute after the explicit file.Close() on line 159, causing a double-close. While Go's os.File.Close is generally safe to call multiple times, this pattern is error-prone and the second close will return an error that goes unchecked.

🐛 Proposed fix: Remove defer and handle close explicitly
 func (s *EncryptedFileStore) writeExclusive(path string, secret EncryptedSecret) error {
 	if err := os.MkdirAll(s.dir, 0700); err != nil {
 		return err
 	}
 	data, err := json.MarshalIndent(secret, "", "  ")
 	if err != nil {
 		return err
 	}
 	file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
 	if err != nil {
 		if errors.Is(err, os.ErrExist) {
 			return ErrSecretExists
 		}
 		return err
 	}
-	defer file.Close()
 	if _, err := file.Write(data); err != nil {
+		file.Close()
 		os.Remove(path)
 		return err
 	}
 	if err := file.Close(); err != nil {
 		os.Remove(path)
 		return err
 	}
 	return nil
 }
🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 154-154: Error return value of file.Close is not checked

(errcheck)


[error] 156-156: Error return value of os.Remove is not checked

(errcheck)


[error] 160-160: Error return value of os.Remove is not checked

(errcheck)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/secrets/file_store.go` around lines 154 - 162, The code currently
defers file.Close() and also calls file.Close() explicitly in the same function
(variable name file in internal/secrets/file_store.go), which can cause a
redundant/unchecked second close; remove the deferred close and instead close
the file explicitly after Write (handle the error from file.Write), then call
file.Close() once and handle its error (ensuring os.Remove(path) is called on
any write or close error) so cleanup and error returns are correct and no
double-close occurs.

@rezaqomy
rezaqomy merged commit 395dfed into master May 31, 2026
1 check passed
@rezaqomy
rezaqomy deleted the feature/secret-manager branch May 31, 2026 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant