Repository navigation
feat: handle key rotation in plain pull, push and diff behind features.key_rotation - #54
Merged
Merged
Conversation
Introduces the cac-owned schema for workspaces/<wid>/key_rotation.yaml
({sig,enc} -> {enabled, cron, starting_from}) and the helpers the rest of
the pipeline uses to carry it in a patch under the key_rotation key:
Pop/Get (strict decode, unknown fields rejected), Validate (cron required
for every present use and parsed with gorhill/cronexpr, the same library
and version the server uses), and conversions to and from the admin
AutomaticKeyRotation model. The read-only scheduled_at and the
never-echoed starting_from are dropped when converting from the server.
Adds utils.AsPatch for the recurring any -> patch map conversion.
Key rotation gets its own file store, mirroring workspace secrets: workspaces/<wid>/key_rotation.yaml is taken whole from the first storage directory that has it, rendered through templates and strictly decoded (unknown fields, including the read-only scheduled_at, are rejected). Writes go to the first directory. The patch-map helpers (Pop/Get) and utils.AsPatch are removed; nothing carries key rotation in a patch anymore.
Reads use=sig and use=enc, omitting a use the server reports as never configured, and replaces each present use on write. Built from the existing client and exposed as Application.KeyRotation, like the secrets store.
A workspace directory holding only key_rotation.yaml (as produced by a key-rotation pull into a tenant layout) yielded an empty map and a nil interface conversion panic on the id field.
Exclusive root flag, mutually exclusive with --workspace and --tenant and required as one of the three. Each command returns early into its own function: pull writes the file from the server, push validates and replaces each present use (--dry-run prints the YAML), diff compares the local file against the remote workspace and shows only what a push would change. --filter, --method, --source and --target are rejected in this mode. The cobra required-flag markers for --method and --source/--target are replaced by checks after the mode branch, as on the secrets branch.
The bumped client adds read-only created_at and updated_at to the system ServerDump model. Writing their zero values made the pulled server.yaml fail strict decoding into the hub TreeServer on push, and broke the read round trip. Also expect the new id_jag_ttl field.
The admin keys API rejects the system workspace client-credentials token cac uses. Switch KeyRotationAPIStore and the keyrotation model conversions to the system keys service, which sits behind the manage_servers scope, and document that scope in the README.
…commands Replace the exclusive root flag --workspace-key-rotation <wid> with a key-rotation subcommand under pull, push and diff that operates on the workspace selected with --workspace and rejects --tenant. The parent commands' own flags are now local, so the subcommands neither inherit nor accept them, and cobra's required-flag checks for --method and --source/--target no longer need hand-written equivalents. --dry-run/--out on push and --out/--colors on diff stay persistent for the subcommands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck The new features section is overridable per profile like client and storage. InitApp rejects features.key_rotation without the manage_servers scope in the effective client.scopes.
…atures.key_rotation With the feature enabled, plain pull, push and diff also handle workspaces/<wid>/key_rotation.yaml, for the selected workspace or every workspace in tenant mode, unless --filter leaves key_rotation out. --filter key_rotation alone skips the regular configuration. Push validates key rotation before pushing anything; dry-run emits it as a separate YAML document or into the out directory; diff appends a per-workspace section. With the feature disabled, --filter key_rotation is rejected and push warns about skipped files.
…on both diff sides A tenant diff compares every workspace the source lists, so a target lacking one made the keys API return 404 and failed the whole diff. The plain-diff side read now maps that 404 to "no settings", so the workspace diffs as all-source like the regular tenant diff. diff key-rotation stays strict. keyRotationDiffConfigs cleared starting_from on the source only; it is now cleared on both sides, since either side can be a local file.
…ow it A [profile@] diff side used that profile's client for the keys API even when the profile had features.key_rotation off or lacked the manage_servers scope, failing the whole diff with a 403. Such a side now warns once and the key rotation section is omitted.
In workspace mode a plain push now considers [workspace] directly instead of scanning every workspace dir. tenantWorkspaces drops its unreachable default branch: the tenant export is decoded into a generic map, so servers is always a map or null.
…h and profile caveats The README stage profile enables features.key_rotation but declares its own client, which replaces the top-level one as a whole, so it now carries the manage_configuration and manage_servers scopes. A config test covers a profile that inherits the feature but declares a client without scopes. The README also notes that a tenant push PUTs for stale workspace dirs without server.yaml, and that a profile inheriting features.key_rotation: true must turn it off or carry the scope. The keyrotation package comment now mentions the plain commands.
keyRotationScope returned four values mixing validation, filter rewriting and control flow; the regular pipeline already ignores the key_rotation filter, so only pull and push keep a guard for key_rotation alone, whose empty result would rewrite server.yaml or reset settings on import.
The key-rotation subcommands and the features.key_rotation path of plain pull, push and diff now share one implementation in the keyrotation package; cmd keeps only flag handling, profile resolution and cobra wiring.
There was a problem hiding this comment.
🟡 Changes recommended
Key-rotation-only diffing and several source-side edge cases can skip data or produce incorrect results.
3 open findings
What changed in this PR
Adds feature-gated key-rotation handling to plain pull, push, and diff workflows.
Changes:
- Adds
features.key_rotationconfiguration and scope validation. - Integrates key rotation into workspace and tenant command flows.
- Adds diff, dry-run, discovery, documentation, and tests.
| File | Description |
|---|---|
| README.md | Documents configuration and command behavior. |
| internal/cac/keyrotation/service.go | Adds key-rotation orchestration. |
| internal/cac/keyrotation/service_test.go | Tests orchestration behavior. |
| internal/cac/keyrotation/keyrotation.go | Updates package documentation. |
| internal/cac/keyrotation/dry_run.go | Adds dry-run output support. |
| internal/cac/keyrotation/dry_run_test.go | Tests dry-run destinations. |
| internal/cac/keyrotation/dir_store.go | Adds workspace discovery. |
| internal/cac/keyrotation/dir_store_test.go | Tests workspace discovery. |
| internal/cac/keyrotation/diff.go | Adds plain-diff integration primitives. |
| internal/cac/keyrotation/diff_test.go | Tests diff sides and composition. |
| internal/cac/keyrotation/diff_internal_test.go | Tests internal diff normalization. |
| internal/cac/config/testdata/features.yaml | Adds feature inheritance fixture. |
| internal/cac/config/testdata/features_profile.yaml | Adds profile override fixture. |
| internal/cac/config/config.go | Defines and validates feature configuration. |
| internal/cac/config/config_test.go | Tests parsing, inheritance, and validation. |
| internal/cac/app.go | Validates configuration during startup. |
| cmd/push.go | Integrates key rotation into plain push. |
| cmd/pull.go | Integrates key rotation into plain pull. |
| cmd/key_rotation.go | Reuses the shared service in subcommands. |
| cmd/key_rotation_test.go | Retains command flag tests. |
| cmd/key_rotation_feature.go | Implements feature gating and command helpers. |
| cmd/key_rotation_feature_test.go | Tests feature-gating decisions. |
| cmd/diff.go | Integrates key rotation into plain diff. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
jdabrowski
approved these changes
Oct 14, 2026
4 of 11 tasks
Contributor
Author
|
Deferred review items tracked in #55. |
diff --filter key_rotation still read and compared the regular configuration, so it failed on unrelated template or server errors. Pull and push already skip the regular pipeline in that case; diff now does too and only builds the key rotation sides.
…isabled A cross-profile remote or merged side whose profile does not enable features.key_rotation was dropped with only a log warning, so the key rotation section silently disappeared from the diff. With --filter key_rotation the diff now fails naming the profile; without a filter it shows a '--- key_rotation skipped' line instead of the sections and still exits 0.
…side A remote or merged key rotation side of another profile that enables features.key_rotation without the manage_servers scope was dropped with a warning. It now fails with the same error InitApp reports for the selected profile, prefixed with the profile name. keyRotationRemoteAllowed is gone and the scope constant is no longer exported.
…ation push key-rotation --dry-run had its own writer that printed bare YAML and failed when --out was an existing directory. It now uses the same writer as plain push --dry-run: a '# key_rotation (<wid>)' document to stdout or a replaced --out file, or workspaces/<wid>/key_rotation.yaml under an --out directory.
Push now reads the remote settings of each workspace and only writes the uses whose enabled or cron differ, logging 'Key rotation unchanged' for the others. A use that sets starting_from is always written: the server never returns it, so cac cannot tell whether it is already applied. This covers both plain push and push key-rotation; dry-run is unaffected.
scopeOf turns features.key_rotation and --filter into a scope value (regular, key rotation, explicitly asked for) that pull, push and diff read, replacing keyRotationRequested, keyRotationOnly and the second look at --filter when a diff side has the feature disabled.
keyrotation.Diff and keyrotation.Skipped return only the key rotation lines and runDiff appends them to the configuration diff, which already ends with a newline when it is not empty. This drops AppendDiff, AppendSkipped and their newline bookkeeping.
Application.ResolveSource parses a [profile@]source-type value for both PickSource and the key rotation sides. Each side is gated first: a side reading another profile's server fails or leaves the skipped marker when that profile has the feature disabled, and is validated otherwise. Only then are the sides built, so plain errors replace the keyRotationDisabledError type used for control flow.
…ush and diff pull, push and diff key-rotation now call runPull, runPush and runDiff with a fixed scope, key rotation alone and asked for explicitly, the way --filter key_rotation does, for the selected workspace whether or not features.key_rotation is enabled. diff key-rotation compares local against remote of the selected profile. This removes pullKeyRotation, pushKeyRotation, diffKeyRotation and Service.Diff. diff key-rotation now prints the "--- key_rotation (<wid>)" header, and a workspace missing remotely diffs as all-source instead of failing. Application.KeyRotation is typed as keyrotation.Remote.
Pull, ReadAll and Push take the server and the key rotation files they need instead of hanging off a Service built for every run. ReadAll validates in place of the exported ReadLocal, and Side.Remote is typed as Remote, dropping the Reader interface.
With the feature disabled, plain push warned about every key_rotation.yaml it skipped. The file is not ignored, though: push key-rotation pushes it whether or not the feature is enabled, so the warning was misleading. This also removes DirStore.Has, which only the warning used.
A dry run now empties a flat --out file at the start of the run, and the key rotation documents are appended after the regular output. A single WriteDryRun builds the key rotation stream and writes it once, replacing DryRun with its AppendFile flag that the first write flipped. A key rotation only dry run with nothing to push used to leave a stale --out file from an earlier run; it now leaves an empty one.
It had a single caller left, and the comments no longer describe key rotation as a plain-command-only feature.
State the manage_servers requirement, the missing-use rule and the dry-run format once, describe each key-rotation subcommand as the plain command with --filter key_rotation, and note that a dry run empties the --out file first.
Side.Read mapped a keys-API 404 to "no settings" on both sides, so a source server without the workspace (for example a mistyped --workspace with --filter key_rotation or diff key-rotation) gave an empty diff. Only the target read in Diff tolerates a missing workspace now; the source read fails.
piotrek-janus
marked this pull request as ready for review
October 16, 2026 08:46
ikawalec
approved these changes
Oct 16, 2026
Contributor
Author
|
Live e2e matrix for this branch (commit 941e072): 37 pull/push/diff runs across subcommands, plain commands with the feature on and off, and tenant mode, all passing, with the captured output of every case and four behaviour notes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Stacked on #53 (base
add-key-rotation-v2).Summary
Adds a
features.key_rotationconfig flag. When it is on, the plainpull,pushanddiffcommands also handleworkspaces/<wid>/key_rotation.yaml, so onecac difforcac pushcovers it, for example in a CI diff bot. Thepull|push|diff key-rotationsubcommands run the same code path as the plain command with--filter key_rotation, for the selected workspace, and work with the flag on or off;diff key-rotationcompareslocalagainstremote.Config
A profile without
featuresgets an independent copy of the top-level section.InitApprejectsfeatures.key_rotation: truewhen the effectiveclient.scopeslacksmanage_servers, with the errorfeatures.key_rotation requires the manage_servers scope in client.scopes.Gating rules
--filteris not given OR it includeskey_rotation).--filter key_rotationon its own skips the regular configuration pipeline entirely. Running it with an empty filtered patch would makepush --method importsend an empty configuration. A mixed filter such asclients,key_rotationruns both.--workspace. Tenant mode works per workspace, one at a time:servers). When the regular pull was skipped or filtered them out, cac runs a full tenant export and keeps onlyserversclient-side; that export is paid oncekey_rotation.yamlremoteormergedsource lists them with the same full tenant export filtered client-side, paid once per such sourceOutput shapes
push: local key rotation is read and validated before anything is pushed, then pushed after the regular push succeeds. The remote settings are read first and a use whoseenabledandcronalready match is skipped (Key rotation unchanged), unless it sets the write-onlystarting_from, which is always written.push key-rotationbehaves the same.push --dry-run:--out -: the regular output, then each workspace as a separate YAML document, so the stream stays valid YAML:--out <existing dir>: written to<out>/workspaces/<wid>/key_rotation.yaml.--out <file>: the file is emptied once at the start of the run, then the regular output and the same sections are appended in that order. A key-rotation-only dry run with nothing to push leaves an empty file.push key-rotation --dry-runis the same with the regular output skipped.diff: after the regular diff, a section per workspace, added only when that workspace has differences. It follows--outand--colors:--source/--targetmap:localreads the file,remotereads the API,mergedreads the local file if present, else the remote.[profile@]is resolved the same way as for the regular diff. Target uses that are absent from the source are ignored. A workspace the target lacks (404 from the keys API) diffs as all-source, like the regular tenant diff.diff key-rotationbehaves the same and prints the--- key_rotation (<wid>)header too.Warnings / errors
--filter key_rotation(pull, push or diff): error--filter key_rotation requires features.key_rotation: true.pull,pushanddiffignore key rotation without a warning (push key-rotationstill pushes the file).diffwith a[profile@]side that needs the server of another profile with the feature off: with--filter key_rotationit fails withprofile <name> cannot diff key rotation: features.key_rotation is not enabled; without a filter the output carries--- key_rotation skipped: profile <name> has features.key_rotation disabledinstead of the sections and exits 0.manage_serversfails withprofile <name>: features.key_rotation requires the manage_servers scope in client.scopes.Live verification (a development tenant)
Run before the subcommands were folded into the plain code path; not rerun since.
--workspace <wid> pull: wrote the normal files andkey_rotation.yaml(sig@monthlyenabled, enc disabled).diff --source local --target remote --colors=false: empty.diffshowed an empty regular part plus--- key_rotation (<wid>)with the cron change.push --method import --dry-runprinted the regular dry-run, then the---/# key_rotation (<wid>)document.push --method importpushed both.diffwas then empty, and a freshpullwrote the new cron.push --method import --filter key_rotation, with a local server change also pending: only key rotation was pushed (nopushed configurationlog). A follow-updiffstill showed the unpushed server change and no key rotation difference.pulldid not writekey_rotation.yaml. Plainpush --method importwith the file present pushed only the configuration (it also logged a skip warning, since removed).push/diff --filter key_rotationfailed with the error above.manage_servers:pullanddiff key-rotationboth failed at startup with the scope error.--tenant pullpulled key rotation for all 21 workspaces (1 configured, 20 reported as not configured) with no rate limiting during the run.--tenant diff --source local --target remote --colors=falsewas empty. After a local cron edit it showed only that workspace's key rotation section. No tenant push was run.Tests
clientwithout scopes)scopeOftable,diff --filter key_rotationskipping the regular diff, cross-profile side error and skip marker, the subcommand scope run throughrunPull/runPush/runDiffwith the feature off (pull, diff with header, push, dry run to a directory, to a file, and with nothing to push emptying the file), subcommand flag scopingResolveSourcePull/ReadAll/Push(fake remote), push skipping unchanged uses withstarting_fromalways written, diff sections and skip marker, 404-tolerant diff side read,starting_fromstripped on both diff sides, dry-run output (stdout / directory / appended file), local and tenant workspace discoverygo build,go vet,go test(network tests skipped),golangci-lint(only the 3 pre-existing issues),gofmt,go mod tidy -diffall clean