Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7ae834b
feat: add keyrotation package with strict decode and cron validation
piotrek-janus Sep 18, 2026
73d34ad
feat(keyrotation): replace patch helpers with a per-workspace DirStore
piotrek-janus Sep 24, 2026
cf44760
feat(client): add KeyRotationAPIStore for the admin keys endpoint
piotrek-janus Sep 24, 2026
53779ce
fix(storage): skip workspace dirs without server.yaml in tenant read
piotrek-janus Sep 24, 2026
12e5720
feat: add --workspace-key-rotation mode to pull, push and diff
piotrek-janus Sep 24, 2026
38fb7df
docs: document managing key rotation
piotrek-janus Sep 24, 2026
6e7817f
chore: bump acp-client-go to 128244c for the system keys service
piotrek-janus Oct 6, 2026
44942e2
fix(storage): keep read-only server timestamps out of server.yaml
piotrek-janus Oct 6, 2026
b910740
feat(client): call the system keys API for automatic key rotation
piotrek-janus Oct 6, 2026
af62202
chore: re-pin acp-client-go to master now that ciam-client-go#76 has …
piotrek-janus Oct 6, 2026
a5061a8
fix: honor --out on key rotation dry-run and reject id/tenant_id in k…
piotrek-janus Oct 6, 2026
d445b6b
refactor(cmd): expose key rotation as pull/push/diff key-rotation sub…
piotrek-janus Oct 11, 2026
a610461
feat(config): add features.key_rotation with a manage_servers scope c…
piotrek-janus Oct 13, 2026
f7cc653
feat(cmd): handle key rotation in plain pull, push and diff behind fe…
piotrek-janus Oct 13, 2026
8b89ae6
docs: document features.key_rotation
piotrek-janus Oct 13, 2026
3b52250
fix(cmd): tolerate missing target workspaces and strip starting_from …
piotrek-janus Oct 13, 2026
dd522d3
fix(cmd): skip key rotation for diff sides whose profile does not all…
piotrek-janus Oct 13, 2026
5752ed4
refactor(cmd): look up only the selected workspace's key rotation file
piotrek-janus Oct 13, 2026
ba549ee
docs: give the stage profile manage_servers and note key rotation pus…
piotrek-janus Oct 13, 2026
64c6e64
refactor(cmd): reduce key rotation gating to a single requested check
piotrek-janus Oct 13, 2026
8162851
refactor(keyrotation): move pull, push and diff orchestration out of cmd
piotrek-janus Oct 14, 2026
9e3cc18
chore: merge master after the key-rotation subcommands landed (#53)
piotrek-janus Oct 14, 2026
242ff12
fix(diff): skip the regular diff when key_rotation is the only filter
piotrek-janus Oct 15, 2026
50d5325
fix(diff): report a key rotation side whose profile has the feature d…
piotrek-janus Oct 15, 2026
471d6a5
refactor(diff): validate the profile of a cross-profile key rotation …
piotrek-janus Oct 15, 2026
2131db8
refactor(push): reuse the key rotation dry-run writer in push key-rot…
piotrek-janus Oct 15, 2026
6caa0fe
feat(keyrotation): skip pushing key rotation uses the server already has
piotrek-janus Oct 15, 2026
795fb4e
docs: explain what the manage_servers scope grants
piotrek-janus Oct 15, 2026
3658760
refactor(cmd): decide the key rotation scope of a run once
piotrek-janus Oct 15, 2026
742a4be
refactor(keyrotation): render the key rotation diff on its own
piotrek-janus Oct 15, 2026
7b13f57
refactor(diff): resolve, gate and build key rotation sides separately
piotrek-janus Oct 15, 2026
1107aab
refactor(cmd): run the key-rotation subcommands through plain pull, p…
piotrek-janus Oct 15, 2026
10b8a7a
refactor(keyrotation): replace Service with package functions
piotrek-janus Oct 15, 2026
3c02f67
fix(push): drop the warning about skipped key rotation files
piotrek-janus Oct 15, 2026
657b31e
fix(push): reset a dry-run --out file once, then append each part
piotrek-janus Oct 15, 2026
fd1d3fd
refactor(cmd): fold keyRotationWorkspaces into readKeyRotations
piotrek-janus Oct 15, 2026
623a33d
docs: describe the key-rotation subcommands as filtered plain commands
piotrek-janus Oct 15, 2026
da25a01
style: gofmt PickSource
piotrek-janus Oct 15, 2026
941e072
fix(diff): fail when the key rotation source server lacks the workspace
piotrek-janus Oct 15, 2026
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
114 changes: 103 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ client:
- read_configuration # alternative scope that can be used only to read configuration
storage:
dir_path: "/tmp/data" # path to local configuration; default: "data"
features: # optional behaviour of plain pull, push and diff; all default to false
key_rotation: false # also handle workspace key rotation, see "Managing key rotation"

profiles: # an optional map of profiles available for use, especially helpful when you want to compare multiple configurations
stage: # each profile support same configuration as root (aka default profile)
Expand All @@ -44,10 +46,19 @@ profiles: # an optional map of profiles available for use, especially helpful wh
client_id: fb346c287c4d4e378cbae39aa0cxxxxx
tenant_id: postmance-stage
client_secret: invalid_secret
scopes: # client replaces the top-level client as a whole, so key rotation needs its scopes here too
- manage_configuration
- manage_servers
storage:
dir_path: "/tmp/other"
features: # like client and storage, replaces the top-level features for this profile
key_rotation: true
```

A profile that does not define `client`, `storage`, `logging` or `features` uses the
top-level section instead. A profile that inherits a top-level `features.key_rotation: true`
must either set it to `false` or carry the `manage_servers` scope in its own `client.scopes`.

## Commands

### Help
Expand Down Expand Up @@ -242,12 +253,7 @@ map[string]any{
### Managing key rotation

Automatic key rotation settings of a workspace live behind a dedicated API and are
managed with the `key-rotation` subcommand of `pull`, `push`, and `diff`, for the
workspace selected with `--workspace` (`--tenant` is rejected). The subcommands do not
take the parent commands' own flags such as `--filter`, `--method`, or `--source`; only
`push key-rotation` keeps `--dry-run` and `--out`, and `diff key-rotation` keeps `--out`
and `--colors`. Plain `pull`, `push`, and `diff` never read or write these settings.
They are stored in `workspaces/<workspace-id>/key_rotation.yaml`. With several storage
stored in `workspaces/<workspace-id>/key_rotation.yaml`. With several storage
directories, the file is taken whole from the first directory that has it; files are
not merged across directories:

Expand All @@ -262,6 +268,15 @@ enc:
cron: "0 0 1 1 *" # required even when disabled
```

The plain `pull`, `push`, and `diff` handle the file when
[`features.key_rotation`](#key-rotation-in-plain-pull-push-and-diff) is enabled. The
`key-rotation` subcommand of each is the same as the plain command with
`--filter key_rotation` for the selected workspace, whether or not the feature is
enabled; `diff key-rotation` compares `local` against `remote`. The subcommands reject
`--tenant` and do not take the parent commands' own flags such as `--filter`,
`--method`, or `--source`; only `push key-rotation` keeps `--dry-run` and `--out`, and
`diff key-rotation` keeps `--out` and `--colors`.

```bash
# write the remote settings to workspaces/demo/key_rotation.yaml
cac --config ./cac.yaml --profile dev --workspace demo pull key-rotation
Expand All @@ -276,9 +291,14 @@ cac --config ./cac.yaml --profile dev --workspace demo push key-rotation
cac --config ./cac.yaml --profile dev --workspace demo diff key-rotation
```

The system workspace client used by cac needs the `manage_servers` scope for these
subcommands, in addition to `manage_configuration`: add it to `client.scopes` in the
[configuration](#configuration).
The system workspace client used by cac needs the `manage_servers` scope for key
rotation, in addition to `manage_configuration`: add it to `client.scopes` in the
[configuration](#configuration). With `features.key_rotation: true`, cac refuses to start
without it: `features.key_rotation requires the manage_servers scope in client.scopes`.
`manage_servers` is broad: it also allows creating and deleting workspaces, and there is
no read-only alternative today. Every call cac makes in a run uses the same token, so the
whole run holds that scope. If your main CI client should stay narrow, manage key
rotation from a dedicated profile with its own client.

Constraints:

Expand All @@ -293,10 +313,82 @@ Constraints:
`@hourly` and the `L`, `W`, and `#` modifiers are supported; `@every` is not.
- `starting_from` is optional and write-only: SecureAuth never returns it, so `pull`
never writes it and `diff` ignores it. It is only honored when it is in the future.
- `push` reads the remote settings first and skips a use whose `enabled` and `cron`
already match. A use that sets `starting_from` is always sent: SecureAuth never returns
it, so cac cannot tell whether it is already applied.
- `scheduled_at` is read-only and rejected in the file.

> **Note:** `push key-rotation` validates every cron before any API call,
> and `--dry-run` prints the settings that would be sent instead of pushing them.
#### Key rotation in plain pull, push and diff

With `features.key_rotation: true`, at the top level or in the selected profile, the
plain `pull`, `push`, and `diff` handle key rotation too, so a single `cac diff` or
`cac push`, for example in CI, covers it:

```yaml
client:
scopes:
- manage_configuration
- manage_servers
features:
key_rotation: true
```

- Key rotation is handled when `--filter` is not given or includes `key_rotation`.
`--filter key_rotation` alone handles only key rotation and skips the rest of the
configuration; `--filter clients,key_rotation` handles both.
- `pull` writes `key_rotation.yaml` after the configuration. With `--tenant` it does so
for every workspace of the tenant.
- `push` reads and validates `key_rotation.yaml` before pushing anything, pushes it after
the configuration, and skips it when the file is absent. With `--tenant` it pushes the
file of every workspace directory that has one, including stale directories without
`server.yaml`: their key rotation request fails with a clear 404 after the import, and
rerunning the push is idempotent. `--method` and `--mode` do not apply to it: every key use
in the file that differs from the server is replaced.
- `push --dry-run` writes the settings that would be sent after the configuration. With
`--out -` (stdout) or a file, each workspace is a separate YAML document; an `--out`
file is emptied first, so it holds only this run's output:

```yaml
# ... configuration ...
---
# key_rotation (demo)
sig:
enabled: true
cron: 0 0 1 * *
```

With `--out` set to an existing directory, the settings go to
`<out>/workspaces/<workspace-id>/key_rotation.yaml`.
- `diff` appends a section for each workspace whose settings differ, after the
configuration diff and in the same format, honoring `--out` and `--colors`:

```
--- key_rotation (demo)
models.Rfc7396PatchOperation{
"sig": map[string]any{
- "cron": string("@monthly"),
+ "cron": string("0 0 1 * *"),
"enabled": bool(true),
},
}
```

`--source` and `--target` map to key rotation as follows: `local` reads
`key_rotation.yaml`, `remote` reads the server, and `merged` reads the local file,
falling back to the server when the workspace has none. A workspace missing on the
target server has no settings there; one missing on the source server fails the diff. With `--tenant` the source's workspaces are compared: the local
workspace directories that have the file for `local`, the workspaces of the tenant for
`remote`, and both for `merged`.

A `remote` or `merged` side of another profile without `features.key_rotation` fails
the diff when `--filter` names `key_rotation`. Otherwise the diff shows
`--- key_rotation skipped: profile <name> has features.key_rotation disabled`
in place of the key rotation sections and still exits 0. A side of another profile
that enables the feature without the `manage_servers` scope always fails the diff.

With the feature disabled, `--filter key_rotation` fails with
`--filter key_rotation requires features.key_rotation: true`, and plain `pull`, `push`,
and `diff` ignore key rotation.

## Templates

Expand Down
66 changes: 49 additions & 17 deletions cmd/diff.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package cmd

import (
"context"
"os"

"github.com/cloudentity/cac/internal/cac"
"github.com/cloudentity/cac/internal/cac/api"
"github.com/cloudentity/cac/internal/cac/diff"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"golang.org/x/exp/slog"
"os"
)

var (
Expand Down Expand Up @@ -41,9 +43,8 @@ Examples:
RunE: func(cmd *cobra.Command, args []string) error {
var (
app *cac.Application
s scope
result string
source api.Source
target api.Source
err error
)

Expand All @@ -63,23 +64,11 @@ Examples:
return err
}

if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil {
return err
}

if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); err != nil {
if s, err = scopeOf(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil {
return err
}

slog.Info("Comparing configurations", "source", source, "target", target)

if result, err = diff.Diff(cmd.Context(), source, target, rootConfig.Workspace,
diff.Colorize(diffConfig.Colors),
diff.OnlyPresent(diffConfig.OnlyPresent),
diff.Filters(diffConfig.Filters...),
diff.WithSecrets(diffConfig.WithSecrets),
diff.FilterVolatileFields(diffConfig.FilterVolatile),
); err != nil {
if result, err = runDiff(cmd.Context(), app, s, diffConfig.Source, diffConfig.Target); err != nil {
return err
}

Expand All @@ -98,6 +87,48 @@ Examples:
}
)

// runDiff compares the configuration and the key rotation of the source and target, as scoped.
func runDiff(ctx context.Context, app *cac.Application, s scope, sourceSpec, targetSpec string) (string, error) {
var (
result string
kr string
source api.Source
target api.Source
err error
)

if s.regular {
if source, err = app.PickSource(sourceSpec, rootConfig.Tenant); err != nil {
return "", err
}

if target, err = app.PickSource(targetSpec, rootConfig.Tenant); err != nil {
return "", err
}

slog.Info("Comparing configurations", "source", source, "target", target)

if result, err = diff.Diff(ctx, source, target, rootConfig.Workspace,
diff.Colorize(diffConfig.Colors),
diff.OnlyPresent(diffConfig.OnlyPresent),
diff.Filters(diffConfig.Filters...),
diff.WithSecrets(diffConfig.WithSecrets),
diff.FilterVolatileFields(diffConfig.FilterVolatile),
); err != nil {
return "", err
}
}

if s.keyRotation {
if kr, err = diffKeyRotations(ctx, app, s, sourceSpec, targetSpec); err != nil {
return "", err
}
}

// a non-empty configuration diff ends with a newline, so the key rotation lines start their own
return result + kr, nil
}

func writeDiffResult(result string) error {
if diffConfig.Out != "-" {
if err := os.WriteFile(diffConfig.Out, []byte(result), 0644); err != nil {
Expand Down Expand Up @@ -139,6 +170,7 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol
ciba (alias of ciba_authentication_service)
Tenant resources: pools, schemas, mfa_methods, themes, servers
Reserved: root (only root-level tenant/workspace config, excluding nested resources)
Key rotation: key_rotation (workspace key rotation settings; requires features.key_rotation: true)
Examples:
--filter clients
--filter clients,idps,policies
Expand Down
Loading
Loading