From 7ae834b872d17b6dba273f8aa54760725b0539be Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 18 Sep 2026 15:57:36 +0200 Subject: [PATCH 01/38] feat: add keyrotation package with strict decode and cron validation Introduces the cac-owned schema for workspaces//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. --- go.mod | 1 + go.sum | 2 + internal/cac/keyrotation/keyrotation.go | 167 +++++++++ internal/cac/keyrotation/keyrotation_test.go | 339 +++++++++++++++++++ internal/cac/utils/model.go | 13 + internal/cac/utils/model_test.go | 43 +++ 6 files changed, 565 insertions(+) create mode 100644 internal/cac/keyrotation/keyrotation.go create mode 100644 internal/cac/keyrotation/keyrotation_test.go diff --git a/go.mod b/go.mod index 96b5f60..cd7c794 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-openapi/strfmt v0.24.0 github.com/goccy/go-yaml v1.12.0 github.com/google/go-cmp v0.7.0 + github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 github.com/imdario/mergo v0.3.16 github.com/mitchellh/mapstructure v1.5.0 github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index a395216..7a7a870 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY= +github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go new file mode 100644 index 0000000..f7b2204 --- /dev/null +++ b/internal/cac/keyrotation/keyrotation.go @@ -0,0 +1,167 @@ +// Package keyrotation holds the automatic key rotation configuration that rides in a workspace +// patch under the key_rotation key. It is not part of models.TreeServer, so it is popped out of +// the patch before every strict decode of the tree models and handled explicitly. +package keyrotation + +import ( + "maps" + + admodels "github.com/cloudentity/acp-client-go/clients/admin/models" + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/go-openapi/strfmt" + "github.com/gorhill/cronexpr" + "github.com/pkg/errors" +) + +// Key is the patch key (and the workspace file name) the configuration lives under. +const Key = "key_rotation" + +// UseSig and UseEnc are the only key uses ACP supports. +const ( + UseSig = "sig" + UseEnc = "enc" +) + +// Rotation is the on-disk and in-patch schema, owned by cac rather than reusing +// admodels.AutomaticKeyRotation: that model carries a read-only scheduled_at field users must not +// write, and its non-pointer date-times would serialize as 0001-01-01 whenever they are unset. +type Rotation struct { + Enabled bool `json:"enabled"` + Cron string `json:"cron"` + StartingFrom *strfmt.DateTime `json:"starting_from,omitempty"` +} + +type Config struct { + Sig *Rotation `json:"sig,omitempty"` + Enc *Rotation `json:"enc,omitempty"` +} + +// UseRotation pairs a key use with its rotation configuration. +type UseRotation struct { + Use string + Rotation *Rotation +} + +// Pop removes Key from patch and strict-decodes it. It returns (nil, nil) when the key is absent +// and does not validate the decoded configuration. +func Pop(patch models.Rfc7396PatchOperation) (*Config, error) { + var ( + raw any + ok bool + ) + + if raw, ok = patch[Key]; !ok { + return nil, nil + } + + delete(patch, Key) + + return decode(raw) +} + +// Get is the non-mutating variant of Pop. +func Get(patch models.Rfc7396PatchOperation) (*Config, error) { + var ( + raw any + ok bool + ) + + if raw, ok = patch[Key]; !ok { + return nil, nil + } + + return decode(raw) +} + +func decode(raw any) (*Config, error) { + var ( + sub models.Rfc7396PatchOperation + config *Config + ok bool + err error + ) + + if sub, ok = utils.AsPatch(raw); !ok { + return nil, errors.Errorf("failed to parse %s: expected an object, got %T", Key, raw) + } + + // FromPatchToModel cleans the map it is given, so decode a copy and leave the caller's alone. + patch := make(models.Rfc7396PatchOperation, len(sub)) + maps.Copy(patch, sub) + + if config, err = utils.FromPatchToModel[Config](patch); err != nil { + return nil, errors.Wrapf(err, "failed to parse %s", Key) + } + + // a configuration with no use configures nothing, so it is reported as absent and nothing is + // written or pushed for it + if config.Sig == nil && config.Enc == nil { + return nil, nil + } + + return config, nil +} + +// Uses returns the configured uses, sig first, skipping the ones that are not set. +func (c *Config) Uses() []UseRotation { + if c == nil { + return nil + } + + var out []UseRotation + + if c.Sig != nil { + out = append(out, UseRotation{Use: UseSig, Rotation: c.Sig}) + } + + if c.Enc != nil { + out = append(out, UseRotation{Use: UseEnc, Rotation: c.Enc}) + } + + return out +} + +// Validate checks that every configured use has a cron ACP will accept. It uses the same parser and +// version ACP does, so what passes here passes there. +func (c *Config) Validate() error { + for _, use := range c.Uses() { + if use.Rotation.Cron == "" { + return errors.Errorf("%s: cron is required for %s, ACP requires a valid cron even when enabled is false", Key, use.Use) + } + + if _, err := cronexpr.Parse(use.Rotation.Cron); err != nil { + return errors.Wrapf(err, "%s: invalid cron for %s", Key, use.Use) + } + } + + return nil +} + +// ToModel converts a Rotation to the API model. ScheduledAt is left zero: it is read-only. +func (r *Rotation) ToModel() *admodels.AutomaticKeyRotation { + out := &admodels.AutomaticKeyRotation{ + Cron: r.Cron, + Enabled: r.Enabled, + } + + if r.StartingFrom != nil { + out.StartingFrom = *r.StartingFrom + } + + return out +} + +// FromModel builds a Rotation out of a GET payload. It returns nil when the use was never +// configured, which ACP reports as an empty cron rather than a 404. StartingFrom and ScheduledAt +// are dropped on purpose: the server never echoes starting_from back, and scheduled_at is read-only. +func FromModel(m *admodels.AutomaticKeyRotation) *Rotation { + if m == nil || m.Cron == "" { + return nil + } + + return &Rotation{ + Enabled: m.Enabled, + Cron: m.Cron, + } +} diff --git a/internal/cac/keyrotation/keyrotation_test.go b/internal/cac/keyrotation/keyrotation_test.go new file mode 100644 index 0000000..d2f5565 --- /dev/null +++ b/internal/cac/keyrotation/keyrotation_test.go @@ -0,0 +1,339 @@ +package keyrotation_test + +import ( + "testing" + "time" + + admodels "github.com/cloudentity/acp-client-go/clients/admin/models" + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/require" +) + +func startingFrom(t *testing.T, value string) *strfmt.DateTime { + t.Helper() + + parsed, err := time.Parse(time.RFC3339, value) + require.NoError(t, err) + + out := strfmt.DateTime(parsed) + + return &out +} + +func TestPop(t *testing.T) { + t.Run("absent", func(t *testing.T) { + patch := models.Rfc7396PatchOperation{"name": "workspace1"} + + config, err := keyrotation.Pop(patch) + require.NoError(t, err) + require.Nil(t, config) + require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) + }) + + t.Run("present", func(t *testing.T) { + patch := models.Rfc7396PatchOperation{ + "name": "workspace1", + keyrotation.Key: map[string]any{ + "sig": map[string]any{ + "enabled": true, + "cron": "0 0 1 * *", + "starting_from": "2026-10-01T00:00:00.000Z", + }, + "enc": map[string]any{ + "enabled": false, + "cron": "@monthly", + }, + }, + } + + config, err := keyrotation.Pop(patch) + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{ + Enabled: true, + Cron: "0 0 1 * *", + StartingFrom: startingFrom(t, "2026-10-01T00:00:00Z"), + }, + Enc: &keyrotation.Rotation{ + Enabled: false, + Cron: "@monthly", + }, + }, config) + require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) + }) + + t.Run("patch operation value", func(t *testing.T) { + patch := models.Rfc7396PatchOperation{ + keyrotation.Key: models.Rfc7396PatchOperation{ + "sig": map[string]any{"enabled": true, "cron": "@daily"}, + }, + } + + config, err := keyrotation.Pop(patch) + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "@daily"}, + }, config) + }) + + t.Run("not an object", func(t *testing.T) { + patch := models.Rfc7396PatchOperation{keyrotation.Key: "@daily"} + + _, err := keyrotation.Pop(patch) + require.ErrorContains(t, err, keyrotation.Key) + }) +} + +func TestGetDoesNotMutatePatch(t *testing.T) { + sig := map[string]any{"enabled": true, "cron": "@monthly"} + patch := models.Rfc7396PatchOperation{ + keyrotation.Key: map[string]any{"sig": sig}, + } + + config, err := keyrotation.Get(patch) + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "@monthly"}, + }, config) + require.Equal(t, models.Rfc7396PatchOperation{ + keyrotation.Key: map[string]any{ + "sig": map[string]any{"enabled": true, "cron": "@monthly"}, + }, + }, patch) + + t.Run("absent", func(t *testing.T) { + empty := models.Rfc7396PatchOperation{} + + config, err := keyrotation.Get(empty) + require.NoError(t, err) + require.Nil(t, config) + }) +} + +func TestStrictDecoding(t *testing.T) { + tcs := []struct { + name string + value map[string]any + }{ + { + name: "unknown field inside a use", + value: map[string]any{ + "sig": map[string]any{"enabled": true, "cron": "@monthly", "rotate": true}, + }, + }, + { + name: "unknown use", + value: map[string]any{ + "sgi": map[string]any{"enabled": true, "cron": "@monthly"}, + }, + }, + { + name: "read only scheduled_at", + value: map[string]any{ + "sig": map[string]any{ + "enabled": true, + "cron": "@monthly", + "scheduled_at": "2026-10-01T00:00:00.000Z", + }, + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + patch := models.Rfc7396PatchOperation{keyrotation.Key: tc.value} + + _, err := keyrotation.Pop(patch) + require.Error(t, err) + }) + } +} + +func TestUses(t *testing.T) { + var config *keyrotation.Config + require.Empty(t, config.Uses()) + + require.Empty(t, (&keyrotation.Config{}).Uses()) + + full := &keyrotation.Config{ + Sig: &keyrotation.Rotation{Cron: "@monthly"}, + Enc: &keyrotation.Rotation{Cron: "@daily"}, + } + require.Equal(t, []keyrotation.UseRotation{ + {Use: keyrotation.UseSig, Rotation: full.Sig}, + {Use: keyrotation.UseEnc, Rotation: full.Enc}, + }, full.Uses()) + + encOnly := &keyrotation.Config{Enc: &keyrotation.Rotation{Cron: "@daily"}} + require.Equal(t, []keyrotation.UseRotation{ + {Use: keyrotation.UseEnc, Rotation: encOnly.Enc}, + }, encOnly.Uses()) +} + +func TestValidate(t *testing.T) { + tcs := []struct { + name string + config *keyrotation.Config + errMsg string + }{ + { + name: "nil config", + config: nil, + }, + { + name: "no uses", + config: &keyrotation.Config{}, + }, + { + name: "missing cron", + config: &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: false}}, + errMsg: "enc", + }, + { + name: "garbage cron", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "not a cron"}}, + errMsg: "sig", + }, + { + name: "every descriptor is not supported", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "@every 5m"}}, + errMsg: "sig", + }, + { + name: "monthly descriptor", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "@monthly"}}, + }, + { + name: "five fields", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + }, + { + name: "six fields with a year", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * * 2027"}}, + }, + { + name: "seven fields with seconds", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 0 1 * * 2027"}}, + }, + { + name: "disabled with a valid cron", + config: &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.Validate() + + if tc.errMsg == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tc.errMsg) + }) + } +} + +func TestValidateMissingCronExplainsAcpRequirement(t *testing.T) { + config := &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: false}} + + err := config.Validate() + require.ErrorContains(t, err, "enc") + require.ErrorContains(t, err, "enabled") +} + +func TestToModel(t *testing.T) { + t.Run("starting from copied", func(t *testing.T) { + from := startingFrom(t, "2026-10-01T00:00:00Z") + rotation := &keyrotation.Rotation{Enabled: true, Cron: "@monthly", StartingFrom: from} + + require.Equal(t, &admodels.AutomaticKeyRotation{ + Cron: "@monthly", + Enabled: true, + StartingFrom: *from, + }, rotation.ToModel()) + }) + + t.Run("starting from unset leaves the zero time", func(t *testing.T) { + model := (&keyrotation.Rotation{Enabled: false, Cron: "@daily"}).ToModel() + + require.True(t, time.Time(model.StartingFrom).IsZero()) + require.True(t, time.Time(model.ScheduledAt).IsZero()) + }) +} + +func TestFromModel(t *testing.T) { + t.Run("nil", func(t *testing.T) { + require.Nil(t, keyrotation.FromModel(nil)) + }) + + t.Run("never configured", func(t *testing.T) { + require.Nil(t, keyrotation.FromModel(&admodels.AutomaticKeyRotation{Cron: ""})) + }) + + t.Run("drops server owned fields", func(t *testing.T) { + rotation := keyrotation.FromModel(&admodels.AutomaticKeyRotation{ + Cron: "0 0 1 * *", + Enabled: true, + ScheduledAt: *startingFrom(t, "2026-11-01T00:00:00Z"), + StartingFrom: *startingFrom(t, "2026-10-01T00:00:00Z"), + }) + + require.Equal(t, &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, rotation) + }) +} + +func TestToYamlOmitsUnsetStartingFrom(t *testing.T) { + config := &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + } + + bts, err := utils.ToYaml(config) + require.NoError(t, err) + require.NotContains(t, string(bts), "starting_from") + require.NotContains(t, string(bts), "scheduled_at") + require.NotContains(t, string(bts), "enc") +} + +func TestPatchRoundTrip(t *testing.T) { + config := &keyrotation.Config{ + Sig: &keyrotation.Rotation{ + Enabled: true, + Cron: "0 0 1 * *", + StartingFrom: startingFrom(t, "2026-10-01T00:00:00Z"), + }, + Enc: &keyrotation.Rotation{Enabled: false, Cron: "@monthly"}, + } + + sub, err := utils.FromModelToPatch(config) + require.NoError(t, err) + + patch := models.Rfc7396PatchOperation{keyrotation.Key: sub} + + out, err := keyrotation.Pop(patch) + require.NoError(t, err) + require.Equal(t, config, out) + require.Empty(t, patch) +} + +func TestEmptyConfigIsAbsent(t *testing.T) { + // key_rotation: {} configures nothing, so it is treated as absent and no file is written for it + t.Run("pop", func(t *testing.T) { + patch := models.Rfc7396PatchOperation{"name": "workspace1", keyrotation.Key: map[string]any{}} + + config, err := keyrotation.Pop(patch) + require.NoError(t, err) + require.Nil(t, config) + require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) + }) + + t.Run("get", func(t *testing.T) { + config, err := keyrotation.Get(models.Rfc7396PatchOperation{keyrotation.Key: map[string]any{}}) + require.NoError(t, err) + require.Nil(t, config) + }) +} diff --git a/internal/cac/utils/model.go b/internal/cac/utils/model.go index 98fb849..023bdc8 100644 --- a/internal/cac/utils/model.go +++ b/internal/cac/utils/model.go @@ -66,6 +66,19 @@ func NormalizePatch(patch models.Rfc7396PatchOperation) (models.Rfc7396PatchOper return out, nil } +// AsPatch narrows a nested patch value to a patch of its own. A patch carries either shape +// depending on whether it was decoded from JSON or built in memory. +func AsPatch(v any) (models.Rfc7396PatchOperation, bool) { + switch value := v.(type) { + case models.Rfc7396PatchOperation: + return value, true + case map[string]any: + return value, true + default: + return nil, false + } +} + // CleanPatch cleans fields that are available in system model but not available in hub model func CleanPatch(patch models.Rfc7396PatchOperation) { delete(patch, "id") diff --git a/internal/cac/utils/model_test.go b/internal/cac/utils/model_test.go index 9604386..b66c010 100644 --- a/internal/cac/utils/model_test.go +++ b/internal/cac/utils/model_test.go @@ -155,3 +155,46 @@ func TestFilterPatch(t *testing.T) { }) } } + +func TestAsPatch(t *testing.T) { + tcs := []struct { + name string + value any + expected models.Rfc7396PatchOperation + ok bool + }{ + { + name: "patch operation", + value: models.Rfc7396PatchOperation{"name": "workspace1"}, + expected: models.Rfc7396PatchOperation{"name": "workspace1"}, + ok: true, + }, + { + name: "plain map", + value: map[string]any{"name": "workspace1"}, + expected: models.Rfc7396PatchOperation{"name": "workspace1"}, + ok: true, + }, + { + name: "string", + value: "workspace1", + }, + { + name: "nil", + value: nil, + }, + { + name: "map of another type", + value: map[string]string{"name": "workspace1"}, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + actual, ok := utils.AsPatch(tc.value) + + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.expected, actual) + }) + } +} From 73d34ad0d18086da7436aeeca1e8f6e69fe09d54 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 24 Sep 2026 14:09:29 +0200 Subject: [PATCH 02/38] feat(keyrotation): replace patch helpers with a per-workspace DirStore Key rotation gets its own file store, mirroring workspace secrets: workspaces//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. --- internal/cac/keyrotation/dir_store.go | 100 ++++++++++ internal/cac/keyrotation/dir_store_test.go | 193 ++++++++++++++++++ internal/cac/keyrotation/keyrotation.go | 79 +------- internal/cac/keyrotation/keyrotation_test.go | 200 +------------------ internal/cac/utils/model.go | 13 -- internal/cac/utils/model_test.go | 43 ---- 6 files changed, 305 insertions(+), 323 deletions(-) create mode 100644 internal/cac/keyrotation/dir_store.go create mode 100644 internal/cac/keyrotation/dir_store_test.go diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go new file mode 100644 index 0000000..0df4ceb --- /dev/null +++ b/internal/cac/keyrotation/dir_store.go @@ -0,0 +1,100 @@ +package keyrotation + +import ( + "os" + "path/filepath" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/templates" + "github.com/cloudentity/cac/internal/cac/utils" + ccyaml "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// FileName is the name of the key rotation file inside a workspace directory. +const FileName = "key_rotation.yaml" + +// DirStore reads and writes the key rotation file under /workspaces//. Reads take the +// whole file from the first dir that has it; files are not merged across dirs. Writes go to the +// first dir. +type DirStore struct { + Dirs []string +} + +func NewDirStore(dirs []string) *DirStore { + return &DirStore{Dirs: dirs} +} + +func (d *DirStore) dirPath(dir string, wid string) string { + return filepath.Join(dir, "workspaces", wid) +} + +// Read returns the configuration with templates rendered ({{ env }} resolved) and strictly decoded: +// unknown fields, including the read-only scheduled_at, are rejected. It returns (nil, nil) when no +// dir has the file or the file configures no use. The configuration is not validated. +func (d *DirStore) Read(wid string) (*Config, error) { + for _, dir := range d.Dirs { + var ( + path = filepath.Join(d.dirPath(dir, wid), FileName) + raw = map[string]any{} + config *Config + bts []byte + err error + ) + + if bts, err = templates.New(path).Render(); err != nil { + if os.IsNotExist(err) { + continue + } + + return nil, errors.Wrapf(err, "failed to render template %s", path) + } + + if err = ccyaml.Unmarshal(bts, &raw); err != nil { + return nil, errors.Wrapf(err, "failed to parse %s", path) + } + + if config, err = utils.FromPatchToModel[Config](models.Rfc7396PatchOperation(raw)); err != nil { + return nil, errors.Wrapf(err, "failed to parse %s", path) + } + + // a file with no use configures nothing, so it is reported as absent + if len(config.Uses()) == 0 { + return nil, nil + } + + return config, nil + } + + return nil, nil +} + +// Write marshals cfg to /workspaces//key_rotation.yaml, replacing any existing file. +func (d *DirStore) Write(wid string, cfg *Config) error { + var ( + bts []byte + err error + ) + + if len(d.Dirs) == 0 { + return errors.New("no storage directories configured") + } + + path := d.dirPath(d.Dirs[0], wid) + + if bts, err = utils.ToYaml(cfg); err != nil { + return errors.Wrap(err, "failed to marshal key rotation") + } + + if err = os.MkdirAll(path, 0755); err != nil { + return errors.Wrapf(err, "failed to create workspace directory %s", path) + } + + file := filepath.Join(path, FileName) + + if err = os.WriteFile(file, bts, 0644); err != nil { + return errors.Wrapf(err, "failed to write key rotation file %s", file) + } + + return nil +} diff --git a/internal/cac/keyrotation/dir_store_test.go b/internal/cac/keyrotation/dir_store_test.go new file mode 100644 index 0000000..09cba31 --- /dev/null +++ b/internal/cac/keyrotation/dir_store_test.go @@ -0,0 +1,193 @@ +package keyrotation_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/stretchr/testify/require" +) + +func writeKeyRotationFile(t *testing.T, dir string, wid string, content string) { + t.Helper() + + path := filepath.Join(dir, "workspaces", wid) + require.NoError(t, os.MkdirAll(path, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(path, keyrotation.FileName), []byte(content), 0644)) +} + +func TestDirStoreReadMissing(t *testing.T) { + store := keyrotation.NewDirStore([]string{t.TempDir(), t.TempDir()}) + + config, err := store.Read("demo") + require.NoError(t, err) + require.Nil(t, config) +} + +func TestDirStoreReadFirstDirWins(t *testing.T) { + dir1, dir2 := t.TempDir(), t.TempDir() + store := keyrotation.NewDirStore([]string{dir1, dir2}) + + writeKeyRotationFile(t, dir2, "demo", ` +sig: + enabled: false + cron: '@daily' +`) + + t.Run("falls back to a later dir", func(t *testing.T) { + config, err := store.Read("demo") + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: false, Cron: "@daily"}, + }, config) + }) + + writeKeyRotationFile(t, dir1, "demo", ` +enc: + enabled: true + cron: '@monthly' +`) + + t.Run("first dir wins", func(t *testing.T) { + config, err := store.Read("demo") + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Enc: &keyrotation.Rotation{Enabled: true, Cron: "@monthly"}, + }, config) + }) +} + +func TestDirStoreReadRendersTemplates(t *testing.T) { + dir := t.TempDir() + store := keyrotation.NewDirStore([]string{dir}) + + writeKeyRotationFile(t, dir, "demo", ` +sig: + enabled: true + cron: '{{ env "CAC_TEST_CRON" }}' +`) + + t.Setenv("CAC_TEST_CRON", "0 0 1 * *") + + config, err := store.Read("demo") + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + }, config) +} + +func TestDirStoreReadStrict(t *testing.T) { + tcs := []struct { + name string + content string + }{ + { + name: "unknown field inside a use", + content: ` +sig: + enabled: true + cron: '@monthly' + rotate: true +`, + }, + { + name: "unknown use", + content: ` +sgi: + enabled: true + cron: '@monthly' +`, + }, + { + name: "read only scheduled_at", + content: ` +sig: + enabled: true + cron: '@monthly' + scheduled_at: 2026-10-01T00:00:00.000Z +`, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + store := keyrotation.NewDirStore([]string{dir}) + + writeKeyRotationFile(t, dir, "demo", tc.content) + + _, err := store.Read("demo") + require.ErrorContains(t, err, "failed to parse") + require.ErrorContains(t, err, keyrotation.FileName) + }) + } +} + +func TestDirStoreReadEmptyIsAbsent(t *testing.T) { + dir := t.TempDir() + store := keyrotation.NewDirStore([]string{dir}) + + writeKeyRotationFile(t, dir, "demo", "{}\n") + + config, err := store.Read("demo") + require.NoError(t, err) + require.Nil(t, config) +} + +func TestDirStoreWriteReadRoundTrip(t *testing.T) { + dir1, dir2 := t.TempDir(), t.TempDir() + store := keyrotation.NewDirStore([]string{dir1, dir2}) + + config := &keyrotation.Config{ + Sig: &keyrotation.Rotation{ + Enabled: true, + Cron: "0 0 1 * *", + StartingFrom: startingFrom(t, "2026-10-01T00:00:00Z"), + }, + Enc: &keyrotation.Rotation{Enabled: false, Cron: "@monthly"}, + } + + require.NoError(t, store.Write("demo", config)) + + bts, err := os.ReadFile(filepath.Join(dir1, "workspaces", "demo", keyrotation.FileName)) + require.NoError(t, err) + require.YAMLEq(t, ` +sig: + enabled: true + cron: 0 0 1 * * + starting_from: 2026-10-01T00:00:00.000Z +enc: + enabled: false + cron: '@monthly' +`, string(bts)) + + _, err = os.Stat(filepath.Join(dir2, "workspaces", "demo", keyrotation.FileName)) + require.True(t, os.IsNotExist(err), "writes go to the first dir only") + + out, err := store.Read("demo") + require.NoError(t, err) + require.Equal(t, config, out) +} + +func TestDirStoreWriteOmitsUnsetStartingFrom(t *testing.T) { + dir := t.TempDir() + store := keyrotation.NewDirStore([]string{dir}) + + require.NoError(t, store.Write("demo", &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: false, Cron: "@monthly"}, + })) + + bts, err := os.ReadFile(filepath.Join(dir, "workspaces", "demo", keyrotation.FileName)) + require.NoError(t, err) + require.NotContains(t, string(bts), "starting_from") + require.YAMLEq(t, ` +sig: + enabled: true + cron: 0 0 1 * * +enc: + enabled: false + cron: '@monthly' +`, string(bts)) +} diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go index f7b2204..35dae73 100644 --- a/internal/cac/keyrotation/keyrotation.go +++ b/internal/cac/keyrotation/keyrotation.go @@ -1,29 +1,22 @@ -// Package keyrotation holds the automatic key rotation configuration that rides in a workspace -// patch under the key_rotation key. It is not part of models.TreeServer, so it is popped out of -// the patch before every strict decode of the tree models and handled explicitly. +// Package keyrotation holds the automatic key rotation configuration of a workspace. It is not part +// of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file and is only +// handled by the dedicated --workspace-key-rotation mode. package keyrotation import ( - "maps" - admodels "github.com/cloudentity/acp-client-go/clients/admin/models" - "github.com/cloudentity/acp-client-go/clients/hub/models" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/go-openapi/strfmt" "github.com/gorhill/cronexpr" "github.com/pkg/errors" ) -// Key is the patch key (and the workspace file name) the configuration lives under. -const Key = "key_rotation" - // UseSig and UseEnc are the only key uses ACP supports. const ( UseSig = "sig" UseEnc = "enc" ) -// Rotation is the on-disk and in-patch schema, owned by cac rather than reusing +// Rotation is the on-disk schema, owned by cac rather than reusing // admodels.AutomaticKeyRotation: that model carries a read-only scheduled_at field users must not // write, and its non-pointer date-times would serialize as 0001-01-01 whenever they are unset. type Rotation struct { @@ -43,66 +36,6 @@ type UseRotation struct { Rotation *Rotation } -// Pop removes Key from patch and strict-decodes it. It returns (nil, nil) when the key is absent -// and does not validate the decoded configuration. -func Pop(patch models.Rfc7396PatchOperation) (*Config, error) { - var ( - raw any - ok bool - ) - - if raw, ok = patch[Key]; !ok { - return nil, nil - } - - delete(patch, Key) - - return decode(raw) -} - -// Get is the non-mutating variant of Pop. -func Get(patch models.Rfc7396PatchOperation) (*Config, error) { - var ( - raw any - ok bool - ) - - if raw, ok = patch[Key]; !ok { - return nil, nil - } - - return decode(raw) -} - -func decode(raw any) (*Config, error) { - var ( - sub models.Rfc7396PatchOperation - config *Config - ok bool - err error - ) - - if sub, ok = utils.AsPatch(raw); !ok { - return nil, errors.Errorf("failed to parse %s: expected an object, got %T", Key, raw) - } - - // FromPatchToModel cleans the map it is given, so decode a copy and leave the caller's alone. - patch := make(models.Rfc7396PatchOperation, len(sub)) - maps.Copy(patch, sub) - - if config, err = utils.FromPatchToModel[Config](patch); err != nil { - return nil, errors.Wrapf(err, "failed to parse %s", Key) - } - - // a configuration with no use configures nothing, so it is reported as absent and nothing is - // written or pushed for it - if config.Sig == nil && config.Enc == nil { - return nil, nil - } - - return config, nil -} - // Uses returns the configured uses, sig first, skipping the ones that are not set. func (c *Config) Uses() []UseRotation { if c == nil { @@ -127,11 +60,11 @@ func (c *Config) Uses() []UseRotation { func (c *Config) Validate() error { for _, use := range c.Uses() { if use.Rotation.Cron == "" { - return errors.Errorf("%s: cron is required for %s, ACP requires a valid cron even when enabled is false", Key, use.Use) + return errors.Errorf("cron is required for %s (the server requires a valid cron even when enabled is false)", use.Use) } if _, err := cronexpr.Parse(use.Rotation.Cron); err != nil { - return errors.Wrapf(err, "%s: invalid cron for %s", Key, use.Use) + return errors.Wrapf(err, "invalid cron for %s", use.Use) } } diff --git a/internal/cac/keyrotation/keyrotation_test.go b/internal/cac/keyrotation/keyrotation_test.go index d2f5565..76c8c96 100644 --- a/internal/cac/keyrotation/keyrotation_test.go +++ b/internal/cac/keyrotation/keyrotation_test.go @@ -1,13 +1,12 @@ package keyrotation_test import ( + "strings" "testing" "time" admodels "github.com/cloudentity/acp-client-go/clients/admin/models" - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" ) @@ -23,135 +22,6 @@ func startingFrom(t *testing.T, value string) *strfmt.DateTime { return &out } -func TestPop(t *testing.T) { - t.Run("absent", func(t *testing.T) { - patch := models.Rfc7396PatchOperation{"name": "workspace1"} - - config, err := keyrotation.Pop(patch) - require.NoError(t, err) - require.Nil(t, config) - require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) - }) - - t.Run("present", func(t *testing.T) { - patch := models.Rfc7396PatchOperation{ - "name": "workspace1", - keyrotation.Key: map[string]any{ - "sig": map[string]any{ - "enabled": true, - "cron": "0 0 1 * *", - "starting_from": "2026-10-01T00:00:00.000Z", - }, - "enc": map[string]any{ - "enabled": false, - "cron": "@monthly", - }, - }, - } - - config, err := keyrotation.Pop(patch) - require.NoError(t, err) - require.Equal(t, &keyrotation.Config{ - Sig: &keyrotation.Rotation{ - Enabled: true, - Cron: "0 0 1 * *", - StartingFrom: startingFrom(t, "2026-10-01T00:00:00Z"), - }, - Enc: &keyrotation.Rotation{ - Enabled: false, - Cron: "@monthly", - }, - }, config) - require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) - }) - - t.Run("patch operation value", func(t *testing.T) { - patch := models.Rfc7396PatchOperation{ - keyrotation.Key: models.Rfc7396PatchOperation{ - "sig": map[string]any{"enabled": true, "cron": "@daily"}, - }, - } - - config, err := keyrotation.Pop(patch) - require.NoError(t, err) - require.Equal(t, &keyrotation.Config{ - Sig: &keyrotation.Rotation{Enabled: true, Cron: "@daily"}, - }, config) - }) - - t.Run("not an object", func(t *testing.T) { - patch := models.Rfc7396PatchOperation{keyrotation.Key: "@daily"} - - _, err := keyrotation.Pop(patch) - require.ErrorContains(t, err, keyrotation.Key) - }) -} - -func TestGetDoesNotMutatePatch(t *testing.T) { - sig := map[string]any{"enabled": true, "cron": "@monthly"} - patch := models.Rfc7396PatchOperation{ - keyrotation.Key: map[string]any{"sig": sig}, - } - - config, err := keyrotation.Get(patch) - require.NoError(t, err) - require.Equal(t, &keyrotation.Config{ - Sig: &keyrotation.Rotation{Enabled: true, Cron: "@monthly"}, - }, config) - require.Equal(t, models.Rfc7396PatchOperation{ - keyrotation.Key: map[string]any{ - "sig": map[string]any{"enabled": true, "cron": "@monthly"}, - }, - }, patch) - - t.Run("absent", func(t *testing.T) { - empty := models.Rfc7396PatchOperation{} - - config, err := keyrotation.Get(empty) - require.NoError(t, err) - require.Nil(t, config) - }) -} - -func TestStrictDecoding(t *testing.T) { - tcs := []struct { - name string - value map[string]any - }{ - { - name: "unknown field inside a use", - value: map[string]any{ - "sig": map[string]any{"enabled": true, "cron": "@monthly", "rotate": true}, - }, - }, - { - name: "unknown use", - value: map[string]any{ - "sgi": map[string]any{"enabled": true, "cron": "@monthly"}, - }, - }, - { - name: "read only scheduled_at", - value: map[string]any{ - "sig": map[string]any{ - "enabled": true, - "cron": "@monthly", - "scheduled_at": "2026-10-01T00:00:00.000Z", - }, - }, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - patch := models.Rfc7396PatchOperation{keyrotation.Key: tc.value} - - _, err := keyrotation.Pop(patch) - require.Error(t, err) - }) - } -} - func TestUses(t *testing.T) { var config *keyrotation.Config require.Empty(t, config.Uses()) @@ -190,17 +60,17 @@ func TestValidate(t *testing.T) { { name: "missing cron", config: &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: false}}, - errMsg: "enc", + errMsg: "cron is required for enc (the server requires a valid cron even when enabled is false)", }, { name: "garbage cron", config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "not a cron"}}, - errMsg: "sig", + errMsg: "invalid cron for sig", }, { name: "every descriptor is not supported", config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "@every 5m"}}, - errMsg: "sig", + errMsg: "invalid cron for sig", }, { name: "monthly descriptor", @@ -233,19 +103,12 @@ func TestValidate(t *testing.T) { return } - require.ErrorContains(t, err, tc.errMsg) + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), tc.errMsg), err.Error()) }) } } -func TestValidateMissingCronExplainsAcpRequirement(t *testing.T) { - config := &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: false}} - - err := config.Validate() - require.ErrorContains(t, err, "enc") - require.ErrorContains(t, err, "enabled") -} - func TestToModel(t *testing.T) { t.Run("starting from copied", func(t *testing.T) { from := startingFrom(t, "2026-10-01T00:00:00Z") @@ -286,54 +149,3 @@ func TestFromModel(t *testing.T) { require.Equal(t, &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, rotation) }) } - -func TestToYamlOmitsUnsetStartingFrom(t *testing.T) { - config := &keyrotation.Config{ - Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, - } - - bts, err := utils.ToYaml(config) - require.NoError(t, err) - require.NotContains(t, string(bts), "starting_from") - require.NotContains(t, string(bts), "scheduled_at") - require.NotContains(t, string(bts), "enc") -} - -func TestPatchRoundTrip(t *testing.T) { - config := &keyrotation.Config{ - Sig: &keyrotation.Rotation{ - Enabled: true, - Cron: "0 0 1 * *", - StartingFrom: startingFrom(t, "2026-10-01T00:00:00Z"), - }, - Enc: &keyrotation.Rotation{Enabled: false, Cron: "@monthly"}, - } - - sub, err := utils.FromModelToPatch(config) - require.NoError(t, err) - - patch := models.Rfc7396PatchOperation{keyrotation.Key: sub} - - out, err := keyrotation.Pop(patch) - require.NoError(t, err) - require.Equal(t, config, out) - require.Empty(t, patch) -} - -func TestEmptyConfigIsAbsent(t *testing.T) { - // key_rotation: {} configures nothing, so it is treated as absent and no file is written for it - t.Run("pop", func(t *testing.T) { - patch := models.Rfc7396PatchOperation{"name": "workspace1", keyrotation.Key: map[string]any{}} - - config, err := keyrotation.Pop(patch) - require.NoError(t, err) - require.Nil(t, config) - require.Equal(t, models.Rfc7396PatchOperation{"name": "workspace1"}, patch) - }) - - t.Run("get", func(t *testing.T) { - config, err := keyrotation.Get(models.Rfc7396PatchOperation{keyrotation.Key: map[string]any{}}) - require.NoError(t, err) - require.Nil(t, config) - }) -} diff --git a/internal/cac/utils/model.go b/internal/cac/utils/model.go index 023bdc8..98fb849 100644 --- a/internal/cac/utils/model.go +++ b/internal/cac/utils/model.go @@ -66,19 +66,6 @@ func NormalizePatch(patch models.Rfc7396PatchOperation) (models.Rfc7396PatchOper return out, nil } -// AsPatch narrows a nested patch value to a patch of its own. A patch carries either shape -// depending on whether it was decoded from JSON or built in memory. -func AsPatch(v any) (models.Rfc7396PatchOperation, bool) { - switch value := v.(type) { - case models.Rfc7396PatchOperation: - return value, true - case map[string]any: - return value, true - default: - return nil, false - } -} - // CleanPatch cleans fields that are available in system model but not available in hub model func CleanPatch(patch models.Rfc7396PatchOperation) { delete(patch, "id") diff --git a/internal/cac/utils/model_test.go b/internal/cac/utils/model_test.go index b66c010..9604386 100644 --- a/internal/cac/utils/model_test.go +++ b/internal/cac/utils/model_test.go @@ -155,46 +155,3 @@ func TestFilterPatch(t *testing.T) { }) } } - -func TestAsPatch(t *testing.T) { - tcs := []struct { - name string - value any - expected models.Rfc7396PatchOperation - ok bool - }{ - { - name: "patch operation", - value: models.Rfc7396PatchOperation{"name": "workspace1"}, - expected: models.Rfc7396PatchOperation{"name": "workspace1"}, - ok: true, - }, - { - name: "plain map", - value: map[string]any{"name": "workspace1"}, - expected: models.Rfc7396PatchOperation{"name": "workspace1"}, - ok: true, - }, - { - name: "string", - value: "workspace1", - }, - { - name: "nil", - value: nil, - }, - { - name: "map of another type", - value: map[string]string{"name": "workspace1"}, - }, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - actual, ok := utils.AsPatch(tc.value) - - require.Equal(t, tc.ok, ok) - require.Equal(t, tc.expected, actual) - }) - } -} From cf44760e5f26cf9125e904c111770d08aec7e840 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 24 Sep 2026 14:09:29 +0200 Subject: [PATCH 03/38] feat(client): add KeyRotationAPIStore for the admin keys endpoint 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. --- internal/cac/app.go | 12 +- internal/cac/client/key_rotation_api.go | 60 ++++++++++ internal/cac/client/key_rotation_api_test.go | 115 +++++++++++++++++++ internal/cac/client/mock_server_test.go | 97 +++++++++++++++- 4 files changed, 276 insertions(+), 8 deletions(-) create mode 100644 internal/cac/client/key_rotation_api.go create mode 100644 internal/cac/client/key_rotation_api_test.go diff --git a/internal/cac/app.go b/internal/cac/app.go index f6b9f02..507f5c6 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -12,11 +12,12 @@ import ( ) type Application struct { - Config *config.Configuration - RootConfig *config.RootConfiguration - Client api.Source - Storage storage.Storage - Validator data.ValidatorApi + Config *config.Configuration + RootConfig *config.RootConfiguration + Client api.Source + Storage storage.Storage + Validator data.ValidatorApi + KeyRotation *client.KeyRotationAPIStore } func InitApp(configPath string, profile string, tenant bool) (app *Application, err error) { @@ -43,6 +44,7 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, } app.Client = c + app.KeyRotation = c.KeyRotationStore() if tenant { app.Client = c.Tenant() diff --git a/internal/cac/client/key_rotation_api.go b/internal/cac/client/key_rotation_api.go new file mode 100644 index 0000000..3f1e99e --- /dev/null +++ b/internal/cac/client/key_rotation_api.go @@ -0,0 +1,60 @@ +package client + +import ( + "context" + + acpclient "github.com/cloudentity/acp-client-go" + kclient "github.com/cloudentity/acp-client-go/clients/admin/client/keys" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" +) + +// KeyRotationAPIStore talks to the admin automatic key rotation API for a single workspace. +type KeyRotationAPIStore struct { + acp *acpclient.Client +} + +func (c *Client) KeyRotationStore() *KeyRotationAPIStore { + return &KeyRotationAPIStore{acp: c.acp} +} + +// Read returns the sig and enc rotation settings, or nil when neither use was ever configured. +func (s *KeyRotationAPIStore) Read(ctx context.Context, wid string) (*keyrotation.Config, error) { + var cfg keyrotation.Config + + for _, use := range []string{keyrotation.UseSig, keyrotation.UseEnc} { + ok, err := s.acp.Admin.Keys.GetAutomaticKeyRotation( + kclient.NewGetAutomaticKeyRotationParams().WithContext(ctx).WithWid(wid).WithUse(&use), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to read key rotation for workspace %s, use %s", wid, use) + } + + if use == keyrotation.UseSig { + cfg.Sig = keyrotation.FromModel(ok.Payload) + } else { + cfg.Enc = keyrotation.FromModel(ok.Payload) + } + } + + if cfg.Sig == nil && cfg.Enc == nil { + return nil, nil + } + + return &cfg, nil +} + +// Write sets rotation for each configured use, sig first. It stops at the first API error. +func (s *KeyRotationAPIStore) Write(ctx context.Context, wid string, cfg *keyrotation.Config) error { + for _, u := range cfg.Uses() { + if _, err := s.acp.Admin.Keys.SetAutomaticKeyRotation( + kclient.NewSetAutomaticKeyRotationParams(). + WithContext(ctx). + WithWid(wid). + WithUse(&u.Use). + WithAutomaticKeyRotation(u.Rotation.ToModel()), nil); err != nil { + return errors.Wrapf(err, "failed to set key rotation for workspace %s, use %s", wid, u.Use) + } + } + + return nil +} diff --git a/internal/cac/client/key_rotation_api_test.go b/internal/cac/client/key_rotation_api_test.go new file mode 100644 index 0000000..135d77c --- /dev/null +++ b/internal/cac/client/key_rotation_api_test.go @@ -0,0 +1,115 @@ +package client_test + +import ( + "context" + "fmt" + "net/url" + "testing" + "time" + + acpclient "github.com/cloudentity/acp-client-go" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/require" +) + +func initKeyRotationStore(t *testing.T) (*client.KeyRotationAPIStore, *MockServer) { + testServer := CreateMockServer(t) + t.Cleanup(testServer.Close) + + issuer, err := url.Parse(fmt.Sprintf("%s/postmance/system", testServer.URL)) + require.NoError(t, err) + + c, err := client.InitClient(&client.Configuration{ + Insecure: true, + Config: acpclient.Config{ + IssuerURL: issuer, + TenantID: "postmance", + ClientID: "fb346c287c4d4e378cbae39aa0c3fe52", + ClientSecret: "valid_secret", + }, + }) + require.NoError(t, err) + + return c.KeyRotationStore(), testServer +} + +func TestKeyRotationRead(t *testing.T) { + store, server := initKeyRotationStore(t) + + cfg, err := store.Read(context.Background(), "demo") + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + }, cfg) + require.Equal(t, []string{"sig", "enc"}, server.KeyRotationGetUses()) +} + +func TestKeyRotationReadEncOnly(t *testing.T) { + store, _ := initKeyRotationStore(t) + + cfg, err := store.Read(context.Background(), "enc-only") + require.NoError(t, err) + require.Equal(t, &keyrotation.Config{ + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + }, cfg) +} + +func TestKeyRotationReadNeverConfigured(t *testing.T) { + store, server := initKeyRotationStore(t) + + cfg, err := store.Read(context.Background(), "unconfigured") + require.NoError(t, err) + require.Nil(t, cfg) + require.Equal(t, []string{"sig", "enc"}, server.KeyRotationGetUses()) +} + +func TestKeyRotationWrite(t *testing.T) { + store, server := initKeyRotationStore(t) + + startingFrom := strfmt.DateTime(time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)) + + require.NoError(t, store.Write(context.Background(), "demo", &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}, + Enc: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 1 *"}, + })) + + puts := server.KeyRotationPuts() + require.Len(t, puts, 2) + + require.Equal(t, "demo", puts[0].Wid) + require.Equal(t, "sig", puts[0].Use) + require.True(t, puts[0].Body.Enabled) + require.Equal(t, "0 0 1 * *", puts[0].Body.Cron) + require.Equal(t, startingFrom.String(), puts[0].Body.StartingFrom.String()) + require.True(t, time.Time(puts[0].Body.ScheduledAt).IsZero()) + + require.Equal(t, "demo", puts[1].Wid) + require.Equal(t, "enc", puts[1].Use) + require.False(t, puts[1].Body.Enabled) + require.Equal(t, "0 0 1 1 *", puts[1].Body.Cron) + require.True(t, time.Time(puts[1].Body.StartingFrom).IsZero()) + require.True(t, time.Time(puts[1].Body.ScheduledAt).IsZero()) +} + +func TestKeyRotationWriteNil(t *testing.T) { + store, server := initKeyRotationStore(t) + + require.NoError(t, store.Write(context.Background(), "demo", nil)) + require.Empty(t, server.KeyRotationPuts()) +} + +func TestKeyRotationWriteStopsAtFirstError(t *testing.T) { + store, server := initKeyRotationStore(t) + + err := store.Write(context.Background(), "failing", &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }) + require.ErrorContains(t, err, "workspace failing, use sig") + + puts := server.KeyRotationPuts() + require.Len(t, puts, 1, "enc must not be sent after sig fails") + require.Equal(t, "sig", puts[0].Use) +} diff --git a/internal/cac/client/mock_server_test.go b/internal/cac/client/mock_server_test.go index 6458057..2e242ac 100644 --- a/internal/cac/client/mock_server_test.go +++ b/internal/cac/client/mock_server_test.go @@ -1,18 +1,109 @@ package client_test import ( + admodels "github.com/cloudentity/acp-client-go/clients/admin/models" "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/go-json-experiment/json" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" + "io" "net/http" "net/http/httptest" + "strings" + "sync" "testing" "time" ) -func CreateMockServer(t *testing.T) *httptest.Server { - testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { +const ( + keyRotationPathPrefix = "/api/admin/postmance/servers/" + keyRotationPathSuffix = "/keys/automatic-key-rotation" +) + +// KeyRotationPut is a PUT the mock received on the automatic key rotation endpoint. +type KeyRotationPut struct { + Wid string + Use string + Body admodels.AutomaticKeyRotation +} + +// MockServer wraps httptest.Server and records key rotation calls. +type MockServer struct { + *httptest.Server + + mu sync.Mutex + keyRotationPuts []KeyRotationPut + keyRotationGets []string +} + +func (m *MockServer) KeyRotationPuts() []KeyRotationPut { + m.mu.Lock() + defer m.mu.Unlock() + + return append([]KeyRotationPut(nil), m.keyRotationPuts...) +} + +// KeyRotationGetUses returns the use query values of the key rotation GETs, in order. +func (m *MockServer) KeyRotationGetUses() []string { + m.mu.Lock() + defer m.mu.Unlock() + + return append([]string(nil), m.keyRotationGets...) +} + +func (m *MockServer) handleKeyRotation(t *testing.T, res http.ResponseWriter, req *http.Request) { + wid := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, keyRotationPathPrefix), keyRotationPathSuffix) + use := req.URL.Query().Get("use") + + res.Header().Set("Content-Type", "application/json") + + switch req.Method { + case http.MethodGet: + m.mu.Lock() + m.keyRotationGets = append(m.keyRotationGets, use) + m.mu.Unlock() + + // demo has only sig configured, enc-only only enc, and any other workspace nothing + js := `{"enabled":false,"cron":"","starting_from":"0001-01-01T00:00:00Z","scheduled_at":"0001-01-01T00:00:00Z"}` + if (wid == "demo" && use == "sig") || (wid == "enc-only" && use == "enc") { + js = `{"enabled":true,"cron":"0 0 1 * *","scheduled_at":"2026-10-01T00:00:00Z","starting_from":"0001-01-01T00:00:00Z"}` + } + + res.WriteHeader(http.StatusOK) + _, err := res.Write([]byte(js)) + require.NoError(t, err) + case http.MethodPut: + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + + var rotation admodels.AutomaticKeyRotation + require.NoError(t, json.Unmarshal(body, &rotation)) + + m.mu.Lock() + m.keyRotationPuts = append(m.keyRotationPuts, KeyRotationPut{Wid: wid, Use: use, Body: rotation}) + m.mu.Unlock() + + if wid == "failing" { + res.WriteHeader(http.StatusInternalServerError) + return + } + + res.WriteHeader(http.StatusOK) + _, err = res.Write(body) + require.NoError(t, err) + default: + res.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func CreateMockServer(t *testing.T) *MockServer { + m := &MockServer{} + m.Server = httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + + if strings.HasPrefix(req.URL.Path, keyRotationPathPrefix) && strings.HasSuffix(req.URL.Path, keyRotationPathSuffix) { + m.handleKeyRotation(t, res, req) + return + } if req.URL.Path == "/postmance/system/.well-known/openid-configuration" { js := []byte(`{ @@ -101,5 +192,5 @@ func CreateMockServer(t *testing.T) *httptest.Server { _, err = res.Write(js) require.NoError(t, err) })) -return testServer +return m } \ No newline at end of file From 53779cece434ffb18018b527144106c84f56d431 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 24 Sep 2026 14:09:29 +0200 Subject: [PATCH 04/38] fix(storage): skip workspace dirs without server.yaml in tenant read 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. --- internal/cac/storage/tenant_storage.go | 7 ++++- internal/cac/storage/tenant_storage_test.go | 32 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/cac/storage/tenant_storage.go b/internal/cac/storage/tenant_storage.go index b94d9ca..bc2acfb 100644 --- a/internal/cac/storage/tenant_storage.go +++ b/internal/cac/storage/tenant_storage.go @@ -190,7 +190,12 @@ func (t *TenantStorage) Read(ctx context.Context, opts ...api.SourceOpt) (models return nil, err } - id := workspaceConfig["id"].(string) + // a workspace dir without server.yaml holds nothing the tenant tree can carry + id, ok := workspaceConfig["id"].(string) + if !ok { + continue + } + delete(workspaceConfig, "id") delete(workspaceConfig, "tenant_id") servers[id] = workspaceConfig diff --git a/internal/cac/storage/tenant_storage_test.go b/internal/cac/storage/tenant_storage_test.go index 5a50846..7430354 100644 --- a/internal/cac/storage/tenant_storage_test.go +++ b/internal/cac/storage/tenant_storage_test.go @@ -407,3 +407,35 @@ func TestTenantStoragePhoneProviderConfigRoundTrip(t *testing.T) { require.Equal(t, "ACtest", back.PhoneProviderConfig.Providers[0].Twilio.Sid) require.Equal(t, "tok", back.PhoneProviderConfig.Providers[0].Twilio.AuthToken) } + +func TestTenantStorageReadSkipsWorkspaceDirWithoutServer(t *testing.T) { + require.NoError(t, logging.InitLogging(&logging.Configuration{Level: "debug"})) + + dir := t.TempDir() + + st, err := storage.InitMultiStorage(&storage.MultiStorageConfiguration{ + DirPath: []string{dir}, + }, storage.InitTenantStorage) + require.NoError(t, err) + + written, err := utils.FromModelToPatch(&models.TreeTenant{ + Servers: models.TreeServers{ + "demo": models.TreeServer{Name: "demo workspace"}, + }, + }) + require.NoError(t, err) + + require.NoError(t, st.Write(context.Background(), written, api.WithWorkspace("demo"))) + + // pull --workspace-key-rotation in a tenant layout creates a workspace dir holding only key_rotation.yaml + require.NoError(t, os.MkdirAll(filepath.Join(dir, "workspaces", "other"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces", "other", "key_rotation.yaml"), []byte("sig:\n cron: \"0 0 1 * *\"\n"), 0644)) + + read, err := st.Read(context.Background(), api.WithWorkspace("demo")) + require.NoError(t, err) + + servers, ok := read["servers"].(map[string]any) + require.True(t, ok) + require.Len(t, servers, 1) + require.Contains(t, servers, "demo") +} From 12e5720e55fafa79e781a28d5e9a8bc5f77a6741 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 24 Sep 2026 14:09:29 +0200 Subject: [PATCH 05/38] feat: add --workspace-key-rotation mode to pull, push and diff 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. --- cmd/diff.go | 138 ++++++++++++++++++++++++++++++---- cmd/diff_key_rotation_test.go | 91 ++++++++++++++++++++++ cmd/flags.go | 13 ---- cmd/pull.go | 56 ++++++++++++++ cmd/push.go | 85 +++++++++++++++++++-- cmd/root.go | 19 +++-- 6 files changed, 360 insertions(+), 42 deletions(-) create mode 100644 cmd/diff_key_rotation_test.go delete mode 100644 cmd/flags.go diff --git a/cmd/diff.go b/cmd/diff.go index a0ad937..ccc57ce 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -1,13 +1,18 @@ package cmd import ( + "os" + "strings" + + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" - "os" ) var ( @@ -47,6 +52,24 @@ Examples: err error ) + if rootConfig.WorkspaceKeyRotation != "" { + return diffKeyRotation(cmd) + } + + var missing []string + + if diffConfig.Source == "" { + missing = append(missing, "source") + } + + if diffConfig.Target == "" { + missing = append(missing, "target") + } + + if len(missing) > 0 { + return errors.Errorf(`required flag(s) "%s" not set`, strings.Join(missing, `", "`)) + } + slog. With("workspace", rootConfig.Workspace). With("config", rootConfig.ConfigPath). @@ -83,19 +106,7 @@ Examples: return err } - if diffConfig.Out != "-" { - if err = os.WriteFile(diffConfig.Out, []byte(result), 0644); err != nil { - return errors.Wrap(err, "failed to write diff result to file") - } - - return nil - } - - if _, err = os.Stdout.Write([]byte(result)); err != nil { - return errors.Wrap(err, "failed to write diff result to stdout") - } - - return nil + return writeDiffResult(result) }, } diffConfig struct { @@ -110,6 +121,103 @@ Examples: } ) +func writeDiffResult(result string) error { + if diffConfig.Out != "-" { + if err := os.WriteFile(diffConfig.Out, []byte(result), 0644); err != nil { + return errors.Wrap(err, "failed to write diff result to file") + } + + return nil + } + + if _, err := os.Stdout.Write([]byte(result)); err != nil { + return errors.Wrap(err, "failed to write diff result to stdout") + } + + return nil +} + +func diffKeyRotation(cmd *cobra.Command) error { + var ( + app *cac.Application + local, remote *keyrotation.Config + sourcePatch, targetPatch models.Rfc7396PatchOperation + result string + err error + ) + + if len(diffConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-key-rotation") + } + + if diffConfig.Source != "" || diffConfig.Target != "" { + return errors.New("--source/--target do not apply to --workspace-key-rotation; the local file is always compared against the remote workspace") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceKeyRotation + + if local, err = dirStore.Read(wid); err != nil { + return errors.Wrap(err, "failed to read local key rotation") + } + + if remote, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { + return err + } + + source, target := keyRotationDiffConfigs(local, remote) + + if sourcePatch, err = utils.FromModelToPatch(source); err != nil { + return errors.Wrap(err, "failed to convert local key rotation") + } + + if targetPatch, err = utils.FromModelToPatch(target); err != nil { + return errors.Wrap(err, "failed to convert remote key rotation") + } + + if result, err = diff.Tree(sourcePatch, targetPatch, diff.Colorize(diffConfig.Colors)); err != nil { + return err + } + + return writeDiffResult(result) +} + +// keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies +// its arguments and replaces nil with an empty configuration. +func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target *keyrotation.Config) { + if local == nil { + local = &keyrotation.Config{} + } + + if remote == nil { + remote = &keyrotation.Config{} + } + + // the server never echoes starting_from back, so it would always show up as a difference + for _, use := range local.Uses() { + use.Rotation.StartingFrom = nil + } + + // push never removes a use that is absent locally, so diff previews only what push would change + if local.Sig == nil { + remote.Sig = nil + } + + if local.Enc == nil { + remote.Enc = nil + } + + return local, remote +} + func init() { diffCmd.PersistentFlags().StringVar(&diffConfig.Source, "source", "", `Source of the comparison (required). Format: [profile@]source-type Source types: local, remote, merged @@ -148,6 +256,4 @@ Examples: Example: --with-secrets`) diffCmd.PersistentFlags().BoolVar(&diffConfig.FilterVolatile, "no-volatile", false, `Ignore volatile fields (e.g. timestamps, generated IDs) when comparing. Example: --no-volatile`) - - mustMarkRequired(diffCmd, "source", "target") } diff --git a/cmd/diff_key_rotation_test.go b/cmd/diff_key_rotation_test.go new file mode 100644 index 0000000..c1967c7 --- /dev/null +++ b/cmd/diff_key_rotation_test.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "testing" + "time" + + "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/require" +) + +func keyRotationDiff(t *testing.T, local, remote *keyrotation.Config) string { + source, target := keyRotationDiffConfigs(local, remote) + + sourcePatch, err := utils.FromModelToPatch(source) + require.NoError(t, err) + + targetPatch, err := utils.FromModelToPatch(target) + require.NoError(t, err) + + result, err := diff.Tree(sourcePatch, targetPatch, diff.Colorize(false)) + require.NoError(t, err) + + return result +} + +func TestKeyRotationDiffConfigs(t *testing.T) { + t.Run("starting_from is cleared on local only", func(t *testing.T) { + startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + + source, target := keyRotationDiffConfigs( + &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + ) + + require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &startingFrom, target.Sig.StartingFrom) + }) + + t.Run("remote use absent locally is dropped", func(t *testing.T) { + source, target := keyRotationDiffConfigs( + &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, + ) + + require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}, target) + }) + + t.Run("nil local and configured remote give an empty diff", func(t *testing.T) { + require.Empty(t, keyRotationDiff(t, nil, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + })) + }) + + t.Run("nil configs give an empty diff", func(t *testing.T) { + require.Empty(t, keyRotationDiff(t, nil, nil)) + }) + + t.Run("local use missing remotely shows up", func(t *testing.T) { + require.NotEmpty(t, keyRotationDiff(t, &keyrotation.Config{ + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, nil)) + }) +} + +func TestDiffRequiredFlags(t *testing.T) { + tcs := []struct { + source, target string + err string + }{ + {"", "", `required flag(s) "source", "target" not set`}, + {"local", "", `required flag(s) "target" not set`}, + {"", "remote", `required flag(s) "source" not set`}, + } + + saved := diffConfig + t.Cleanup(func() { diffConfig = saved }) + + for _, tc := range tcs { + diffConfig.Source, diffConfig.Target = tc.source, tc.target + + require.EqualError(t, diffCmd.RunE(diffCmd, nil), tc.err) + } +} diff --git a/cmd/flags.go b/cmd/flags.go deleted file mode 100644 index 40b67bb..0000000 --- a/cmd/flags.go +++ /dev/null @@ -1,13 +0,0 @@ -package cmd - -import "github.com/spf13/cobra" - -func mustMarkRequired(cmd *cobra.Command, flags ...string) { - for _, flag := range flags { - err := cmd.MarkPersistentFlagRequired(flag) - - if err != nil { - panic(err) - } - } -} diff --git a/cmd/pull.go b/cmd/pull.go index 6bde4f7..c5a53c7 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -4,6 +4,8 @@ import ( "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" ) @@ -34,6 +36,10 @@ Examples: err error ) + if rootConfig.WorkspaceKeyRotation != "" { + return pullKeyRotation(cmd) + } + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -67,6 +73,56 @@ Examples: } ) +func pullKeyRotation(cmd *cobra.Command) error { + var ( + app *cac.Application + cfg *keyrotation.Config + err error + ) + + if len(pullConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-key-rotation") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceKeyRotation + + slog.With("workspace", wid).Info("Pulling key rotation") + + if cfg, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { + return err + } + + if cfg == nil { + slog.Info("No key rotation configured", "workspace", wid) + return nil + } + + if err = dirStore.Write(wid, cfg); err != nil { + return err + } + + slog.Info("Pulled key rotation", "workspace", wid) + + return nil +} + +func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { + if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { + return nil, errors.New("no storage directories configured for the selected profile") + } + + return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil +} + func init() { pullCmd.PersistentFlags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. Example: --with-secrets`) diff --git a/cmd/push.go b/cmd/push.go index 32e4e36..43a0d32 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,10 +1,14 @@ package cmd import ( + "os" + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/storage" + "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" @@ -42,6 +46,14 @@ Examples: err error ) + if rootConfig.WorkspaceKeyRotation != "" { + return pushKeyRotation(cmd) + } + + if pushConfig.Method == "" { + return errors.New(`required flag(s) "method" not set`) + } + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -99,15 +111,76 @@ Examples: }, } pushConfig struct { - DryRun bool - Out string - Mode string - Method string - Filters []string + DryRun bool + Out string + Mode string + Method string + Filters []string NoLocalValidate bool } ) +func pushKeyRotation(cmd *cobra.Command) error { + var ( + app *cac.Application + cfg *keyrotation.Config + err error + ) + + if len(pushConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-key-rotation") + } + + if pushConfig.Method != "" { + return errors.New("--method does not apply to --workspace-key-rotation; each configured key use is always replaced") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceKeyRotation + + if cfg, err = dirStore.Read(wid); err != nil { + return errors.Wrap(err, "failed to read local key rotation") + } + + if cfg == nil { + slog.Info("No key rotation configuration to push", "workspace", wid) + return nil + } + + if err = cfg.Validate(); err != nil { + return errors.Wrap(err, "failed to validate key rotation") + } + + if pushConfig.DryRun { + bts, err := utils.ToYaml(cfg) + if err != nil { + return errors.Wrap(err, "failed to marshal key rotation") + } + + if _, err = os.Stdout.Write(bts); err != nil { + return errors.Wrap(err, "failed to write key rotation to stdout") + } + + return nil + } + + if err = app.KeyRotation.Write(cmd.Context(), wid, cfg); err != nil { + return err + } + + slog.Info("Pushed key rotation", "workspace", wid, "uses", len(cfg.Uses())) + + return nil +} + func init() { pushCmd.PersistentFlags().BoolVar(&pushConfig.DryRun, "dry-run", false, `Write the resolved configuration to disk or stdout instead of pushing to the server. Use with --out to control the destination. @@ -145,6 +218,4 @@ Examples: --filter scopes --filter pools --filter root --filter root,clients`) - - mustMarkRequired(pushCmd, "method") } diff --git a/cmd/root.go b/cmd/root.go index 2652205..af4a2eb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,10 +28,11 @@ Examples: ) type RootConfig struct { - ConfigPath string - Profile string - Workspace string - Tenant bool + ConfigPath string + Profile string + Workspace string + Tenant bool + WorkspaceKeyRotation string } func init() { @@ -45,13 +46,19 @@ Example: --tenant`) rootCmd.PersistentFlags().StringVar(&rootConfig.Workspace, "workspace", "", `Workspace identifier to operate on. Mutually exclusive with --tenant. Example: --workspace demo`) + rootCmd.PersistentFlags().StringVar(&rootConfig.WorkspaceKeyRotation, "workspace-key-rotation", "", `Operate exclusively on the automatic key rotation settings of the given workspace. +Mutually exclusive with --workspace, --tenant, and --filter. +Examples: + cac --config ./cac.yaml --profile dev pull --workspace-key-rotation demo + cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo + cac --config ./cac.yaml --profile dev diff --workspace-key-rotation demo`) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(diffCmd) - rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant") - rootCmd.MarkFlagsOneRequired("workspace", "tenant") + rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant", "workspace-key-rotation") + rootCmd.MarkFlagsOneRequired("workspace", "tenant", "workspace-key-rotation") } func Execute() error { From 38fb7df3998e00a52efbe64e2dcd80f097b1aae0 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 24 Sep 2026 14:09:29 +0200 Subject: [PATCH 06/38] docs: document managing key rotation --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 10998be..91da6bc 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,59 @@ map[string]any{ - "ciba_authentication_service": map[string]any{"type": string("mock")}, ``` +### Managing key rotation + +Automatic key rotation settings of a workspace live behind a dedicated API and are +managed with the exclusive `--workspace-key-rotation ` flag (mutually +exclusive with `--workspace`, `--tenant`, and `--filter`). Plain `pull`, `push`, and +`diff` never read or write these settings. They are stored in +`workspaces//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: + +```yaml +# workspaces/demo/key_rotation.yaml +sig: + enabled: true + cron: "0 0 1 * *" + starting_from: "2030-01-01T00:00:00Z" +enc: + enabled: false + cron: "0 0 1 1 *" # required even when disabled +``` + +```bash +# write the remote settings to workspaces/demo/key_rotation.yaml +cac --config ./cac.yaml --profile dev pull --workspace-key-rotation demo + +# preview what a push would send +cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo --dry-run + +# replace the remote settings of every key use present in the file +cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo + +# compare the uses present in the local file against the remote workspace +cac --config ./cac.yaml --profile dev diff --workspace-key-rotation demo +``` + +Constraints: + +- `sig` and `enc` are both optional. A use missing from the file is left untouched + remotely, so `diff` does not report it either, and `pull` omits a use that + SecureAuth reports as never configured. +- `cron` is required for every use present; `enabled` defaults to `false`. SecureAuth + validates `cron` even when `enabled` is `false`. +- `cron` uses the [gorhill/cronexpr](https://github.com/gorhill/cronexpr) syntax: + 5 fields, an optional 6th year field, or 7 fields with seconds first. The + descriptors `@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`, and + `@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. +- `scheduled_at` is read-only and rejected in the file. + +> **Note:** `push --workspace-key-rotation` validates every cron before any API call, +> and `--dry-run` prints the settings that would be sent instead of pushing them. + ## Templates Templates are used to generate configuration files. They are using [Go template language](https://golang.org/pkg/text/template/). From 6e7817f5362d2183dd9aa019c2a7ee7a76eb2cb0 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 6 Oct 2026 11:15:17 +0200 Subject: [PATCH 07/38] chore: bump acp-client-go to 128244c for the system keys service --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cd7c794..2189413 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/Masterminds/sprig/v3 v3.2.3 - github.com/cloudentity/acp-client-go v0.0.0-20260825070526-1de34904f06f + github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856 github.com/corvus-ch/zbase32 v1.0.0 github.com/go-json-experiment/json v0.0.0-20240524174822-2d9f40f7385b github.com/go-openapi/strfmt v0.24.0 diff --git a/go.sum b/go.sum index 7a7a870..b3e3f52 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/cloudentity/acp-client-go v0.0.0-20260825070526-1de34904f06f h1:EVEtG2VSF7NojXtgOeWjUQtgwOGvnBlTWmVpD24LEVQ= -github.com/cloudentity/acp-client-go v0.0.0-20260825070526-1de34904f06f/go.mod h1:Hr2WHHXmp+DC4B2oprhgP47yl/dsCAyj9HcG6P3z4m0= +github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856 h1:wQ2DzoPIGMjAYlDlxyxzIgVWLWgnwTTKjvlTGt6TVkM= +github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856/go.mod h1:Hr2WHHXmp+DC4B2oprhgP47yl/dsCAyj9HcG6P3z4m0= github.com/corvus-ch/zbase32 v1.0.0 h1:pDV0qZ1g+HYA8P0PbULsgUg/tZue1FIjsZ7r7h4nZeU= github.com/corvus-ch/zbase32 v1.0.0/go.mod h1:A7KLRecF1tysURyoqiJBvMJFmt/ccqkRdDTLjlQeVsU= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= From 44942e2d6bcd8c7e31722a08889b4e9e66478e05 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 6 Oct 2026 11:22:38 +0200 Subject: [PATCH 08/38] fix(storage): keep read-only server timestamps out of server.yaml 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. --- internal/cac/storage/server_storage.go | 14 ++++++++++++-- internal/cac/storage/server_storage_test.go | 1 + internal/cac/storage/tenant_storage_test.go | 1 + 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/cac/storage/server_storage.go b/internal/cac/storage/server_storage.go index 7e9f713..bb4b740 100644 --- a/internal/cac/storage/server_storage.go +++ b/internal/cac/storage/server_storage.go @@ -8,6 +8,7 @@ import ( smodels "github.com/cloudentity/acp-client-go/clients/system/models" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/utils" + "github.com/go-openapi/strfmt" "github.com/pkg/errors" "golang.org/x/exp/maps" "golang.org/x/exp/slog" @@ -272,10 +273,19 @@ func (s *ServerStorage) workspacePath(workspace string) string { return filepath.Join(s.Config.DirPath, "workspaces", workspace) } +// serverFile is the on-disk server model. It hides the read-only created_at and updated_at +// timestamps of smodels.ServerDump: the hub import model has no such fields, so writing their +// zero values would make the file fail strict decoding on push. +type serverFile struct { + smodels.ServerDump + CreatedAt *strfmt.DateTime `json:"created_at,omitempty"` + UpdatedAt *strfmt.DateTime `json:"updated_at,omitempty"` +} + func (s *ServerStorage) storeServer(workspace string, data *models.TreeServer) error { var ( path = filepath.Join(s.workspacePath(workspace), "server") - server smodels.ServerDump + server serverFile bts []byte err error ) @@ -285,7 +295,7 @@ func (s *ServerStorage) storeServer(workspace string, data *models.TreeServer) e return err } - if err = json.Unmarshal(bts, &server); err != nil { + if err = json.Unmarshal(bts, &server.ServerDump); err != nil { return err } diff --git a/internal/cac/storage/server_storage_test.go b/internal/cac/storage/server_storage_test.go index 997fca8..b7221e4 100644 --- a/internal/cac/storage/server_storage_test.go +++ b/internal/cac/storage/server_storage_test.go @@ -56,6 +56,7 @@ enforce_pkce: false enforce_pkce_for_public_clients: false grant_types: [] id: demo +id_jag_ttl: 0s id_token_ttl: 0s initialize: false name: demo workspace diff --git a/internal/cac/storage/tenant_storage_test.go b/internal/cac/storage/tenant_storage_test.go index 7430354..94d9622 100644 --- a/internal/cac/storage/tenant_storage_test.go +++ b/internal/cac/storage/tenant_storage_test.go @@ -75,6 +75,7 @@ enforce_pkce: false enforce_pkce_for_public_clients: false grant_types: [] id: demo +id_jag_ttl: 0s id_token_ttl: 0s initialize: false name: demo workspace From b910740927c362af89d73d67dc784dd13b5995ab Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 6 Oct 2026 11:22:38 +0200 Subject: [PATCH 09/38] feat(client): call the system keys API for automatic key rotation 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. --- README.md | 4 ++++ internal/cac/client/key_rotation_api.go | 8 ++++---- internal/cac/client/mock_server_test.go | 8 ++++---- internal/cac/keyrotation/keyrotation.go | 10 +++++----- internal/cac/keyrotation/keyrotation_test.go | 8 ++++---- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 91da6bc..3cd4a65 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,10 @@ cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo cac --config ./cac.yaml --profile dev diff --workspace-key-rotation demo ``` +The system workspace client used by cac needs the `manage_servers` scope for this +mode, in addition to `manage_configuration`: add it to `client.scopes` in the +[configuration](#configuration). + Constraints: - `sig` and `enc` are both optional. A use missing from the file is left untouched diff --git a/internal/cac/client/key_rotation_api.go b/internal/cac/client/key_rotation_api.go index 3f1e99e..90697dd 100644 --- a/internal/cac/client/key_rotation_api.go +++ b/internal/cac/client/key_rotation_api.go @@ -4,12 +4,12 @@ import ( "context" acpclient "github.com/cloudentity/acp-client-go" - kclient "github.com/cloudentity/acp-client-go/clients/admin/client/keys" + kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/pkg/errors" ) -// KeyRotationAPIStore talks to the admin automatic key rotation API for a single workspace. +// KeyRotationAPIStore talks to the system automatic key rotation API for a single workspace. type KeyRotationAPIStore struct { acp *acpclient.Client } @@ -23,7 +23,7 @@ func (s *KeyRotationAPIStore) Read(ctx context.Context, wid string) (*keyrotatio var cfg keyrotation.Config for _, use := range []string{keyrotation.UseSig, keyrotation.UseEnc} { - ok, err := s.acp.Admin.Keys.GetAutomaticKeyRotation( + ok, err := s.acp.System.Keys.GetAutomaticKeyRotation( kclient.NewGetAutomaticKeyRotationParams().WithContext(ctx).WithWid(wid).WithUse(&use), nil) if err != nil { return nil, errors.Wrapf(err, "failed to read key rotation for workspace %s, use %s", wid, use) @@ -46,7 +46,7 @@ func (s *KeyRotationAPIStore) Read(ctx context.Context, wid string) (*keyrotatio // Write sets rotation for each configured use, sig first. It stops at the first API error. func (s *KeyRotationAPIStore) Write(ctx context.Context, wid string, cfg *keyrotation.Config) error { for _, u := range cfg.Uses() { - if _, err := s.acp.Admin.Keys.SetAutomaticKeyRotation( + if _, err := s.acp.System.Keys.SetAutomaticKeyRotation( kclient.NewSetAutomaticKeyRotationParams(). WithContext(ctx). WithWid(wid). diff --git a/internal/cac/client/mock_server_test.go b/internal/cac/client/mock_server_test.go index 2e242ac..7ba01ca 100644 --- a/internal/cac/client/mock_server_test.go +++ b/internal/cac/client/mock_server_test.go @@ -1,8 +1,8 @@ package client_test import ( - admodels "github.com/cloudentity/acp-client-go/clients/admin/models" "github.com/cloudentity/acp-client-go/clients/hub/models" + smodels "github.com/cloudentity/acp-client-go/clients/system/models" "github.com/go-json-experiment/json" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" @@ -16,7 +16,7 @@ import ( ) const ( - keyRotationPathPrefix = "/api/admin/postmance/servers/" + keyRotationPathPrefix = "/api/system/postmance/servers/" keyRotationPathSuffix = "/keys/automatic-key-rotation" ) @@ -24,7 +24,7 @@ const ( type KeyRotationPut struct { Wid string Use string - Body admodels.AutomaticKeyRotation + Body smodels.AutomaticKeyRotation } // MockServer wraps httptest.Server and records key rotation calls. @@ -76,7 +76,7 @@ func (m *MockServer) handleKeyRotation(t *testing.T, res http.ResponseWriter, re body, err := io.ReadAll(req.Body) require.NoError(t, err) - var rotation admodels.AutomaticKeyRotation + var rotation smodels.AutomaticKeyRotation require.NoError(t, json.Unmarshal(body, &rotation)) m.mu.Lock() diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go index 35dae73..42197b9 100644 --- a/internal/cac/keyrotation/keyrotation.go +++ b/internal/cac/keyrotation/keyrotation.go @@ -4,7 +4,7 @@ package keyrotation import ( - admodels "github.com/cloudentity/acp-client-go/clients/admin/models" + smodels "github.com/cloudentity/acp-client-go/clients/system/models" "github.com/go-openapi/strfmt" "github.com/gorhill/cronexpr" "github.com/pkg/errors" @@ -17,7 +17,7 @@ const ( ) // Rotation is the on-disk schema, owned by cac rather than reusing -// admodels.AutomaticKeyRotation: that model carries a read-only scheduled_at field users must not +// smodels.AutomaticKeyRotation: that model carries a read-only scheduled_at field users must not // write, and its non-pointer date-times would serialize as 0001-01-01 whenever they are unset. type Rotation struct { Enabled bool `json:"enabled"` @@ -72,8 +72,8 @@ func (c *Config) Validate() error { } // ToModel converts a Rotation to the API model. ScheduledAt is left zero: it is read-only. -func (r *Rotation) ToModel() *admodels.AutomaticKeyRotation { - out := &admodels.AutomaticKeyRotation{ +func (r *Rotation) ToModel() *smodels.AutomaticKeyRotation { + out := &smodels.AutomaticKeyRotation{ Cron: r.Cron, Enabled: r.Enabled, } @@ -88,7 +88,7 @@ func (r *Rotation) ToModel() *admodels.AutomaticKeyRotation { // FromModel builds a Rotation out of a GET payload. It returns nil when the use was never // configured, which ACP reports as an empty cron rather than a 404. StartingFrom and ScheduledAt // are dropped on purpose: the server never echoes starting_from back, and scheduled_at is read-only. -func FromModel(m *admodels.AutomaticKeyRotation) *Rotation { +func FromModel(m *smodels.AutomaticKeyRotation) *Rotation { if m == nil || m.Cron == "" { return nil } diff --git a/internal/cac/keyrotation/keyrotation_test.go b/internal/cac/keyrotation/keyrotation_test.go index 76c8c96..28b3a2a 100644 --- a/internal/cac/keyrotation/keyrotation_test.go +++ b/internal/cac/keyrotation/keyrotation_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - admodels "github.com/cloudentity/acp-client-go/clients/admin/models" + smodels "github.com/cloudentity/acp-client-go/clients/system/models" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" @@ -114,7 +114,7 @@ func TestToModel(t *testing.T) { from := startingFrom(t, "2026-10-01T00:00:00Z") rotation := &keyrotation.Rotation{Enabled: true, Cron: "@monthly", StartingFrom: from} - require.Equal(t, &admodels.AutomaticKeyRotation{ + require.Equal(t, &smodels.AutomaticKeyRotation{ Cron: "@monthly", Enabled: true, StartingFrom: *from, @@ -135,11 +135,11 @@ func TestFromModel(t *testing.T) { }) t.Run("never configured", func(t *testing.T) { - require.Nil(t, keyrotation.FromModel(&admodels.AutomaticKeyRotation{Cron: ""})) + require.Nil(t, keyrotation.FromModel(&smodels.AutomaticKeyRotation{Cron: ""})) }) t.Run("drops server owned fields", func(t *testing.T) { - rotation := keyrotation.FromModel(&admodels.AutomaticKeyRotation{ + rotation := keyrotation.FromModel(&smodels.AutomaticKeyRotation{ Cron: "0 0 1 * *", Enabled: true, ScheduledAt: *startingFrom(t, "2026-11-01T00:00:00Z"), From af62202f7de5ec4ebea6ba283c63a8701cf423b1 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 6 Oct 2026 13:03:11 +0200 Subject: [PATCH 10/38] chore: re-pin acp-client-go to master now that ciam-client-go#76 has merged --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2189413..6e78c73 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.0 require ( github.com/Masterminds/sprig/v3 v3.2.3 - github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856 + github.com/cloudentity/acp-client-go v0.0.0-20261006105303-b8f2a1b4bb59 github.com/corvus-ch/zbase32 v1.0.0 github.com/go-json-experiment/json v0.0.0-20240524174822-2d9f40f7385b github.com/go-openapi/strfmt v0.24.0 diff --git a/go.sum b/go.sum index b3e3f52..c5acb04 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,8 @@ github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856 h1:wQ2DzoPIGMjAYlDlxyxzIgVWLWgnwTTKjvlTGt6TVkM= -github.com/cloudentity/acp-client-go v0.0.0-20261006091150-128244c7a856/go.mod h1:Hr2WHHXmp+DC4B2oprhgP47yl/dsCAyj9HcG6P3z4m0= +github.com/cloudentity/acp-client-go v0.0.0-20261006105303-b8f2a1b4bb59 h1:MYaO2dYPLPyjNBw92+6us1hrWE8iSaXxnYJGgIX6uPk= +github.com/cloudentity/acp-client-go v0.0.0-20261006105303-b8f2a1b4bb59/go.mod h1:Hr2WHHXmp+DC4B2oprhgP47yl/dsCAyj9HcG6P3z4m0= github.com/corvus-ch/zbase32 v1.0.0 h1:pDV0qZ1g+HYA8P0PbULsgUg/tZue1FIjsZ7r7h4nZeU= github.com/corvus-ch/zbase32 v1.0.0/go.mod h1:A7KLRecF1tysURyoqiJBvMJFmt/ccqkRdDTLjlQeVsU= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= From a5061a8be81ed9386d5c98d044e50852fe8eb6e9 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 6 Oct 2026 15:17:15 +0200 Subject: [PATCH 11/38] fix: honor --out on key rotation dry-run and reject id/tenant_id in key_rotation.yaml --- cmd/push.go | 4 ++++ internal/cac/keyrotation/dir_store.go | 24 ++++++++++++++++++++-- internal/cac/keyrotation/dir_store_test.go | 10 +++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cmd/push.go b/cmd/push.go index 43a0d32..bc8d65a 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -165,6 +165,10 @@ func pushKeyRotation(cmd *cobra.Command) error { return errors.Wrap(err, "failed to marshal key rotation") } + if pushConfig.Out != "-" { + return errors.Wrap(os.WriteFile(pushConfig.Out, bts, 0644), "failed to write key rotation to file") + } + if _, err = os.Stdout.Write(bts); err != nil { return errors.Wrap(err, "failed to write key rotation to stdout") } diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go index 0df4ceb..492a17d 100644 --- a/internal/cac/keyrotation/dir_store.go +++ b/internal/cac/keyrotation/dir_store.go @@ -4,9 +4,9 @@ import ( "os" "path/filepath" - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac/templates" "github.com/cloudentity/cac/internal/cac/utils" + "github.com/go-json-experiment/json" ccyaml "github.com/goccy/go-yaml" "github.com/pkg/errors" ) @@ -54,7 +54,9 @@ func (d *DirStore) Read(wid string) (*Config, error) { return nil, errors.Wrapf(err, "failed to parse %s", path) } - if config, err = utils.FromPatchToModel[Config](models.Rfc7396PatchOperation(raw)); err != nil { + // decoded directly rather than through utils.FromPatchToModel, which strips the + // workspace-patch keys id and tenant_id; here every unknown field must be rejected + if config, err = decodeStrict(raw); err != nil { return nil, errors.Wrapf(err, "failed to parse %s", path) } @@ -98,3 +100,21 @@ func (d *DirStore) Write(wid string, cfg *Config) error { return nil } + +func decodeStrict(raw map[string]any) (*Config, error) { + var ( + config Config + bts []byte + err error + ) + + if bts, err = json.Marshal(raw); err != nil { + return nil, err + } + + if err = json.Unmarshal(bts, &config, json.RejectUnknownMembers(true)); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/internal/cac/keyrotation/dir_store_test.go b/internal/cac/keyrotation/dir_store_test.go index 09cba31..74fd633 100644 --- a/internal/cac/keyrotation/dir_store_test.go +++ b/internal/cac/keyrotation/dir_store_test.go @@ -97,6 +97,16 @@ sig: sgi: enabled: true cron: '@monthly' +`, + }, + { + name: "workspace patch keys id and tenant_id", + content: ` +id: demo +tenant_id: t1 +sig: + enabled: true + cron: '@monthly' `, }, { From d445b6be78a69b9d580736799da8b02a0a69c188 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Sun, 11 Oct 2026 16:36:40 +0200 Subject: [PATCH 12/38] refactor(cmd): expose key rotation as pull/push/diff key-rotation subcommands Replace the exclusive root flag --workspace-key-rotation 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 --- README.md | 28 +-- cmd/diff.go | 120 +--------- cmd/flags.go | 13 ++ cmd/key_rotation.go | 207 ++++++++++++++++++ ..._rotation_test.go => key_rotation_test.go} | 30 ++- cmd/pull.go | 60 +---- cmd/push.go | 87 +------- cmd/root.go | 19 +- internal/cac/keyrotation/keyrotation.go | 2 +- internal/cac/storage/tenant_storage_test.go | 2 +- 10 files changed, 280 insertions(+), 288 deletions(-) create mode 100644 cmd/flags.go create mode 100644 cmd/key_rotation.go rename cmd/{diff_key_rotation_test.go => key_rotation_test.go} (71%) diff --git a/README.md b/README.md index 3cd4a65..aa75c9a 100644 --- a/README.md +++ b/README.md @@ -242,12 +242,14 @@ map[string]any{ ### Managing key rotation Automatic key rotation settings of a workspace live behind a dedicated API and are -managed with the exclusive `--workspace-key-rotation ` flag (mutually -exclusive with `--workspace`, `--tenant`, and `--filter`). Plain `pull`, `push`, and -`diff` never read or write these settings. They are stored in -`workspaces//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: +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//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: ```yaml # workspaces/demo/key_rotation.yaml @@ -262,20 +264,20 @@ enc: ```bash # write the remote settings to workspaces/demo/key_rotation.yaml -cac --config ./cac.yaml --profile dev pull --workspace-key-rotation demo +cac --config ./cac.yaml --profile dev --workspace demo pull key-rotation # preview what a push would send -cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo --dry-run +cac --config ./cac.yaml --profile dev --workspace demo push key-rotation --dry-run # replace the remote settings of every key use present in the file -cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo +cac --config ./cac.yaml --profile dev --workspace demo push key-rotation # compare the uses present in the local file against the remote workspace -cac --config ./cac.yaml --profile dev diff --workspace-key-rotation demo +cac --config ./cac.yaml --profile dev --workspace demo diff key-rotation ``` -The system workspace client used by cac needs the `manage_servers` scope for this -mode, in addition to `manage_configuration`: add it to `client.scopes` in the +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). Constraints: @@ -293,7 +295,7 @@ Constraints: never writes it and `diff` ignores it. It is only honored when it is in the future. - `scheduled_at` is read-only and rejected in the file. -> **Note:** `push --workspace-key-rotation` validates every cron before any API call, +> **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. ## Templates diff --git a/cmd/diff.go b/cmd/diff.go index ccc57ce..46fd9bf 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -1,18 +1,13 @@ package cmd import ( - "os" - "strings" - - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/diff" - "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" + "os" ) var ( @@ -52,24 +47,6 @@ Examples: err error ) - if rootConfig.WorkspaceKeyRotation != "" { - return diffKeyRotation(cmd) - } - - var missing []string - - if diffConfig.Source == "" { - missing = append(missing, "source") - } - - if diffConfig.Target == "" { - missing = append(missing, "target") - } - - if len(missing) > 0 { - return errors.Errorf(`required flag(s) "%s" not set`, strings.Join(missing, `", "`)) - } - slog. With("workspace", rootConfig.Workspace). With("config", rootConfig.ConfigPath). @@ -137,96 +114,15 @@ func writeDiffResult(result string) error { return nil } -func diffKeyRotation(cmd *cobra.Command) error { - var ( - app *cac.Application - local, remote *keyrotation.Config - sourcePatch, targetPatch models.Rfc7396PatchOperation - result string - err error - ) - - if len(diffConfig.Filters) > 0 { - return errors.New("--filter cannot be combined with --workspace-key-rotation") - } - - if diffConfig.Source != "" || diffConfig.Target != "" { - return errors.New("--source/--target do not apply to --workspace-key-rotation; the local file is always compared against the remote workspace") - } - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - wid := rootConfig.WorkspaceKeyRotation - - if local, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") - } - - if remote, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { - return err - } - - source, target := keyRotationDiffConfigs(local, remote) - - if sourcePatch, err = utils.FromModelToPatch(source); err != nil { - return errors.Wrap(err, "failed to convert local key rotation") - } - - if targetPatch, err = utils.FromModelToPatch(target); err != nil { - return errors.Wrap(err, "failed to convert remote key rotation") - } - - if result, err = diff.Tree(sourcePatch, targetPatch, diff.Colorize(diffConfig.Colors)); err != nil { - return err - } - - return writeDiffResult(result) -} - -// keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies -// its arguments and replaces nil with an empty configuration. -func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target *keyrotation.Config) { - if local == nil { - local = &keyrotation.Config{} - } - - if remote == nil { - remote = &keyrotation.Config{} - } - - // the server never echoes starting_from back, so it would always show up as a difference - for _, use := range local.Uses() { - use.Rotation.StartingFrom = nil - } - - // push never removes a use that is absent locally, so diff previews only what push would change - if local.Sig == nil { - remote.Sig = nil - } - - if local.Enc == nil { - remote.Enc = nil - } - - return local, remote -} - func init() { - diffCmd.PersistentFlags().StringVar(&diffConfig.Source, "source", "", `Source of the comparison (required). Format: [profile@]source-type + diffCmd.Flags().StringVar(&diffConfig.Source, "source", "", `Source of the comparison (required). Format: [profile@]source-type Source types: local, remote, merged Examples: --source local --source remote --source merged --source dev@remote`) - diffCmd.PersistentFlags().StringVar(&diffConfig.Target, "target", "", `Target of the comparison (required). Format: [profile@]source-type + diffCmd.Flags().StringVar(&diffConfig.Target, "target", "", `Target of the comparison (required). Format: [profile@]source-type Source types: local, remote, merged Examples: --target remote @@ -234,9 +130,9 @@ Examples: --target merged`) diffCmd.PersistentFlags().BoolVar(&diffConfig.Colors, "colors", true, `Colorize the diff output (default true). Example: --colors=false`) - diffCmd.PersistentFlags().BoolVar(&diffConfig.OnlyPresent, "only-present", false, `Only diff resources that are present in the source; ignore resources that only exist in the target. + diffCmd.Flags().BoolVar(&diffConfig.OnlyPresent, "only-present", false, `Only diff resources that are present in the source; ignore resources that only exist in the target. Example: --only-present`) - diffCmd.PersistentFlags().StringSliceVar(&diffConfig.Filters, "filter", []string{}, `Restrict the comparison to selected top-level resources (comma-separated or repeated). + diffCmd.Flags().StringSliceVar(&diffConfig.Filters, "filter", []string{}, `Restrict the comparison to selected top-level resources (comma-separated or repeated). Workspace resources: clients, idps, claims, custom_apps, gateways, policies, policy_execution_points, pools, scopes (alias of scopes_without_service), scripts, script_execution_points, server_consent, servers_bindings, services, theme_binding, webhooks, @@ -252,8 +148,10 @@ Examples: Examples: --out - (stdout) --out ./diff.txt (file)`) - diffCmd.PersistentFlags().BoolVar(&diffConfig.WithSecrets, "with-secrets", false, `Include secret fields in the comparison. + diffCmd.Flags().BoolVar(&diffConfig.WithSecrets, "with-secrets", false, `Include secret fields in the comparison. Example: --with-secrets`) - diffCmd.PersistentFlags().BoolVar(&diffConfig.FilterVolatile, "no-volatile", false, `Ignore volatile fields (e.g. timestamps, generated IDs) when comparing. + diffCmd.Flags().BoolVar(&diffConfig.FilterVolatile, "no-volatile", false, `Ignore volatile fields (e.g. timestamps, generated IDs) when comparing. Example: --no-volatile`) + + mustMarkRequired(diffCmd, "source", "target") } diff --git a/cmd/flags.go b/cmd/flags.go new file mode 100644 index 0000000..914d733 --- /dev/null +++ b/cmd/flags.go @@ -0,0 +1,13 @@ +package cmd + +import "github.com/spf13/cobra" + +func mustMarkRequired(cmd *cobra.Command, flags ...string) { + for _, flag := range flags { + err := cmd.MarkFlagRequired(flag) + + if err != nil { + panic(err) + } + } +} diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go new file mode 100644 index 0000000..8ba0948 --- /dev/null +++ b/cmd/key_rotation.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "os" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "golang.org/x/exp/slog" +) + +// newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff. It operates on the +// workspace selected with the root --workspace flag. +func newKeyRotationCmd(short string, run func(cmd *cobra.Command, wid string) error) *cobra.Command { + return &cobra.Command{ + Use: "key-rotation", + Short: short, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if rootConfig.Tenant { + return errors.New("--tenant does not apply to key-rotation; select a workspace with --workspace") + } + + return run(cmd, rootConfig.Workspace) + }, + } +} + +func init() { + pullCmd.AddCommand(newKeyRotationCmd("Pull the automatic key rotation settings of the workspace", pullKeyRotation)) + pushCmd.AddCommand(newKeyRotationCmd("Push the automatic key rotation settings of the workspace", pushKeyRotation)) + diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", diffKeyRotation)) +} + +func pullKeyRotation(cmd *cobra.Command, wid string) error { + var ( + app *cac.Application + cfg *keyrotation.Config + err error + ) + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + slog.With("workspace", wid).Info("Pulling key rotation") + + if cfg, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { + return err + } + + if cfg == nil { + slog.Info("No key rotation configured", "workspace", wid) + return nil + } + + if err = dirStore.Write(wid, cfg); err != nil { + return err + } + + slog.Info("Pulled key rotation", "workspace", wid) + + return nil +} + +func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { + if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { + return nil, errors.New("no storage directories configured for the selected profile") + } + + return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil +} + +func pushKeyRotation(cmd *cobra.Command, wid string) error { + var ( + app *cac.Application + cfg *keyrotation.Config + err error + ) + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + if cfg, err = dirStore.Read(wid); err != nil { + return errors.Wrap(err, "failed to read local key rotation") + } + + if cfg == nil { + slog.Info("No key rotation configuration to push", "workspace", wid) + return nil + } + + if err = cfg.Validate(); err != nil { + return errors.Wrap(err, "failed to validate key rotation") + } + + if pushConfig.DryRun { + bts, err := utils.ToYaml(cfg) + if err != nil { + return errors.Wrap(err, "failed to marshal key rotation") + } + + if pushConfig.Out != "-" { + return errors.Wrap(os.WriteFile(pushConfig.Out, bts, 0644), "failed to write key rotation to file") + } + + if _, err = os.Stdout.Write(bts); err != nil { + return errors.Wrap(err, "failed to write key rotation to stdout") + } + + return nil + } + + if err = app.KeyRotation.Write(cmd.Context(), wid, cfg); err != nil { + return err + } + + slog.Info("Pushed key rotation", "workspace", wid, "uses", len(cfg.Uses())) + + return nil +} + +func diffKeyRotation(cmd *cobra.Command, wid string) error { + var ( + app *cac.Application + local, remote *keyrotation.Config + sourcePatch, targetPatch models.Rfc7396PatchOperation + result string + err error + ) + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + if local, err = dirStore.Read(wid); err != nil { + return errors.Wrap(err, "failed to read local key rotation") + } + + if remote, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { + return err + } + + source, target := keyRotationDiffConfigs(local, remote) + + if sourcePatch, err = utils.FromModelToPatch(source); err != nil { + return errors.Wrap(err, "failed to convert local key rotation") + } + + if targetPatch, err = utils.FromModelToPatch(target); err != nil { + return errors.Wrap(err, "failed to convert remote key rotation") + } + + if result, err = diff.Tree(sourcePatch, targetPatch, diff.Colorize(diffConfig.Colors)); err != nil { + return err + } + + return writeDiffResult(result) +} + +// keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies +// its arguments and replaces nil with an empty configuration. +func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target *keyrotation.Config) { + if local == nil { + local = &keyrotation.Config{} + } + + if remote == nil { + remote = &keyrotation.Config{} + } + + // the server never echoes starting_from back, so it would always show up as a difference + for _, use := range local.Uses() { + use.Rotation.StartingFrom = nil + } + + // push never removes a use that is absent locally, so diff previews only what push would change + if local.Sig == nil { + remote.Sig = nil + } + + if local.Enc == nil { + remote.Enc = nil + } + + return local, remote +} diff --git a/cmd/diff_key_rotation_test.go b/cmd/key_rotation_test.go similarity index 71% rename from cmd/diff_key_rotation_test.go rename to cmd/key_rotation_test.go index c1967c7..8a8baa0 100644 --- a/cmd/diff_key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -1,6 +1,7 @@ package cmd import ( + "io" "testing" "time" @@ -70,22 +71,31 @@ func TestKeyRotationDiffConfigs(t *testing.T) { }) } -func TestDiffRequiredFlags(t *testing.T) { +// TestKeyRotationFlagScoping checks that the required flags of push and diff still apply to them, while +// the key-rotation subcommands neither require nor accept the parent commands' own flags. +func TestKeyRotationFlagScoping(t *testing.T) { tcs := []struct { - source, target string - err string + args []string + err string }{ - {"", "", `required flag(s) "source", "target" not set`}, - {"local", "", `required flag(s) "target" not set`}, - {"", "remote", `required flag(s) "source" not set`}, + {[]string{"--workspace", "demo", "push"}, `required flag(s) "method" not set`}, + {[]string{"--workspace", "demo", "diff"}, `required flag(s) "source", "target" not set`}, + {[]string{"--workspace", "demo", "push", "key-rotation", "--method", "import"}, "unknown flag: --method"}, + {[]string{"--workspace", "demo", "pull", "key-rotation", "--filter", "clients"}, "unknown flag: --filter"}, + {[]string{"--workspace", "demo", "diff", "key-rotation", "--source", "local"}, "unknown flag: --source"}, } - saved := diffConfig - t.Cleanup(func() { diffConfig = saved }) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) for _, tc := range tcs { - diffConfig.Source, diffConfig.Target = tc.source, tc.target + rootCmd.SetArgs(tc.args) - require.EqualError(t, diffCmd.RunE(diffCmd, nil), tc.err) + require.EqualError(t, rootCmd.Execute(), tc.err, tc.args) } } diff --git a/cmd/pull.go b/cmd/pull.go index c5a53c7..982cec1 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -4,8 +4,6 @@ import ( "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" - "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" ) @@ -36,10 +34,6 @@ Examples: err error ) - if rootConfig.WorkspaceKeyRotation != "" { - return pullKeyRotation(cmd) - } - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -73,60 +67,10 @@ Examples: } ) -func pullKeyRotation(cmd *cobra.Command) error { - var ( - app *cac.Application - cfg *keyrotation.Config - err error - ) - - if len(pullConfig.Filters) > 0 { - return errors.New("--filter cannot be combined with --workspace-key-rotation") - } - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - wid := rootConfig.WorkspaceKeyRotation - - slog.With("workspace", wid).Info("Pulling key rotation") - - if cfg, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { - return err - } - - if cfg == nil { - slog.Info("No key rotation configured", "workspace", wid) - return nil - } - - if err = dirStore.Write(wid, cfg); err != nil { - return err - } - - slog.Info("Pulled key rotation", "workspace", wid) - - return nil -} - -func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { - if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { - return nil, errors.New("no storage directories configured for the selected profile") - } - - return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil -} - func init() { - pullCmd.PersistentFlags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. + pullCmd.Flags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. Example: --with-secrets`) - pullCmd.PersistentFlags().StringSliceVar(&pullConfig.Filters, "filter", []string{}, `Restrict the pull to selected top-level resources (comma-separated or repeated). + pullCmd.Flags().StringSliceVar(&pullConfig.Filters, "filter", []string{}, `Restrict the pull to selected top-level resources (comma-separated or repeated). Workspace resources: clients, idps, claims, custom_apps, gateways, policies, policy_execution_points, pools, scopes (alias of scopes_without_service), scripts, script_execution_points, server_consent, servers_bindings, services, theme_binding, webhooks, diff --git a/cmd/push.go b/cmd/push.go index bc8d65a..6bc194d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,14 +1,10 @@ package cmd import ( - "os" - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" - "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/storage" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" @@ -46,14 +42,6 @@ Examples: err error ) - if rootConfig.WorkspaceKeyRotation != "" { - return pushKeyRotation(cmd) - } - - if pushConfig.Method == "" { - return errors.New(`required flag(s) "method" not set`) - } - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -120,71 +108,6 @@ Examples: } ) -func pushKeyRotation(cmd *cobra.Command) error { - var ( - app *cac.Application - cfg *keyrotation.Config - err error - ) - - if len(pushConfig.Filters) > 0 { - return errors.New("--filter cannot be combined with --workspace-key-rotation") - } - - if pushConfig.Method != "" { - return errors.New("--method does not apply to --workspace-key-rotation; each configured key use is always replaced") - } - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - wid := rootConfig.WorkspaceKeyRotation - - if cfg, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") - } - - if cfg == nil { - slog.Info("No key rotation configuration to push", "workspace", wid) - return nil - } - - if err = cfg.Validate(); err != nil { - return errors.Wrap(err, "failed to validate key rotation") - } - - if pushConfig.DryRun { - bts, err := utils.ToYaml(cfg) - if err != nil { - return errors.Wrap(err, "failed to marshal key rotation") - } - - if pushConfig.Out != "-" { - return errors.Wrap(os.WriteFile(pushConfig.Out, bts, 0644), "failed to write key rotation to file") - } - - if _, err = os.Stdout.Write(bts); err != nil { - return errors.Wrap(err, "failed to write key rotation to stdout") - } - - return nil - } - - if err = app.KeyRotation.Write(cmd.Context(), wid, cfg); err != nil { - return err - } - - slog.Info("Pushed key rotation", "workspace", wid, "uses", len(cfg.Uses())) - - return nil -} - func init() { pushCmd.PersistentFlags().BoolVar(&pushConfig.DryRun, "dry-run", false, `Write the resolved configuration to disk or stdout instead of pushing to the server. Use with --out to control the destination. @@ -195,21 +118,21 @@ Examples: --out - (stdout) --out ./out.yaml (single file) --out ./out/ (directory)`) - pushCmd.PersistentFlags().StringVar(&pushConfig.Mode, "mode", "update", `Conflict resolution mode when a resource already exists on the server. + pushCmd.Flags().StringVar(&pushConfig.Mode, "mode", "update", `Conflict resolution mode when a resource already exists on the server. One of: ignore, fail, update ignore - skip existing resources fail - abort the push on conflict update - overwrite the existing resource (default) Example: --mode update`) - pushCmd.PersistentFlags().StringVar(&pushConfig.Method, "method", "", `How to apply the configuration to the server (required). + pushCmd.Flags().StringVar(&pushConfig.Method, "method", "", `How to apply the configuration to the server (required). One of: patch, import patch - merge remote configuration with your local config before applying import - replace remote configuration with your local config Example: --method patch`) - pushCmd.PersistentFlags().BoolVar(&pushConfig.NoLocalValidate, "no-validate", false, `Skip client-side validation before pushing. + pushCmd.Flags().BoolVar(&pushConfig.NoLocalValidate, "no-validate", false, `Skip client-side validation before pushing. Workaround for cases where local validation rejects a configuration the server accepts. Example: --no-validate`) - pushCmd.PersistentFlags().StringSliceVar(&pushConfig.Filters, "filter", []string{}, `Restrict the push to selected top-level resources (comma-separated or repeated). + pushCmd.Flags().StringSliceVar(&pushConfig.Filters, "filter", []string{}, `Restrict the push to selected top-level resources (comma-separated or repeated). Workspace resources: clients, idps, claims, custom_apps, gateways, policies, policy_execution_points, pools, scopes (alias of scopes_without_service), scripts, script_execution_points, server_consent, servers_bindings, services, theme_binding, webhooks, @@ -222,4 +145,6 @@ Examples: --filter scopes --filter pools --filter root --filter root,clients`) + + mustMarkRequired(pushCmd, "method") } diff --git a/cmd/root.go b/cmd/root.go index af4a2eb..2652205 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,11 +28,10 @@ Examples: ) type RootConfig struct { - ConfigPath string - Profile string - Workspace string - Tenant bool - WorkspaceKeyRotation string + ConfigPath string + Profile string + Workspace string + Tenant bool } func init() { @@ -46,19 +45,13 @@ Example: --tenant`) rootCmd.PersistentFlags().StringVar(&rootConfig.Workspace, "workspace", "", `Workspace identifier to operate on. Mutually exclusive with --tenant. Example: --workspace demo`) - rootCmd.PersistentFlags().StringVar(&rootConfig.WorkspaceKeyRotation, "workspace-key-rotation", "", `Operate exclusively on the automatic key rotation settings of the given workspace. -Mutually exclusive with --workspace, --tenant, and --filter. -Examples: - cac --config ./cac.yaml --profile dev pull --workspace-key-rotation demo - cac --config ./cac.yaml --profile dev push --workspace-key-rotation demo - cac --config ./cac.yaml --profile dev diff --workspace-key-rotation demo`) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(diffCmd) - rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant", "workspace-key-rotation") - rootCmd.MarkFlagsOneRequired("workspace", "tenant", "workspace-key-rotation") + rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant") + rootCmd.MarkFlagsOneRequired("workspace", "tenant") } func Execute() error { diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go index 42197b9..f216a2b 100644 --- a/internal/cac/keyrotation/keyrotation.go +++ b/internal/cac/keyrotation/keyrotation.go @@ -1,6 +1,6 @@ // Package keyrotation holds the automatic key rotation configuration of a workspace. It is not part // of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file and is only -// handled by the dedicated --workspace-key-rotation mode. +// handled by the dedicated pull/push/diff key-rotation subcommands. package keyrotation import ( diff --git a/internal/cac/storage/tenant_storage_test.go b/internal/cac/storage/tenant_storage_test.go index 94d9622..0721f15 100644 --- a/internal/cac/storage/tenant_storage_test.go +++ b/internal/cac/storage/tenant_storage_test.go @@ -428,7 +428,7 @@ func TestTenantStorageReadSkipsWorkspaceDirWithoutServer(t *testing.T) { require.NoError(t, st.Write(context.Background(), written, api.WithWorkspace("demo"))) - // pull --workspace-key-rotation in a tenant layout creates a workspace dir holding only key_rotation.yaml + // pull key-rotation in a tenant layout creates a workspace dir holding only key_rotation.yaml require.NoError(t, os.MkdirAll(filepath.Join(dir, "workspaces", "other"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces", "other", "key_rotation.yaml"), []byte("sig:\n cron: \"0 0 1 * *\"\n"), 0644)) From a610461094f2741cea2a0ea80d53ee3b607818af Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:23:51 +0200 Subject: [PATCH 13/38] feat(config): add features.key_rotation with a manage_servers scope check 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. --- internal/cac/app.go | 4 + internal/cac/config/config.go | 51 ++++++-- internal/cac/config/config_test.go | 123 ++++++++++++++++++ internal/cac/config/testdata/features.yaml | 14 ++ .../cac/config/testdata/features_profile.yaml | 10 ++ 5 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 internal/cac/config/testdata/features.yaml create mode 100644 internal/cac/config/testdata/features_profile.yaml diff --git a/internal/cac/app.go b/internal/cac/app.go index 507f5c6..c192a27 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -31,6 +31,10 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, return app, err } + if err = app.Config.Validate(); err != nil { + return app, err + } + if err = logging.InitLogging(app.Config.Logging); err != nil { return app, err } diff --git a/internal/cac/config/config.go b/internal/cac/config/config.go index 7b9d125..6e0a71d 100644 --- a/internal/cac/config/config.go +++ b/internal/cac/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "slices" "strings" "github.com/cloudentity/cac/internal/cac/client" @@ -17,13 +18,23 @@ import ( var ( DefaultConfig = func() Configuration { return Configuration{ - Client: client.DefaultConfig(), - Storage: storage.DefaultMultiStorageConfig(), - Logging: logging.DefaultLoggingConfig(), + Client: client.DefaultConfig(), + Storage: storage.DefaultMultiStorageConfig(), + Logging: logging.DefaultLoggingConfig(), + Features: &Features{}, } } ) +// ManageServersScope is the scope the system keys API behind key rotation requires. +const ManageServersScope = "manage_servers" + +// Features toggles optional behaviour of the plain pull, push and diff commands. +type Features struct { + // KeyRotation makes plain pull, push and diff also handle workspaces//key_rotation.yaml. + KeyRotation bool `json:"key_rotation"` +} + type RootConfiguration struct { // nolint Default Configuration `json:",inline,squash"` // default profile @@ -46,10 +57,25 @@ func (c *RootConfiguration) ForProfile(profile string) (*Configuration, error) { } type Configuration struct { - Name string `json:"name"` - Logging *logging.Configuration `json:"logging"` - Client *client.Configuration `json:"client"` - Storage *storage.MultiStorageConfiguration `json:"storage"` + Name string `json:"name"` + Logging *logging.Configuration `json:"logging"` + Client *client.Configuration `json:"client"` + Storage *storage.MultiStorageConfiguration `json:"storage"` + Features *Features `json:"features"` +} + +// KeyRotationEnabled reports whether features.key_rotation is on. +func (c *Configuration) KeyRotationEnabled() bool { + return c.Features != nil && c.Features.KeyRotation +} + +// Validate checks settings that only make sense together. +func (c *Configuration) Validate() error { + if c.KeyRotationEnabled() && (c.Client == nil || !slices.Contains(c.Client.Scopes, ManageServersScope)) { + return errors.New("features.key_rotation requires the manage_servers scope in client.scopes") + } + + return nil } func (c *Configuration) SetImplicitValues(name string, defaultConfig Configuration) { @@ -59,9 +85,9 @@ func (c *Configuration) SetImplicitValues(name string, defaultConfig Configurati // Fall back to the default config, but as an independent copy so that // profiles never share (and accidentally mutate) the default's objects. - // Logging only holds value fields, so a plain struct copy is enough; the - // client and storage configs carry reference fields and are deep-copied - // via their Clone methods. + // Logging and features only hold value fields, so a plain struct copy is + // enough; the client and storage configs carry reference fields and are + // deep-copied via their Clone methods. if c.Logging == nil && defaultConfig.Logging != nil { clone := *defaultConfig.Logging c.Logging = &clone @@ -74,6 +100,11 @@ func (c *Configuration) SetImplicitValues(name string, defaultConfig Configurati if c.Storage == nil && defaultConfig.Storage != nil { c.Storage = defaultConfig.Storage.Clone() } + + if c.Features == nil && defaultConfig.Features != nil { + clone := *defaultConfig.Features + c.Features = &clone + } } func InitConfig(path string) (_ *RootConfiguration, err error) { diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index b533051..86803f1 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -175,4 +175,127 @@ func TestReadingConfiguration(t *testing.T) { require.NotNil(t, conf.Client.IssuerURL) require.Equal(t, "https://postmance.eu.authz.cloudentity.io/postmance/system", conf.Client.IssuerURL.String()) }) + + t.Run("features are off by default", func(t *testing.T) { + rootConf, err := config.InitConfig("./../../../examples/e2e/config.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NotNil(t, conf.Features) + require.False(t, conf.KeyRotationEnabled()) + + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + require.False(t, stage.KeyRotationEnabled()) + }) + + t.Run("profile features override the default features", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.True(t, defaultConf.Features.KeyRotation) + + off, err := rootConf.ForProfile("off") + require.NoError(t, err) + require.False(t, off.Features.KeyRotation) + + // a profile without features falls back to an independent copy of the default + bare, err := rootConf.ForProfile("bare") + require.NoError(t, err) + require.True(t, bare.Features.KeyRotation) + require.NotSame(t, defaultConf.Features, bare.Features) + + bare.Features.KeyRotation = false + require.True(t, defaultConf.Features.KeyRotation, + "mutating the profile features must not affect the default features") + }) + + t.Run("profile enables a feature the default leaves off", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.False(t, defaultConf.KeyRotationEnabled()) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.True(t, on.KeyRotationEnabled()) + + bare, err := rootConf.ForProfile("bare") + require.NoError(t, err) + require.False(t, bare.KeyRotationEnabled()) + }) + + t.Run("features from env", func(t *testing.T) { + t.Setenv("FEATURES_KEY_ROTATION", "true") + + rootConf, err := config.InitConfig("./testdata/profile_env_conflict.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.True(t, defaultConf.KeyRotationEnabled()) + + // stage declares no features, so it inherits the default + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + require.True(t, stage.KeyRotationEnabled()) + }) + + t.Run("profile features from env", func(t *testing.T) { + t.Setenv("PROFILES_ON_FEATURES_KEY_ROTATION", "false") + + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.False(t, on.KeyRotationEnabled()) + }) +} + +func TestValidateConfiguration(t *testing.T) { + const scopeErr = "features.key_rotation requires the manage_servers scope in client.scopes" + + t.Run("key rotation with the default scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NoError(t, conf.Validate()) + + // the default client scopes are only manage_configuration + conf.Client.Scopes = config.DefaultConfig().Client.Scopes + require.EqualError(t, conf.Validate(), scopeErr) + }) + + t.Run("key rotation without manage_servers in the profile scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.NoError(t, on.Validate()) + + on.Client.Scopes = []string{"manage_configuration"} + require.EqualError(t, on.Validate(), scopeErr) + + on.Client = nil + require.EqualError(t, on.Validate(), scopeErr) + }) + + t.Run("scopes are not checked when key rotation is off", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NotContains(t, conf.Client.Scopes, config.ManageServersScope) + require.NoError(t, conf.Validate()) + }) } diff --git a/internal/cac/config/testdata/features.yaml b/internal/cac/config/testdata/features.yaml new file mode 100644 index 0000000..8525d25 --- /dev/null +++ b/internal/cac/config/testdata/features.yaml @@ -0,0 +1,14 @@ +client: + scopes: + - manage_configuration + - manage_servers +features: + key_rotation: true + +profiles: + # overrides the default features + off: + features: + key_rotation: false + # declares nothing of its own, so it falls back to (a copy of) the default features + bare: {} diff --git a/internal/cac/config/testdata/features_profile.yaml b/internal/cac/config/testdata/features_profile.yaml new file mode 100644 index 0000000..e7eaa15 --- /dev/null +++ b/internal/cac/config/testdata/features_profile.yaml @@ -0,0 +1,10 @@ +# the default config leaves features out, a profile turns key rotation on +profiles: + on: + client: + scopes: + - manage_configuration + - manage_servers + features: + key_rotation: true + bare: {} From f7cc653a1d943161a281b24c3bcfeb483f33f829 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:35:47 +0200 Subject: [PATCH 14/38] feat(cmd): handle key rotation in plain pull, push and diff behind features.key_rotation With the feature enabled, plain pull, push and diff also handle workspaces//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. --- cmd/diff.go | 52 ++-- cmd/key_rotation.go | 84 ++++-- cmd/key_rotation_feature.go | 431 +++++++++++++++++++++++++++++++ cmd/key_rotation_feature_test.go | 180 +++++++++++++ cmd/key_rotation_test.go | 12 +- cmd/pull.go | 50 ++-- cmd/push.go | 121 ++++++--- 7 files changed, 820 insertions(+), 110 deletions(-) create mode 100644 cmd/key_rotation_feature.go create mode 100644 cmd/key_rotation_feature_test.go diff --git a/cmd/diff.go b/cmd/diff.go index 46fd9bf..0e76944 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -40,11 +40,14 @@ Examples: --source local --target remote --out diff.txt`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - result string - source api.Source - target api.Source - err error + app *cac.Application + result string + source api.Source + target api.Source + handle bool + normal bool + filters []string + err error ) slog. @@ -63,24 +66,36 @@ Examples: return err } - if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { + if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { return err } - if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); err != nil { - return err + if normal { + if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { + return err + } + + if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); 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(filters...), + diff.WithSecrets(diffConfig.WithSecrets), + diff.FilterVolatileFields(diffConfig.FilterVolatile), + ); 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 { - return err + if handle { + if result, err = diffKeyRotations(cmd.Context(), app, result); err != nil { + return err + } } return writeDiffResult(result) @@ -139,6 +154,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 diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index 8ba0948..e5150f1 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "os" "github.com/cloudentity/acp-client-go/clients/hub/models" @@ -39,7 +40,6 @@ func init() { func pullKeyRotation(cmd *cobra.Command, wid string) error { var ( app *cac.Application - cfg *keyrotation.Config err error ) @@ -52,9 +52,20 @@ func pullKeyRotation(cmd *cobra.Command, wid string) error { return err } + return pullWorkspaceKeyRotation(cmd.Context(), app, dirStore, wid) +} + +// pullWorkspaceKeyRotation writes the remote key rotation settings of the workspace to dirStore. It +// writes nothing when the workspace has none configured. +func pullWorkspaceKeyRotation(ctx context.Context, app *cac.Application, dirStore *keyrotation.DirStore, wid string) error { + var ( + cfg *keyrotation.Config + err error + ) + slog.With("workspace", wid).Info("Pulling key rotation") - if cfg, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { + if cfg, err = app.KeyRotation.Read(ctx, wid); err != nil { return err } @@ -80,6 +91,25 @@ func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil } +// readKeyRotation reads and validates the local key rotation settings of the workspace. It returns +// nil when the workspace has none. +func readKeyRotation(dirStore *keyrotation.DirStore, wid string) (*keyrotation.Config, error) { + cfg, err := dirStore.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg == nil { + return nil, nil + } + + if err = cfg.Validate(); err != nil { + return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) + } + + return cfg, nil +} + func pushKeyRotation(cmd *cobra.Command, wid string) error { var ( app *cac.Application @@ -96,8 +126,8 @@ func pushKeyRotation(cmd *cobra.Command, wid string) error { return err } - if cfg, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") + if cfg, err = readKeyRotation(dirStore, wid); err != nil { + return err } if cfg == nil { @@ -105,10 +135,6 @@ func pushKeyRotation(cmd *cobra.Command, wid string) error { return nil } - if err = cfg.Validate(); err != nil { - return errors.Wrap(err, "failed to validate key rotation") - } - if pushConfig.DryRun { bts, err := utils.ToYaml(cfg) if err != nil { @@ -126,7 +152,11 @@ func pushKeyRotation(cmd *cobra.Command, wid string) error { return nil } - if err = app.KeyRotation.Write(cmd.Context(), wid, cfg); err != nil { + return pushWorkspaceKeyRotation(cmd.Context(), app, wid, cfg) +} + +func pushWorkspaceKeyRotation(ctx context.Context, app *cac.Application, wid string, cfg *keyrotation.Config) error { + if err := app.KeyRotation.Write(ctx, wid, cfg); err != nil { return err } @@ -137,11 +167,10 @@ func pushKeyRotation(cmd *cobra.Command, wid string) error { func diffKeyRotation(cmd *cobra.Command, wid string) error { var ( - app *cac.Application - local, remote *keyrotation.Config - sourcePatch, targetPatch models.Rfc7396PatchOperation - result string - err error + app *cac.Application + local, remote *keyrotation.Config + result string + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { @@ -161,21 +190,32 @@ func diffKeyRotation(cmd *cobra.Command, wid string) error { return err } - source, target := keyRotationDiffConfigs(local, remote) + if result, err = keyRotationTreeDiff(local, remote, diffConfig.Colors); err != nil { + return err + } + + return writeDiffResult(result) +} + +// keyRotationTreeDiff renders the difference between the source and target settings the way the +// configuration diff does, after keyRotationDiffConfigs. It modifies its arguments. +func keyRotationTreeDiff(source, target *keyrotation.Config, colors bool) (string, error) { + var ( + sourcePatch, targetPatch models.Rfc7396PatchOperation + err error + ) + + source, target = keyRotationDiffConfigs(source, target) if sourcePatch, err = utils.FromModelToPatch(source); err != nil { - return errors.Wrap(err, "failed to convert local key rotation") + return "", errors.Wrap(err, "failed to convert source key rotation") } if targetPatch, err = utils.FromModelToPatch(target); err != nil { - return errors.Wrap(err, "failed to convert remote key rotation") + return "", errors.Wrap(err, "failed to convert target key rotation") } - if result, err = diff.Tree(sourcePatch, targetPatch, diff.Colorize(diffConfig.Colors)); err != nil { - return err - } - - return writeDiffResult(result) + return diff.Tree(sourcePatch, targetPatch, diff.Colorize(colors)) } // keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go new file mode 100644 index 0000000..16b888c --- /dev/null +++ b/cmd/key_rotation_feature.go @@ -0,0 +1,431 @@ +package cmd + +// Key rotation handling of the plain pull, push and diff commands, enabled by features.key_rotation. + +import ( + "context" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" + "golang.org/x/exp/slog" +) + +// keyRotationFilter is the --filter value that selects key rotation in plain pull, push and diff. +const keyRotationFilter = "key_rotation" + +// keyRotationScope decides how a plain pull, push or diff treats key rotation. handle reports whether +// key rotation is handled: the feature is enabled and --filter is either empty or names key_rotation. +// normal reports whether the regular configuration pipeline runs; it is skipped when key_rotation is +// the only filter, since an empty filtered configuration would otherwise be pulled, diffed or pushed. +// rest are the filters left for the regular pipeline. +func keyRotationScope(enabled bool, filters []string) (handle, normal bool, rest []string, err error) { + rest = slices.DeleteFunc(slices.Clone(filters), func(f string) bool { return f == keyRotationFilter }) + requested := len(rest) < len(filters) + + if requested && !enabled { + return false, false, nil, errors.New("--filter key_rotation requires features.key_rotation: true") + } + + return enabled && (len(filters) == 0 || requested), len(filters) == 0 || len(rest) > 0, rest, nil +} + +// tenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has +// no servers, because the regular pull was skipped or filtered them out, they are read from source. +func tenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc7396PatchOperation) ([]string, error) { + var err error + + if _, ok := data["servers"]; !ok { + if data, err = source.Read(ctx, api.WithFilters([]string{"servers"})); err != nil { + return nil, errors.Wrap(err, "failed to list tenant workspaces") + } + } + + switch servers := data["servers"].(type) { + case nil: + return nil, nil + case map[string]any: + return slices.Sorted(maps.Keys(servers)), nil + default: + return nil, errors.Errorf("unexpected servers type %T in tenant configuration", servers) + } +} + +// localKeyRotationWorkspaces returns the sorted ids of the workspaces that have a key rotation file in +// any of the dirs. +func localKeyRotationWorkspaces(dirs []string) ([]string, error) { + ids := map[string]struct{}{} + + for _, dir := range dirs { + entries, err := os.ReadDir(filepath.Join(dir, "workspaces")) + if os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + if _, err = os.Stat(filepath.Join(dir, "workspaces", entry.Name(), keyrotation.FileName)); err == nil { + ids[entry.Name()] = struct{}{} + } + } + } + + return slices.Sorted(maps.Keys(ids)), nil +} + +// pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant +// mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. +func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { + var ( + wids = []string{rootConfig.Workspace} + err error + ) + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return err + } + + if rootConfig.Tenant { + if wids, err = tenantWorkspaces(ctx, app.Client, data); err != nil { + return err + } + } + + for _, wid := range wids { + if err = pullWorkspaceKeyRotation(ctx, app, dirStore, wid); err != nil { + return err + } + } + + return nil +} + +type workspaceKeyRotation struct { + wid string + cfg *keyrotation.Config +} + +// localKeyRotationWorkspacesFor returns the workspaces with a local key rotation file: the selected +// workspace, or in tenant mode every workspace dir. +func localKeyRotationWorkspacesFor(app *cac.Application) ([]string, error) { + if app.Config.Storage == nil { + return nil, nil + } + + wids, err := localKeyRotationWorkspaces(app.Config.Storage.DirPath) + if err != nil || rootConfig.Tenant { + return wids, err + } + + if slices.Contains(wids, rootConfig.Workspace) { + return []string{rootConfig.Workspace}, nil + } + + return nil, nil +} + +// readKeyRotations reads and validates the local key rotation settings a plain push sends, before +// anything is pushed. +func readKeyRotations(app *cac.Application) ([]workspaceKeyRotation, error) { + var out []workspaceKeyRotation + + dirStore, err := keyRotationDirStore(app) + if err != nil { + return nil, err + } + + wids, err := localKeyRotationWorkspacesFor(app) + if err != nil { + return nil, err + } + + for _, wid := range wids { + cfg, err := readKeyRotation(dirStore, wid) + if err != nil { + return nil, err + } + + if cfg != nil { + out = append(out, workspaceKeyRotation{wid: wid, cfg: cfg}) + } + } + + if len(out) == 0 { + slog.Info("No key rotation configuration to push") + } + + return out, nil +} + +// warnIgnoredKeyRotation warns about local key rotation files a plain push skips because the feature +// is disabled. +func warnIgnoredKeyRotation(app *cac.Application) { + wids, err := localKeyRotationWorkspacesFor(app) + if err != nil { + slog.Debug("failed to look for key rotation files", "error", err) + return + } + + for _, wid := range wids { + slog.Warn("key_rotation.yaml present but features.key_rotation is disabled; skipping", "workspace", wid) + } +} + +// pushKeyRotations pushes key rotation after a plain push, or writes it next to the dry-run output. +// normal reports whether the regular push ran and so wrote dry-run output first. +func pushKeyRotations(ctx context.Context, app *cac.Application, krs []workspaceKeyRotation, normal bool) error { + if pushConfig.DryRun { + dryRun := &keyRotationDryRun{out: pushConfig.Out, stdout: os.Stdout, appendFile: normal} + + for _, kr := range krs { + if err := dryRun.write(kr.wid, kr.cfg); err != nil { + return err + } + } + + return nil + } + + for _, kr := range krs { + if err := pushWorkspaceKeyRotation(ctx, app, kr.wid, kr.cfg); err != nil { + return err + } + } + + return nil +} + +// keyRotationDryRun writes key rotation settings next to the regular dry-run output: to +// workspaces//key_rotation.yaml when out is an existing directory, otherwise as an extra YAML +// document headed by a "# key_rotation ()" comment, to stdout or appended to the out file. +type keyRotationDryRun struct { + out string + stdout io.Writer + // appendFile makes the next write to a flat out file append rather than replace it + appendFile bool +} + +func (d *keyRotationDryRun) write(wid string, cfg *keyrotation.Config) error { + if d.out != "-" { + if info, err := os.Stat(d.out); err == nil && info.IsDir() { + return keyrotation.NewDirStore([]string{d.out}).Write(wid, cfg) + } + } + + section, err := keyRotationDryRunSection(wid, cfg) + if err != nil { + return err + } + + if d.out == "-" { + _, err = d.stdout.Write(section) + return errors.Wrap(err, "failed to write key rotation to stdout") + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if d.appendFile { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + + file, err := os.OpenFile(d.out, flags, 0644) + if err != nil { + return errors.Wrap(err, "failed to open dry-run output file") + } + + if _, err = file.Write(section); err != nil { + _ = file.Close() + return errors.Wrap(err, "failed to write key rotation to file") + } + + d.appendFile = true + + return errors.Wrap(file.Close(), "failed to write key rotation to file") +} + +// keyRotationDryRunSection renders the settings as a separate YAML document, so that the combined +// dry-run output stays a valid YAML stream. +func keyRotationDryRunSection(wid string, cfg *keyrotation.Config) ([]byte, error) { + bts, err := utils.ToYaml(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal key rotation") + } + + return append(fmt.Appendf(nil, "---\n# key_rotation (%s)\n", wid), bts...), nil +} + +// keyRotationSide reads key rotation for one side of a plain diff, mapping the diff source types: +// local reads the profile's storage dirs, remote the profile's server, and merged the local file, +// falling back to the server when the workspace has none. +type keyRotationSide struct { + local *keyrotation.DirStore + remote *client.KeyRotationAPIStore + // tenant lists the workspaces of a remote side in tenant mode + tenant api.Source +} + +// newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource. +func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, error) { + var ( + side = &keyRotationSide{} + profile, sourceS, found = strings.Cut(spec, "@") + sourceType api.SourceType + err error + ) + + if !found { + sourceS, profile = profile, app.Config.Name + } + + conf, err := app.RootConfig.ForProfile(profile) + if err != nil { + return nil, err + } + + if sourceType, err = api.SourceFromString(sourceS); err != nil { + return nil, err + } + + if sourceType == api.SourceLocal || sourceType == api.SourceMerged { + if conf.Storage == nil || len(conf.Storage.DirPath) == 0 { + return nil, errors.Errorf("no storage directories configured for profile %s", profile) + } + + side.local = keyrotation.NewDirStore(conf.Storage.DirPath) + } + + if sourceType == api.SourceRemote || sourceType == api.SourceMerged { + // the selected profile's client is already initialized; in tenant mode it is a tenant client + if conf.Name == app.Config.Name { + side.remote, side.tenant = app.KeyRotation, app.Client + } else { + c, err := client.InitClient(conf.Client) + if err != nil { + return nil, err + } + + side.remote, side.tenant = c.KeyRotationStore(), c.Tenant() + } + } + + return side, nil +} + +func (s *keyRotationSide) read(ctx context.Context, wid string) (*keyrotation.Config, error) { + if s.local != nil { + cfg, err := s.local.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg != nil || s.remote == nil { + return cfg, nil + } + } + + return s.remote.Read(ctx, wid) +} + +// workspaces lists the workspaces the side has key rotation for, in tenant mode. +func (s *keyRotationSide) workspaces(ctx context.Context) ([]string, error) { + var wids []string + + if s.local != nil { + local, err := localKeyRotationWorkspaces(s.local.Dirs) + if err != nil { + return nil, err + } + + wids = append(wids, local...) + } + + if s.remote != nil { + remote, err := tenantWorkspaces(ctx, s.tenant, nil) + if err != nil { + return nil, err + } + + wids = append(wids, remote...) + } + + slices.Sort(wids) + + return slices.Compact(wids), nil +} + +// diffKeyRotations appends the key rotation diff of the selected workspace, or in tenant mode of every +// workspace the source has, to the result of a plain diff. Target uses absent from the source are +// ignored, as in diff key-rotation. +func diffKeyRotations(ctx context.Context, app *cac.Application, result string) (string, error) { + var ( + wids = []string{rootConfig.Workspace} + source, target *keyRotationSide + err error + ) + + if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { + return "", err + } + + if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { + return "", err + } + + if rootConfig.Tenant { + if wids, err = source.workspaces(ctx); err != nil { + return "", err + } + } + + for _, wid := range wids { + var ( + sourceCfg, targetCfg *keyrotation.Config + d string + ) + + if sourceCfg, err = source.read(ctx, wid); err != nil { + return "", err + } + + if targetCfg, err = target.read(ctx, wid); err != nil { + return "", err + } + + if d, err = keyRotationTreeDiff(sourceCfg, targetCfg, diffConfig.Colors); err != nil { + return "", err + } + + result = appendKeyRotationDiff(result, wid, d) + } + + return result, nil +} + +// appendKeyRotationDiff appends a non-empty key rotation diff under a "--- key_rotation ()" line. +func appendKeyRotationDiff(result, wid, d string) string { + if d == "" { + return result + } + + if result != "" && !strings.HasSuffix(result, "\n") { + result += "\n" + } + + return result + fmt.Sprintf("--- key_rotation (%s)\n", wid) + d +} diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go new file mode 100644 index 0000000..b59096b --- /dev/null +++ b/cmd/key_rotation_feature_test.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/stretchr/testify/require" +) + +func TestKeyRotationScope(t *testing.T) { + tcs := []struct { + name string + enabled bool + filters []string + handle bool + normal bool + rest []string + err string + }{ + {name: "disabled without filters", filters: nil, normal: true, rest: nil}, + {name: "disabled with other filters", filters: []string{"clients"}, normal: true, rest: []string{"clients"}}, + {name: "disabled with key_rotation", filters: []string{"clients", "key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, + {name: "enabled without filters", enabled: true, filters: nil, handle: true, normal: true, rest: nil}, + {name: "enabled with other filters", enabled: true, filters: []string{"clients"}, normal: true, rest: []string{"clients"}}, + {name: "enabled with key_rotation only", enabled: true, filters: []string{"key_rotation"}, handle: true, rest: []string{}}, + {name: "enabled with key_rotation and others", enabled: true, filters: []string{"key_rotation", "clients"}, handle: true, normal: true, rest: []string{"clients"}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + filters := append([]string(nil), tc.filters...) + + handle, normal, rest, err := keyRotationScope(tc.enabled, filters) + + if tc.err != "" { + require.EqualError(t, err, tc.err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.handle, handle, "handle") + require.Equal(t, tc.normal, normal, "normal") + require.Equal(t, tc.rest, rest) + require.Equal(t, tc.filters, filters, "the input filters must not be modified") + }) + } +} + +func TestAppendKeyRotationDiff(t *testing.T) { + require.Equal(t, "normal\n", appendKeyRotationDiff("normal\n", "demo", "")) + require.Equal(t, "--- key_rotation (demo)\nkr\n", appendKeyRotationDiff("", "demo", "kr\n")) + require.Equal(t, "normal\n--- key_rotation (demo)\nkr\n", appendKeyRotationDiff("normal", "demo", "kr\n")) + require.Equal(t, "normal\n--- key_rotation (a)\nkr\n--- key_rotation (b)\nkr\n", + appendKeyRotationDiff(appendKeyRotationDiff("normal\n", "a", "kr\n"), "b", "kr\n")) +} + +func TestKeyRotationDryRun(t *testing.T) { + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + section := "---\n# key_rotation (demo)\nsig:\n enabled: true\n cron: 0 0 1 * *\n" + + t.Run("section", func(t *testing.T) { + bts, err := keyRotationDryRunSection("demo", cfg) + require.NoError(t, err) + require.Equal(t, section, string(bts)) + }) + + t.Run("stdout", func(t *testing.T) { + var stdout bytes.Buffer + + dryRun := &keyRotationDryRun{out: "-", stdout: &stdout} + require.NoError(t, dryRun.write("demo", cfg)) + require.Equal(t, section, stdout.String()) + }) + + t.Run("directory", func(t *testing.T) { + dir := t.TempDir() + + dryRun := &keyRotationDryRun{out: dir} + require.NoError(t, dryRun.write("demo", cfg)) + + got, err := keyrotation.NewDirStore([]string{dir}).Read("demo") + require.NoError(t, err) + require.Equal(t, cfg, got) + }) + + t.Run("file after the regular output is appended to", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("name: demo\n"), 0644)) + + dryRun := &keyRotationDryRun{out: out, appendFile: true} + require.NoError(t, dryRun.write("demo", cfg)) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, "name: demo\n"+section, string(bts)) + }) + + t.Run("file without the regular output is replaced, then appended to", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + + dryRun := &keyRotationDryRun{out: out} + require.NoError(t, dryRun.write("demo", cfg)) + require.NoError(t, dryRun.write("other", cfg)) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + + other, err := keyRotationDryRunSection("other", cfg) + require.NoError(t, err) + require.Equal(t, section+string(other), string(bts)) + }) +} + +func TestLocalKeyRotationWorkspaces(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + + require.NoError(t, keyrotation.NewDirStore([]string{first}).Write("b", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("a", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("b", cfg)) + // a workspace dir without the file is not listed + require.NoError(t, os.MkdirAll(filepath.Join(first, "workspaces", "c"), 0755)) + + wids, err := localKeyRotationWorkspaces([]string{first, second, filepath.Join(first, "missing")}) + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) +} + +type fakeSource struct { + data models.Rfc7396PatchOperation + reads int +} + +func (f *fakeSource) Read(context.Context, ...api.SourceOpt) (models.Rfc7396PatchOperation, error) { + f.reads++ + return f.data, nil +} + +func (f *fakeSource) Write(context.Context, models.Rfc7396PatchOperation, ...api.SourceOpt) error { + return nil +} + +func (f *fakeSource) String() string { + return "fake" +} + +func TestTenantWorkspaces(t *testing.T) { + t.Run("from the pulled configuration", func(t *testing.T) { + source := &fakeSource{} + + wids, err := tenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{ + "servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) + require.Zero(t, source.reads) + }) + + t.Run("read from the source when servers are missing", func(t *testing.T) { + source := &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"a": map[string]any{}}}} + + wids, err := tenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{"pools": map[string]any{}}) + require.NoError(t, err) + require.Equal(t, []string{"a"}, wids) + require.Equal(t, 1, source.reads) + }) + + t.Run("null servers", func(t *testing.T) { + wids, err := tenantWorkspaces(context.Background(), &fakeSource{}, models.Rfc7396PatchOperation{"servers": nil}) + require.NoError(t, err) + require.Empty(t, wids) + }) +} diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index 8a8baa0..8dd12b0 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -5,23 +5,13 @@ import ( "testing" "time" - "github.com/cloudentity/cac/internal/cac/diff" "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" ) func keyRotationDiff(t *testing.T, local, remote *keyrotation.Config) string { - source, target := keyRotationDiffConfigs(local, remote) - - sourcePatch, err := utils.FromModelToPatch(source) - require.NoError(t, err) - - targetPatch, err := utils.FromModelToPatch(target) - require.NoError(t, err) - - result, err := diff.Tree(sourcePatch, targetPatch, diff.Colorize(false)) + result, err := keyRotationTreeDiff(local, remote, false) require.NoError(t, err) return result diff --git a/cmd/pull.go b/cmd/pull.go index 982cec1..857a35a 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -29,33 +29,46 @@ Examples: cac --config ./cac.yaml --profile dev --tenant pull`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - err error + app *cac.Application + data models.Rfc7396PatchOperation + handle bool + normal bool + filters []string + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - slog. - With("workspace", rootConfig.Workspace). - With("tenant", rootConfig.Tenant). - With("filters", pullConfig.Filters). - With("config", rootConfig.ConfigPath). - Info("Pulling configuration") - - if data, err = app.Client.Read( - cmd.Context(), - api.WithWorkspace(rootConfig.Workspace), - api.WithSecrets(pullConfig.WithSecrets), - api.WithFilters(pullConfig.Filters), - ); err != nil { + if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { return err } - if err = app.Storage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { - return err + if normal { + slog. + With("workspace", rootConfig.Workspace). + With("tenant", rootConfig.Tenant). + With("filters", filters). + With("config", rootConfig.ConfigPath). + Info("Pulling configuration") + + if data, err = app.Client.Read( + cmd.Context(), + api.WithWorkspace(rootConfig.Workspace), + api.WithSecrets(pullConfig.WithSecrets), + api.WithFilters(filters), + ); err != nil { + return err + } + + if err = app.Storage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + return err + } + } + + if handle { + return pullKeyRotations(cmd.Context(), app, data) } return nil @@ -77,6 +90,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 diff --git a/cmd/push.go b/cmd/push.go index 6bc194d..47c01b1 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" @@ -37,64 +39,41 @@ Examples: cac --config ./cac.yaml --profile dev --workspace demo push --method patch --filter clients,services`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - err error + app *cac.Application + krs []workspaceKeyRotation + handle bool + normal bool + filters []string + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if data, err = app.Storage.Read( - cmd.Context(), - api.WithWorkspace(rootConfig.Workspace), - api.WithFilters(pushConfig.Filters), - ); err != nil { + if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { return err } - if !pushConfig.NoLocalValidate { - if err = app.Validator.Validate(&data); err != nil { - return errors.Wrap(err, "failed to validate configuration") + // key rotation is read and validated up front, so an invalid file fails before anything is pushed + if handle { + if krs, err = readKeyRotations(app); err != nil { + return err } + } else if !app.Config.KeyRotationEnabled() { + warnIgnoredKeyRotation(app) } - if pushConfig.DryRun { - slog.Info("dry run enabled, storing files to disk instead of pushing to server") - - var ( - dryStorage storage.Storage - constr = storage.InitServerStorage - ) - - if rootConfig.Tenant { - constr = storage.InitTenantStorage - } - - if dryStorage, err = storage.InitDryStorage(pushConfig.Out, constr); err != nil { - return errors.Wrap(err, "failed to initialize dry storage") - } - - if err = dryStorage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { - return errors.Wrap(err, "failed to write configuration") + if normal { + if err = pushConfiguration(cmd.Context(), app, filters); err != nil { + return err } - - return nil } - if err = app.Client.Write( - cmd.Context(), - data, - api.WithWorkspace(rootConfig.Workspace), - api.WithMode(pushConfig.Mode), - api.WithMethod(pushConfig.Method), - ); err != nil { - return errors.Wrap(err, "failed to push configuration") + if handle { + return pushKeyRotations(cmd.Context(), app, krs, normal) } - slog.Info("pushed configuration") - return nil }, } @@ -108,6 +87,65 @@ Examples: } ) +// pushConfiguration pushes the regular configuration, or writes it to --out with --dry-run. +func pushConfiguration(ctx context.Context, app *cac.Application, filters []string) error { + var ( + data models.Rfc7396PatchOperation + err error + ) + + if data, err = app.Storage.Read( + ctx, + api.WithWorkspace(rootConfig.Workspace), + api.WithFilters(filters), + ); err != nil { + return err + } + + if !pushConfig.NoLocalValidate { + if err = app.Validator.Validate(&data); err != nil { + return errors.Wrap(err, "failed to validate configuration") + } + } + + if pushConfig.DryRun { + slog.Info("dry run enabled, storing files to disk instead of pushing to server") + + var ( + dryStorage storage.Storage + constr = storage.InitServerStorage + ) + + if rootConfig.Tenant { + constr = storage.InitTenantStorage + } + + if dryStorage, err = storage.InitDryStorage(pushConfig.Out, constr); err != nil { + return errors.Wrap(err, "failed to initialize dry storage") + } + + if err = dryStorage.Write(ctx, data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + return errors.Wrap(err, "failed to write configuration") + } + + return nil + } + + if err = app.Client.Write( + ctx, + data, + api.WithWorkspace(rootConfig.Workspace), + api.WithMode(pushConfig.Mode), + api.WithMethod(pushConfig.Method), + ); err != nil { + return errors.Wrap(err, "failed to push configuration") + } + + slog.Info("pushed configuration") + + return nil +} + func init() { pushCmd.PersistentFlags().BoolVar(&pushConfig.DryRun, "dry-run", false, `Write the resolved configuration to disk or stdout instead of pushing to the server. Use with --out to control the destination. @@ -139,6 +177,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 From 8b89ae60fb4b9df2d8a16a2019234d1001bd054c Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:35:47 +0200 Subject: [PATCH 15/38] docs: document features.key_rotation --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index aa75c9a..fc9d7d8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -46,8 +48,13 @@ profiles: # an optional map of profiles available for use, especially helpful wh client_secret: invalid_secret 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. + ## Commands ### Help @@ -243,11 +250,13 @@ map[string]any{ 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//key_rotation.yaml`. With several storage +workspace selected with `--workspace` (`--tenant` is rejected), or by the plain commands +when [`features.key_rotation`](#key-rotation-in-plain-pull-push-and-diff) is enabled. +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`. They work whether the feature is +enabled or not. +The settings are stored in `workspaces//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: @@ -276,8 +285,8 @@ 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 +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). Constraints: @@ -298,6 +307,72 @@ Constraints: > **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. cac then refuses to start unless +`client.scopes` includes `manage_servers`: +`features.key_rotation requires the manage_servers scope in client.scopes`. + +```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, as `pull key-rotation` + does. 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. `--method` and `--mode` do not apply to + it: every key use in the file is replaced. +- `push --dry-run` writes the settings after the configuration. With `--out -` (stdout) + or a file, each workspace is a separate YAML document: + + ```yaml + # ... configuration ... + --- + # key_rotation (demo) + sig: + enabled: true + cron: 0 0 1 * * + ``` + + With `--out` set to an existing directory, the settings go to + `/workspaces//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. As with `diff key-rotation`, + uses missing from the source are ignored. 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`. + +With the feature disabled, `--filter key_rotation` fails with +`--filter key_rotation requires features.key_rotation: true`, plain `pull` and `diff` +ignore key rotation, and plain `push` warns about each `key_rotation.yaml` it skips: +`key_rotation.yaml present but features.key_rotation is disabled; skipping`. + ## Templates Templates are used to generate configuration files. They are using [Go template language](https://golang.org/pkg/text/template/). From 3b5225050171bdb299ded83eff0fa59d9219418d Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:51:09 +0200 Subject: [PATCH 16/38] fix(cmd): tolerate missing target workspaces and strip starting_from 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. --- cmd/key_rotation.go | 3 ++- cmd/key_rotation_feature.go | 18 ++++++++++++++++-- cmd/key_rotation_feature_test.go | 30 ++++++++++++++++++++++++++++++ cmd/key_rotation_test.go | 4 ++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index e5150f1..d981b83 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -3,6 +3,7 @@ package cmd import ( "context" "os" + "slices" "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" @@ -230,7 +231,7 @@ func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target * } // the server never echoes starting_from back, so it would always show up as a difference - for _, use := range local.Uses() { + for _, use := range slices.Concat(local.Uses(), remote.Uses()) { use.Rotation.StartingFrom = nil } diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 16b888c..3def964 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/acp-client-go/clients/system/client/keys" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" @@ -270,12 +271,17 @@ func keyRotationDryRunSection(wid string, cfg *keyrotation.Config) ([]byte, erro return append(fmt.Appendf(nil, "---\n# key_rotation (%s)\n", wid), bts...), nil } +// keyRotationReader reads the key rotation settings of a workspace from the server. +type keyRotationReader interface { + Read(ctx context.Context, wid string) (*keyrotation.Config, error) +} + // keyRotationSide reads key rotation for one side of a plain diff, mapping the diff source types: // local reads the profile's storage dirs, remote the profile's server, and merged the local file, // falling back to the server when the workspace has none. type keyRotationSide struct { local *keyrotation.DirStore - remote *client.KeyRotationAPIStore + remote keyRotationReader // tenant lists the workspaces of a remote side in tenant mode tenant api.Source } @@ -339,7 +345,15 @@ func (s *keyRotationSide) read(ctx context.Context, wid string) (*keyrotation.Co } } - return s.remote.Read(ctx, wid) + cfg, err := s.remote.Read(ctx, wid) + + // a workspace the target lacks has no settings, so it diffs as all-source like the configuration + var notFound *keys.GetAutomaticKeyRotationNotFound + if errors.As(err, ¬Found) { + return nil, nil + } + + return cfg, err } // workspaces lists the workspaces the side has key rotation for, in tenant mode. diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index b59096b..48ec3b7 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -8,8 +8,10 @@ import ( "testing" "github.com/cloudentity/acp-client-go/clients/hub/models" + kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -178,3 +180,31 @@ func TestTenantWorkspaces(t *testing.T) { require.Empty(t, wids) }) } + +type fakeKeyRotationReader struct { + cfg *keyrotation.Config + err error +} + +func (f fakeKeyRotationReader) Read(context.Context, string) (*keyrotation.Config, error) { + return f.cfg, f.err +} + +func TestKeyRotationSideRead(t *testing.T) { + t.Run("a workspace missing remotely has no settings", func(t *testing.T) { + side := &keyRotationSide{remote: fakeKeyRotationReader{ + err: errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation"), + }} + + cfg, err := side.read(context.Background(), "missing") + require.NoError(t, err) + require.Nil(t, cfg) + }) + + t.Run("other remote errors fail", func(t *testing.T) { + side := &keyRotationSide{remote: fakeKeyRotationReader{err: kclient.NewGetAutomaticKeyRotationForbidden()}} + + _, err := side.read(context.Background(), "demo") + require.Error(t, err) + }) +} diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index 8dd12b0..cf89b07 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -18,7 +18,7 @@ func keyRotationDiff(t *testing.T, local, remote *keyrotation.Config) string { } func TestKeyRotationDiffConfigs(t *testing.T) { - t.Run("starting_from is cleared on local only", func(t *testing.T) { + t.Run("starting_from is cleared on both sides", func(t *testing.T) { startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) source, target := keyRotationDiffConfigs( @@ -27,7 +27,7 @@ func TestKeyRotationDiffConfigs(t *testing.T) { ) require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) - require.Equal(t, &startingFrom, target.Sig.StartingFrom) + require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, target) }) t.Run("remote use absent locally is dropped", func(t *testing.T) { From dd522d35b497738176fb358a7f12be2095518f26 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:51:24 +0200 Subject: [PATCH 17/38] fix(cmd): skip key rotation for diff sides whose profile does not allow 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. --- cmd/key_rotation_feature.go | 22 ++++++++++++++++++---- cmd/key_rotation_feature_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 3def964..2251116 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -17,6 +17,7 @@ import ( "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" @@ -286,7 +287,15 @@ type keyRotationSide struct { tenant api.Source } +// keyRotationRemoteAllowed reports whether the keys API may be called with the profile's client: the +// profile enables features.key_rotation and its client has the manage_servers scope. +func keyRotationRemoteAllowed(conf *config.Configuration) bool { + return conf.KeyRotationEnabled() && conf.Client != nil && slices.Contains(conf.Client.Scopes, config.ManageServersScope) +} + // newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource. +// It returns nil, after a warning, when the side needs the server but its profile does not allow key +// rotation. func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, error) { var ( side = &keyRotationSide{} @@ -317,6 +326,11 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, er } if sourceType == api.SourceRemote || sourceType == api.SourceMerged { + if !keyRotationRemoteAllowed(conf) { + slog.Warn(fmt.Sprintf("key rotation skipped for profile %s: features.key_rotation disabled or manage_servers scope missing", profile)) + return nil, nil + } + // the selected profile's client is already initialized; in tenant mode it is a tenant client if conf.Name == app.Config.Name { side.remote, side.tenant = app.KeyRotation, app.Client @@ -393,12 +407,12 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) err error ) - if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { - return "", err + if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil || source == nil { + return result, err } - if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { - return "", err + if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil || target == nil { + return result, err } if rootConfig.Tenant { diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 48ec3b7..72c8e4b 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -10,6 +10,8 @@ import ( "github.com/cloudentity/acp-client-go/clients/hub/models" kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/pkg/errors" "github.com/stretchr/testify/require" @@ -208,3 +210,29 @@ func TestKeyRotationSideRead(t *testing.T) { require.Error(t, err) }) } + +func TestKeyRotationRemoteAllowed(t *testing.T) { + withScopes := func(scopes ...string) *client.Configuration { + c := client.DefaultConfig() + c.Scopes = scopes + return c + } + + tcs := []struct { + name string + conf *config.Configuration + want bool + }{ + {"enabled with manage_servers", &config.Configuration{Features: &config.Features{KeyRotation: true}, Client: withScopes("manage_configuration", "manage_servers")}, true}, + {"enabled without manage_servers", &config.Configuration{Features: &config.Features{KeyRotation: true}, Client: withScopes("manage_configuration")}, false}, + {"enabled without client", &config.Configuration{Features: &config.Features{KeyRotation: true}}, false}, + {"disabled with manage_servers", &config.Configuration{Features: &config.Features{}, Client: withScopes("manage_servers")}, false}, + {"no features", &config.Configuration{Client: withScopes("manage_servers")}, false}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, keyRotationRemoteAllowed(tc.conf)) + }) + } +} From 5752ed4a561adc74b2c527144e45855ce3b1575f Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:51:35 +0200 Subject: [PATCH 18/38] refactor(cmd): look up only the selected workspace's key rotation file 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. --- cmd/key_rotation_feature.go | 60 ++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 2251116..314b5ff 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -54,14 +54,10 @@ func tenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc739 } } - switch servers := data["servers"].(type) { - case nil: - return nil, nil - case map[string]any: - return slices.Sorted(maps.Keys(servers)), nil - default: - return nil, errors.Errorf("unexpected servers type %T in tenant configuration", servers) - } + // the tenant export is decoded into a generic map, so servers is either a map or null + servers, _ := data["servers"].(map[string]any) + + return slices.Sorted(maps.Keys(servers)), nil } // localKeyRotationWorkspaces returns the sorted ids of the workspaces that have a key rotation file in @@ -119,28 +115,30 @@ func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc return nil } -type workspaceKeyRotation struct { - wid string - cfg *keyrotation.Config -} - -// localKeyRotationWorkspacesFor returns the workspaces with a local key rotation file: the selected -// workspace, or in tenant mode every workspace dir. -func localKeyRotationWorkspacesFor(app *cac.Application) ([]string, error) { - if app.Config.Storage == nil { - return nil, nil +// localKeyRotationWorkspacesFor returns the workspaces a plain push considers: the selected +// workspace, or in tenant mode every workspace dir with a key rotation file. +func localKeyRotationWorkspacesFor(dirs []string) ([]string, error) { + if rootConfig.Tenant { + return localKeyRotationWorkspaces(dirs) } - wids, err := localKeyRotationWorkspaces(app.Config.Storage.DirPath) - if err != nil || rootConfig.Tenant { - return wids, err - } + return []string{rootConfig.Workspace}, nil +} - if slices.Contains(wids, rootConfig.Workspace) { - return []string{rootConfig.Workspace}, nil +// hasLocalKeyRotation reports whether any of the dirs has a key rotation file for the workspace. +func hasLocalKeyRotation(dirs []string, wid string) bool { + for _, dir := range dirs { + if _, err := os.Stat(filepath.Join(dir, "workspaces", wid, keyrotation.FileName)); err == nil { + return true + } } - return nil, nil + return false +} + +type workspaceKeyRotation struct { + wid string + cfg *keyrotation.Config } // readKeyRotations reads and validates the local key rotation settings a plain push sends, before @@ -153,7 +151,7 @@ func readKeyRotations(app *cac.Application) ([]workspaceKeyRotation, error) { return nil, err } - wids, err := localKeyRotationWorkspacesFor(app) + wids, err := localKeyRotationWorkspacesFor(dirStore.Dirs) if err != nil { return nil, err } @@ -179,13 +177,21 @@ func readKeyRotations(app *cac.Application) ([]workspaceKeyRotation, error) { // warnIgnoredKeyRotation warns about local key rotation files a plain push skips because the feature // is disabled. func warnIgnoredKeyRotation(app *cac.Application) { - wids, err := localKeyRotationWorkspacesFor(app) + if app.Config.Storage == nil { + return + } + + wids, err := localKeyRotationWorkspacesFor(app.Config.Storage.DirPath) if err != nil { slog.Debug("failed to look for key rotation files", "error", err) return } for _, wid := range wids { + if !hasLocalKeyRotation(app.Config.Storage.DirPath, wid) { + continue + } + slog.Warn("key_rotation.yaml present but features.key_rotation is disabled; skipping", "workspace", wid) } } From ba549ee180068c4403c112d48232165968a5a5c1 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 11:51:41 +0200 Subject: [PATCH 19/38] docs: give the stage profile manage_servers and note key rotation push 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. --- README.md | 11 ++++++++--- internal/cac/config/config_test.go | 10 ++++++++++ internal/cac/config/testdata/features.yaml | 4 ++++ internal/cac/keyrotation/keyrotation.go | 5 +++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fc9d7d8..33c6d53 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ 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 @@ -53,7 +56,8 @@ profiles: # an optional map of profiles available for use, especially helpful wh ``` A profile that does not define `client`, `storage`, `logging` or `features` uses the -top-level section instead. +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 @@ -331,8 +335,9 @@ features: does. 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. `--method` and `--mode` do not apply to - it: every key use in the file is replaced. + file of every workspace directory that has one, including stale directories without + `server.yaml`: their PUT 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 is replaced. - `push --dry-run` writes the settings after the configuration. With `--out -` (stdout) or a file, each workspace is a separate YAML document: diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index 86803f1..32b4c6a 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -289,6 +289,16 @@ func TestValidateConfiguration(t *testing.T) { require.EqualError(t, on.Validate(), scopeErr) }) + t.Run("a profile inheriting key rotation with its own client without scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("own_client") + require.NoError(t, err) + require.True(t, conf.KeyRotationEnabled()) + require.EqualError(t, conf.Validate(), scopeErr) + }) + t.Run("scopes are not checked when key rotation is off", func(t *testing.T) { rootConf, err := config.InitConfig("./testdata/features_profile.yaml") require.NoError(t, err) diff --git a/internal/cac/config/testdata/features.yaml b/internal/cac/config/testdata/features.yaml index 8525d25..db7c8aa 100644 --- a/internal/cac/config/testdata/features.yaml +++ b/internal/cac/config/testdata/features.yaml @@ -12,3 +12,7 @@ profiles: key_rotation: false # declares nothing of its own, so it falls back to (a copy of) the default features bare: {} + # inherits key rotation but replaces the whole client, dropping the default scopes + own_client: + client: + tenant_id: other diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go index f216a2b..3ae28cc 100644 --- a/internal/cac/keyrotation/keyrotation.go +++ b/internal/cac/keyrotation/keyrotation.go @@ -1,6 +1,7 @@ // Package keyrotation holds the automatic key rotation configuration of a workspace. It is not part -// of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file and is only -// handled by the dedicated pull/push/diff key-rotation subcommands. +// of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file, handled by the +// dedicated pull/push/diff key-rotation subcommands and, with features.key_rotation on, by the plain +// pull, push and diff commands. package keyrotation import ( From 64c6e6490fb0d67b3225864b635693bb13e54696 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Tue, 13 Oct 2026 13:20:19 +0200 Subject: [PATCH 20/38] refactor(cmd): reduce key rotation gating to a single requested check 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. --- cmd/diff.go | 48 +++++++++++++++----------------- cmd/key_rotation_feature.go | 34 +++++++++++----------- cmd/key_rotation_feature_test.go | 46 +++++++++++++++--------------- cmd/pull.go | 18 ++++++------ cmd/push.go | 18 ++++++------ 5 files changed, 80 insertions(+), 84 deletions(-) diff --git a/cmd/diff.go b/cmd/diff.go index 0e76944..e8b919a 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -40,14 +40,12 @@ Examples: --source local --target remote --out diff.txt`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - result string - source api.Source - target api.Source - handle bool - normal bool - filters []string - err error + app *cac.Application + result string + source api.Source + target api.Source + handle bool + err error ) slog. @@ -66,30 +64,28 @@ Examples: return err } - if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { + if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { return err } - if normal { - if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { - 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 { - return err - } + if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); err != nil { + return err + } - slog.Info("Comparing configurations", "source", source, "target", target) + 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(filters...), - diff.WithSecrets(diffConfig.WithSecrets), - diff.FilterVolatileFields(diffConfig.FilterVolatile), - ); err != nil { - return err - } + 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 { + return err } if handle { diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 314b5ff..cbf107a 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -27,20 +27,22 @@ import ( // keyRotationFilter is the --filter value that selects key rotation in plain pull, push and diff. const keyRotationFilter = "key_rotation" -// keyRotationScope decides how a plain pull, push or diff treats key rotation. handle reports whether -// key rotation is handled: the feature is enabled and --filter is either empty or names key_rotation. -// normal reports whether the regular configuration pipeline runs; it is skipped when key_rotation is -// the only filter, since an empty filtered configuration would otherwise be pulled, diffed or pushed. -// rest are the filters left for the regular pipeline. -func keyRotationScope(enabled bool, filters []string) (handle, normal bool, rest []string, err error) { - rest = slices.DeleteFunc(slices.Clone(filters), func(f string) bool { return f == keyRotationFilter }) - requested := len(rest) < len(filters) - - if requested && !enabled { - return false, false, nil, errors.New("--filter key_rotation requires features.key_rotation: true") +// keyRotationRequested reports whether a plain pull, push or diff also handles key rotation: the +// feature is enabled and --filter is empty or names key_rotation. The regular pipeline ignores the +// key_rotation filter, as it does any filter naming no configuration key. +func keyRotationRequested(enabled bool, filters []string) (bool, error) { + if !enabled && slices.Contains(filters, keyRotationFilter) { + return false, errors.New("--filter key_rotation requires features.key_rotation: true") } - return enabled && (len(filters) == 0 || requested), len(filters) == 0 || len(rest) > 0, rest, nil + return enabled && (len(filters) == 0 || slices.Contains(filters, keyRotationFilter)), nil +} + +// keyRotationOnly reports whether --filter selects key rotation alone. Pull and push then skip the +// regular pipeline, since its empty result is not a no-op: a pull rewrites server.yaml and an import +// resets the workspace settings. +func keyRotationOnly(filters []string) bool { + return len(filters) > 0 && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) } // tenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has @@ -196,11 +198,11 @@ func warnIgnoredKeyRotation(app *cac.Application) { } } -// pushKeyRotations pushes key rotation after a plain push, or writes it next to the dry-run output. -// normal reports whether the regular push ran and so wrote dry-run output first. -func pushKeyRotations(ctx context.Context, app *cac.Application, krs []workspaceKeyRotation, normal bool) error { +// pushKeyRotations pushes key rotation after a plain push, or writes it next to the dry-run output, +// appending to the regular dry-run output file unless the regular push was skipped. +func pushKeyRotations(ctx context.Context, app *cac.Application, krs []workspaceKeyRotation) error { if pushConfig.DryRun { - dryRun := &keyRotationDryRun{out: pushConfig.Out, stdout: os.Stdout, appendFile: normal} + dryRun := &keyRotationDryRun{out: pushConfig.Out, stdout: os.Stdout, appendFile: !keyRotationOnly(pushConfig.Filters)} for _, kr := range krs { if err := dryRun.write(kr.wid, kr.cfg); err != nil { diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 72c8e4b..9b295cc 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -17,30 +17,27 @@ import ( "github.com/stretchr/testify/require" ) -func TestKeyRotationScope(t *testing.T) { +func TestKeyRotationRequested(t *testing.T) { tcs := []struct { - name string - enabled bool - filters []string - handle bool - normal bool - rest []string - err string + name string + enabled bool + filters []string + requested bool + err string }{ - {name: "disabled without filters", filters: nil, normal: true, rest: nil}, - {name: "disabled with other filters", filters: []string{"clients"}, normal: true, rest: []string{"clients"}}, - {name: "disabled with key_rotation", filters: []string{"clients", "key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, - {name: "enabled without filters", enabled: true, filters: nil, handle: true, normal: true, rest: nil}, - {name: "enabled with other filters", enabled: true, filters: []string{"clients"}, normal: true, rest: []string{"clients"}}, - {name: "enabled with key_rotation only", enabled: true, filters: []string{"key_rotation"}, handle: true, rest: []string{}}, - {name: "enabled with key_rotation and others", enabled: true, filters: []string{"key_rotation", "clients"}, handle: true, normal: true, rest: []string{"clients"}}, + {name: "disabled without filters"}, + {name: "disabled with key_rotation", filters: []string{"key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, + {name: "disabled with other filters", filters: []string{"clients"}}, + {name: "disabled with mixed filters", filters: []string{"clients", "key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, + {name: "enabled without filters", enabled: true, requested: true}, + {name: "enabled with key_rotation", enabled: true, filters: []string{"key_rotation"}, requested: true}, + {name: "enabled with other filters", enabled: true, filters: []string{"clients"}}, + {name: "enabled with mixed filters", enabled: true, filters: []string{"key_rotation", "clients"}, requested: true}, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - filters := append([]string(nil), tc.filters...) - - handle, normal, rest, err := keyRotationScope(tc.enabled, filters) + requested, err := keyRotationRequested(tc.enabled, tc.filters) if tc.err != "" { require.EqualError(t, err, tc.err) @@ -48,14 +45,19 @@ func TestKeyRotationScope(t *testing.T) { } require.NoError(t, err) - require.Equal(t, tc.handle, handle, "handle") - require.Equal(t, tc.normal, normal, "normal") - require.Equal(t, tc.rest, rest) - require.Equal(t, tc.filters, filters, "the input filters must not be modified") + require.Equal(t, tc.requested, requested) }) } } +func TestKeyRotationOnly(t *testing.T) { + require.False(t, keyRotationOnly(nil)) + require.False(t, keyRotationOnly([]string{"clients"})) + require.False(t, keyRotationOnly([]string{"key_rotation", "clients"})) + require.True(t, keyRotationOnly([]string{"key_rotation"})) + require.True(t, keyRotationOnly([]string{"key_rotation", "key_rotation"})) +} + func TestAppendKeyRotationDiff(t *testing.T) { require.Equal(t, "normal\n", appendKeyRotationDiff("normal\n", "demo", "")) require.Equal(t, "--- key_rotation (demo)\nkr\n", appendKeyRotationDiff("", "demo", "kr\n")) diff --git a/cmd/pull.go b/cmd/pull.go index 857a35a..1af0736 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -29,27 +29,25 @@ Examples: cac --config ./cac.yaml --profile dev --tenant pull`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - handle bool - normal bool - filters []string - err error + app *cac.Application + data models.Rfc7396PatchOperation + handle bool + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { + if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { return err } - if normal { + if !keyRotationOnly(pullConfig.Filters) { slog. With("workspace", rootConfig.Workspace). With("tenant", rootConfig.Tenant). - With("filters", filters). + With("filters", pullConfig.Filters). With("config", rootConfig.ConfigPath). Info("Pulling configuration") @@ -57,7 +55,7 @@ Examples: cmd.Context(), api.WithWorkspace(rootConfig.Workspace), api.WithSecrets(pullConfig.WithSecrets), - api.WithFilters(filters), + api.WithFilters(pullConfig.Filters), ); err != nil { return err } diff --git a/cmd/push.go b/cmd/push.go index 47c01b1..6151294 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -39,19 +39,17 @@ Examples: cac --config ./cac.yaml --profile dev --workspace demo push --method patch --filter clients,services`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - krs []workspaceKeyRotation - handle bool - normal bool - filters []string - err error + app *cac.Application + krs []workspaceKeyRotation + handle bool + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if handle, normal, filters, err = keyRotationScope(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { + if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { return err } @@ -64,14 +62,14 @@ Examples: warnIgnoredKeyRotation(app) } - if normal { - if err = pushConfiguration(cmd.Context(), app, filters); err != nil { + if !keyRotationOnly(pushConfig.Filters) { + if err = pushConfiguration(cmd.Context(), app, pushConfig.Filters); err != nil { return err } } if handle { - return pushKeyRotations(cmd.Context(), app, krs, normal) + return pushKeyRotations(cmd.Context(), app, krs) } return nil From 8162851d7387823f49a13451f0745ac2fa800961 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Wed, 14 Oct 2026 16:43:05 +0200 Subject: [PATCH 21/38] refactor(keyrotation): move pull, push and diff orchestration out of cmd 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. --- cmd/key_rotation.go | 203 ++--------- cmd/key_rotation_feature.go | 316 ++---------------- cmd/key_rotation_feature_test.go | 164 --------- cmd/key_rotation_test.go | 54 --- cmd/push.go | 12 +- internal/cac/keyrotation/diff.go | 165 +++++++++ .../cac/keyrotation/diff_internal_test.go | 44 +++ internal/cac/keyrotation/diff_test.go | 192 +++++++++++ internal/cac/keyrotation/dir_store.go | 35 ++ internal/cac/keyrotation/dir_store_test.go | 22 ++ internal/cac/keyrotation/dry_run.go | 80 +++++ internal/cac/keyrotation/dry_run_test.go | 68 ++++ internal/cac/keyrotation/service.go | 163 +++++++++ internal/cac/keyrotation/service_test.go | 86 +++++ 14 files changed, 926 insertions(+), 678 deletions(-) create mode 100644 internal/cac/keyrotation/diff.go create mode 100644 internal/cac/keyrotation/diff_internal_test.go create mode 100644 internal/cac/keyrotation/diff_test.go create mode 100644 internal/cac/keyrotation/dry_run.go create mode 100644 internal/cac/keyrotation/dry_run_test.go create mode 100644 internal/cac/keyrotation/service.go create mode 100644 internal/cac/keyrotation/service_test.go diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index d981b83..3252984 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -1,13 +1,9 @@ package cmd import ( - "context" "os" - "slices" - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" - "github.com/cloudentity/cac/internal/cac/diff" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" @@ -17,17 +13,31 @@ import ( // newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff. It operates on the // workspace selected with the root --workspace flag. -func newKeyRotationCmd(short string, run func(cmd *cobra.Command, wid string) error) *cobra.Command { +func newKeyRotationCmd(short string, run func(cmd *cobra.Command, svc *keyrotation.Service, wid string) error) *cobra.Command { return &cobra.Command{ Use: "key-rotation", Short: short, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + var ( + app *cac.Application + svc *keyrotation.Service + err error + ) + if rootConfig.Tenant { return errors.New("--tenant does not apply to key-rotation; select a workspace with --workspace") } - return run(cmd, rootConfig.Workspace) + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + if svc, err = keyRotationService(app); err != nil { + return err + } + + return run(cmd, svc, rootConfig.Workspace) }, } } @@ -38,106 +48,33 @@ func init() { diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", diffKeyRotation)) } -func pullKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - return pullWorkspaceKeyRotation(cmd.Context(), app, dirStore, wid) -} - -// pullWorkspaceKeyRotation writes the remote key rotation settings of the workspace to dirStore. It -// writes nothing when the workspace has none configured. -func pullWorkspaceKeyRotation(ctx context.Context, app *cac.Application, dirStore *keyrotation.DirStore, wid string) error { - var ( - cfg *keyrotation.Config - err error - ) - - slog.With("workspace", wid).Info("Pulling key rotation") - - if cfg, err = app.KeyRotation.Read(ctx, wid); err != nil { - return err - } - - if cfg == nil { - slog.Info("No key rotation configured", "workspace", wid) - return nil - } - - if err = dirStore.Write(wid, cfg); err != nil { - return err - } - - slog.Info("Pulled key rotation", "workspace", wid) - - return nil -} - -func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { +// keyRotationService builds the key rotation service of the selected profile: its storage dirs and +// its server. +func keyRotationService(app *cac.Application) (*keyrotation.Service, error) { if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { return nil, errors.New("no storage directories configured for the selected profile") } - return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil + return keyrotation.NewService(keyrotation.NewDirStore(app.Config.Storage.DirPath), app.KeyRotation), nil } -// readKeyRotation reads and validates the local key rotation settings of the workspace. It returns -// nil when the workspace has none. -func readKeyRotation(dirStore *keyrotation.DirStore, wid string) (*keyrotation.Config, error) { - cfg, err := dirStore.Read(wid) - if err != nil { - return nil, errors.Wrap(err, "failed to read local key rotation") - } - - if cfg == nil { - return nil, nil - } - - if err = cfg.Validate(); err != nil { - return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) - } - - return cfg, nil +func pullKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { + return svc.Pull(cmd.Context(), []string{wid}) } -func pushKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - cfg *keyrotation.Config - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) +func pushKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { + items, err := svc.ReadAll([]string{wid}) if err != nil { return err } - if cfg, err = readKeyRotation(dirStore, wid); err != nil { - return err - } - - if cfg == nil { + if len(items) == 0 { slog.Info("No key rotation configuration to push", "workspace", wid) return nil } if pushConfig.DryRun { - bts, err := utils.ToYaml(cfg) + bts, err := utils.ToYaml(items[0].Config) if err != nil { return errors.Wrap(err, "failed to marshal key rotation") } @@ -153,96 +90,14 @@ func pushKeyRotation(cmd *cobra.Command, wid string) error { return nil } - return pushWorkspaceKeyRotation(cmd.Context(), app, wid, cfg) -} - -func pushWorkspaceKeyRotation(ctx context.Context, app *cac.Application, wid string, cfg *keyrotation.Config) error { - if err := app.KeyRotation.Write(ctx, wid, cfg); err != nil { - return err - } - - slog.Info("Pushed key rotation", "workspace", wid, "uses", len(cfg.Uses())) - - return nil + return svc.Push(cmd.Context(), items) } -func diffKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - local, remote *keyrotation.Config - result string - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) +func diffKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { + result, err := svc.Diff(cmd.Context(), wid, diffConfig.Colors) if err != nil { return err } - if local, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") - } - - if remote, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { - return err - } - - if result, err = keyRotationTreeDiff(local, remote, diffConfig.Colors); err != nil { - return err - } - return writeDiffResult(result) } - -// keyRotationTreeDiff renders the difference between the source and target settings the way the -// configuration diff does, after keyRotationDiffConfigs. It modifies its arguments. -func keyRotationTreeDiff(source, target *keyrotation.Config, colors bool) (string, error) { - var ( - sourcePatch, targetPatch models.Rfc7396PatchOperation - err error - ) - - source, target = keyRotationDiffConfigs(source, target) - - if sourcePatch, err = utils.FromModelToPatch(source); err != nil { - return "", errors.Wrap(err, "failed to convert source key rotation") - } - - if targetPatch, err = utils.FromModelToPatch(target); err != nil { - return "", errors.Wrap(err, "failed to convert target key rotation") - } - - return diff.Tree(sourcePatch, targetPatch, diff.Colorize(colors)) -} - -// keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies -// its arguments and replaces nil with an empty configuration. -func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target *keyrotation.Config) { - if local == nil { - local = &keyrotation.Config{} - } - - if remote == nil { - remote = &keyrotation.Config{} - } - - // the server never echoes starting_from back, so it would always show up as a difference - for _, use := range slices.Concat(local.Uses(), remote.Uses()) { - use.Rotation.StartingFrom = nil - } - - // push never removes a use that is absent locally, so diff previews only what push would change - if local.Sig == nil { - remote.Sig = nil - } - - if local.Enc == nil { - remote.Enc = nil - } - - return local, remote -} diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index cbf107a..7279996 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -5,21 +5,16 @@ package cmd import ( "context" "fmt" - "io" - "maps" "os" - "path/filepath" "slices" "strings" "github.com/cloudentity/acp-client-go/clients/hub/models" - "github.com/cloudentity/acp-client-go/clients/system/client/keys" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "golang.org/x/exp/slog" ) @@ -45,135 +40,56 @@ func keyRotationOnly(filters []string) bool { return len(filters) > 0 && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) } -// tenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has -// no servers, because the regular pull was skipped or filtered them out, they are read from source. -func tenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc7396PatchOperation) ([]string, error) { - var err error - - if _, ok := data["servers"]; !ok { - if data, err = source.Read(ctx, api.WithFilters([]string{"servers"})); err != nil { - return nil, errors.Wrap(err, "failed to list tenant workspaces") - } - } - - // the tenant export is decoded into a generic map, so servers is either a map or null - servers, _ := data["servers"].(map[string]any) - - return slices.Sorted(maps.Keys(servers)), nil -} - -// localKeyRotationWorkspaces returns the sorted ids of the workspaces that have a key rotation file in -// any of the dirs. -func localKeyRotationWorkspaces(dirs []string) ([]string, error) { - ids := map[string]struct{}{} - - for _, dir := range dirs { - entries, err := os.ReadDir(filepath.Join(dir, "workspaces")) - if os.IsNotExist(err) { - continue - } else if err != nil { - return nil, err - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - if _, err = os.Stat(filepath.Join(dir, "workspaces", entry.Name(), keyrotation.FileName)); err == nil { - ids[entry.Name()] = struct{}{} - } - } - } - - return slices.Sorted(maps.Keys(ids)), nil -} - // pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant // mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { var ( wids = []string{rootConfig.Workspace} + svc *keyrotation.Service err error ) - dirStore, err := keyRotationDirStore(app) - if err != nil { + if svc, err = keyRotationService(app); err != nil { return err } if rootConfig.Tenant { - if wids, err = tenantWorkspaces(ctx, app.Client, data); err != nil { + if wids, err = keyrotation.TenantWorkspaces(ctx, app.Client, data); err != nil { return err } } - for _, wid := range wids { - if err = pullWorkspaceKeyRotation(ctx, app, dirStore, wid); err != nil { - return err - } - } - - return nil + return svc.Pull(ctx, wids) } -// localKeyRotationWorkspacesFor returns the workspaces a plain push considers: the selected -// workspace, or in tenant mode every workspace dir with a key rotation file. -func localKeyRotationWorkspacesFor(dirs []string) ([]string, error) { +// keyRotationWorkspaces returns the workspaces a plain push considers: the selected workspace, or in +// tenant mode every workspace dir with a key rotation file. +func keyRotationWorkspaces(dir *keyrotation.DirStore) ([]string, error) { if rootConfig.Tenant { - return localKeyRotationWorkspaces(dirs) + return dir.Workspaces() } return []string{rootConfig.Workspace}, nil } -// hasLocalKeyRotation reports whether any of the dirs has a key rotation file for the workspace. -func hasLocalKeyRotation(dirs []string, wid string) bool { - for _, dir := range dirs { - if _, err := os.Stat(filepath.Join(dir, "workspaces", wid, keyrotation.FileName)); err == nil { - return true - } - } - - return false -} - -type workspaceKeyRotation struct { - wid string - cfg *keyrotation.Config -} - // readKeyRotations reads and validates the local key rotation settings a plain push sends, before // anything is pushed. -func readKeyRotations(app *cac.Application) ([]workspaceKeyRotation, error) { - var out []workspaceKeyRotation - - dirStore, err := keyRotationDirStore(app) +func readKeyRotations(svc *keyrotation.Service) ([]keyrotation.WorkspaceConfig, error) { + wids, err := keyRotationWorkspaces(svc.Dir) if err != nil { return nil, err } - wids, err := localKeyRotationWorkspacesFor(dirStore.Dirs) + items, err := svc.ReadAll(wids) if err != nil { return nil, err } - for _, wid := range wids { - cfg, err := readKeyRotation(dirStore, wid) - if err != nil { - return nil, err - } - - if cfg != nil { - out = append(out, workspaceKeyRotation{wid: wid, cfg: cfg}) - } - } - - if len(out) == 0 { + if len(items) == 0 { slog.Info("No key rotation configuration to push") } - return out, nil + return items, nil } // warnIgnoredKeyRotation warns about local key rotation files a plain push skips because the feature @@ -183,14 +99,16 @@ func warnIgnoredKeyRotation(app *cac.Application) { return } - wids, err := localKeyRotationWorkspacesFor(app.Config.Storage.DirPath) + dir := keyrotation.NewDirStore(app.Config.Storage.DirPath) + + wids, err := keyRotationWorkspaces(dir) if err != nil { slog.Debug("failed to look for key rotation files", "error", err) return } for _, wid := range wids { - if !hasLocalKeyRotation(app.Config.Storage.DirPath, wid) { + if !dir.Has(wid) { continue } @@ -200,99 +118,14 @@ func warnIgnoredKeyRotation(app *cac.Application) { // pushKeyRotations pushes key rotation after a plain push, or writes it next to the dry-run output, // appending to the regular dry-run output file unless the regular push was skipped. -func pushKeyRotations(ctx context.Context, app *cac.Application, krs []workspaceKeyRotation) error { +func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []keyrotation.WorkspaceConfig) error { if pushConfig.DryRun { - dryRun := &keyRotationDryRun{out: pushConfig.Out, stdout: os.Stdout, appendFile: !keyRotationOnly(pushConfig.Filters)} - - for _, kr := range krs { - if err := dryRun.write(kr.wid, kr.cfg); err != nil { - return err - } - } - - return nil - } - - for _, kr := range krs { - if err := pushWorkspaceKeyRotation(ctx, app, kr.wid, kr.cfg); err != nil { - return err - } - } - - return nil -} - -// keyRotationDryRun writes key rotation settings next to the regular dry-run output: to -// workspaces//key_rotation.yaml when out is an existing directory, otherwise as an extra YAML -// document headed by a "# key_rotation ()" comment, to stdout or appended to the out file. -type keyRotationDryRun struct { - out string - stdout io.Writer - // appendFile makes the next write to a flat out file append rather than replace it - appendFile bool -} - -func (d *keyRotationDryRun) write(wid string, cfg *keyrotation.Config) error { - if d.out != "-" { - if info, err := os.Stat(d.out); err == nil && info.IsDir() { - return keyrotation.NewDirStore([]string{d.out}).Write(wid, cfg) - } - } - - section, err := keyRotationDryRunSection(wid, cfg) - if err != nil { - return err - } + dryRun := &keyrotation.DryRun{Out: pushConfig.Out, Stdout: os.Stdout, AppendFile: !keyRotationOnly(pushConfig.Filters)} - if d.out == "-" { - _, err = d.stdout.Write(section) - return errors.Wrap(err, "failed to write key rotation to stdout") + return dryRun.WriteAll(items) } - flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC - if d.appendFile { - flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND - } - - file, err := os.OpenFile(d.out, flags, 0644) - if err != nil { - return errors.Wrap(err, "failed to open dry-run output file") - } - - if _, err = file.Write(section); err != nil { - _ = file.Close() - return errors.Wrap(err, "failed to write key rotation to file") - } - - d.appendFile = true - - return errors.Wrap(file.Close(), "failed to write key rotation to file") -} - -// keyRotationDryRunSection renders the settings as a separate YAML document, so that the combined -// dry-run output stays a valid YAML stream. -func keyRotationDryRunSection(wid string, cfg *keyrotation.Config) ([]byte, error) { - bts, err := utils.ToYaml(cfg) - if err != nil { - return nil, errors.Wrap(err, "failed to marshal key rotation") - } - - return append(fmt.Appendf(nil, "---\n# key_rotation (%s)\n", wid), bts...), nil -} - -// keyRotationReader reads the key rotation settings of a workspace from the server. -type keyRotationReader interface { - Read(ctx context.Context, wid string) (*keyrotation.Config, error) -} - -// keyRotationSide reads key rotation for one side of a plain diff, mapping the diff source types: -// local reads the profile's storage dirs, remote the profile's server, and merged the local file, -// falling back to the server when the workspace has none. -type keyRotationSide struct { - local *keyrotation.DirStore - remote keyRotationReader - // tenant lists the workspaces of a remote side in tenant mode - tenant api.Source + return svc.Push(ctx, items) } // keyRotationRemoteAllowed reports whether the keys API may be called with the profile's client: the @@ -301,12 +134,13 @@ func keyRotationRemoteAllowed(conf *config.Configuration) bool { return conf.KeyRotationEnabled() && conf.Client != nil && slices.Contains(conf.Client.Scopes, config.ManageServersScope) } -// newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource. -// It returns nil, after a warning, when the side needs the server but its profile does not allow key -// rotation. -func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, error) { +// newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource: +// local reads the profile's storage dirs, remote the profile's server, and merged the local file, +// falling back to the server when the workspace has none. It returns nil, after a warning, when the +// side needs the server but its profile does not allow key rotation. +func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, error) { var ( - side = &keyRotationSide{} + side = &keyrotation.Side{} profile, sourceS, found = strings.Cut(spec, "@") sourceType api.SourceType err error @@ -330,7 +164,7 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, er return nil, errors.Errorf("no storage directories configured for profile %s", profile) } - side.local = keyrotation.NewDirStore(conf.Storage.DirPath) + side.Local = keyrotation.NewDirStore(conf.Storage.DirPath) } if sourceType == api.SourceRemote || sourceType == api.SourceMerged { @@ -341,77 +175,27 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyRotationSide, er // the selected profile's client is already initialized; in tenant mode it is a tenant client if conf.Name == app.Config.Name { - side.remote, side.tenant = app.KeyRotation, app.Client + side.Remote, side.Tenant = app.KeyRotation, app.Client } else { c, err := client.InitClient(conf.Client) if err != nil { return nil, err } - side.remote, side.tenant = c.KeyRotationStore(), c.Tenant() + side.Remote, side.Tenant = c.KeyRotationStore(), c.Tenant() } } return side, nil } -func (s *keyRotationSide) read(ctx context.Context, wid string) (*keyrotation.Config, error) { - if s.local != nil { - cfg, err := s.local.Read(wid) - if err != nil { - return nil, errors.Wrap(err, "failed to read local key rotation") - } - - if cfg != nil || s.remote == nil { - return cfg, nil - } - } - - cfg, err := s.remote.Read(ctx, wid) - - // a workspace the target lacks has no settings, so it diffs as all-source like the configuration - var notFound *keys.GetAutomaticKeyRotationNotFound - if errors.As(err, ¬Found) { - return nil, nil - } - - return cfg, err -} - -// workspaces lists the workspaces the side has key rotation for, in tenant mode. -func (s *keyRotationSide) workspaces(ctx context.Context) ([]string, error) { - var wids []string - - if s.local != nil { - local, err := localKeyRotationWorkspaces(s.local.Dirs) - if err != nil { - return nil, err - } - - wids = append(wids, local...) - } - - if s.remote != nil { - remote, err := tenantWorkspaces(ctx, s.tenant, nil) - if err != nil { - return nil, err - } - - wids = append(wids, remote...) - } - - slices.Sort(wids) - - return slices.Compact(wids), nil -} - // diffKeyRotations appends the key rotation diff of the selected workspace, or in tenant mode of every // workspace the source has, to the result of a plain diff. Target uses absent from the source are // ignored, as in diff key-rotation. func diffKeyRotations(ctx context.Context, app *cac.Application, result string) (string, error) { var ( wids = []string{rootConfig.Workspace} - source, target *keyRotationSide + source, target *keyrotation.Side err error ) @@ -424,44 +208,10 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) } if rootConfig.Tenant { - if wids, err = source.workspaces(ctx); err != nil { - return "", err - } - } - - for _, wid := range wids { - var ( - sourceCfg, targetCfg *keyrotation.Config - d string - ) - - if sourceCfg, err = source.read(ctx, wid); err != nil { - return "", err - } - - if targetCfg, err = target.read(ctx, wid); err != nil { - return "", err - } - - if d, err = keyRotationTreeDiff(sourceCfg, targetCfg, diffConfig.Colors); err != nil { + if wids, err = source.Workspaces(ctx); err != nil { return "", err } - - result = appendKeyRotationDiff(result, wid, d) - } - - return result, nil -} - -// appendKeyRotationDiff appends a non-empty key rotation diff under a "--- key_rotation ()" line. -func appendKeyRotationDiff(result, wid, d string) string { - if d == "" { - return result - } - - if result != "" && !strings.HasSuffix(result, "\n") { - result += "\n" } - return result + fmt.Sprintf("--- key_rotation (%s)\n", wid) + d + return keyrotation.AppendDiff(ctx, result, source, target, wids, diffConfig.Colors) } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 9b295cc..e2aa3f0 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -1,19 +1,10 @@ package cmd import ( - "bytes" - "context" - "os" - "path/filepath" "testing" - "github.com/cloudentity/acp-client-go/clients/hub/models" - kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" - "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" "github.com/cloudentity/cac/internal/cac/config" - "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -58,161 +49,6 @@ func TestKeyRotationOnly(t *testing.T) { require.True(t, keyRotationOnly([]string{"key_rotation", "key_rotation"})) } -func TestAppendKeyRotationDiff(t *testing.T) { - require.Equal(t, "normal\n", appendKeyRotationDiff("normal\n", "demo", "")) - require.Equal(t, "--- key_rotation (demo)\nkr\n", appendKeyRotationDiff("", "demo", "kr\n")) - require.Equal(t, "normal\n--- key_rotation (demo)\nkr\n", appendKeyRotationDiff("normal", "demo", "kr\n")) - require.Equal(t, "normal\n--- key_rotation (a)\nkr\n--- key_rotation (b)\nkr\n", - appendKeyRotationDiff(appendKeyRotationDiff("normal\n", "a", "kr\n"), "b", "kr\n")) -} - -func TestKeyRotationDryRun(t *testing.T) { - cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - section := "---\n# key_rotation (demo)\nsig:\n enabled: true\n cron: 0 0 1 * *\n" - - t.Run("section", func(t *testing.T) { - bts, err := keyRotationDryRunSection("demo", cfg) - require.NoError(t, err) - require.Equal(t, section, string(bts)) - }) - - t.Run("stdout", func(t *testing.T) { - var stdout bytes.Buffer - - dryRun := &keyRotationDryRun{out: "-", stdout: &stdout} - require.NoError(t, dryRun.write("demo", cfg)) - require.Equal(t, section, stdout.String()) - }) - - t.Run("directory", func(t *testing.T) { - dir := t.TempDir() - - dryRun := &keyRotationDryRun{out: dir} - require.NoError(t, dryRun.write("demo", cfg)) - - got, err := keyrotation.NewDirStore([]string{dir}).Read("demo") - require.NoError(t, err) - require.Equal(t, cfg, got) - }) - - t.Run("file after the regular output is appended to", func(t *testing.T) { - out := filepath.Join(t.TempDir(), "out.yaml") - require.NoError(t, os.WriteFile(out, []byte("name: demo\n"), 0644)) - - dryRun := &keyRotationDryRun{out: out, appendFile: true} - require.NoError(t, dryRun.write("demo", cfg)) - - bts, err := os.ReadFile(out) - require.NoError(t, err) - require.Equal(t, "name: demo\n"+section, string(bts)) - }) - - t.Run("file without the regular output is replaced, then appended to", func(t *testing.T) { - out := filepath.Join(t.TempDir(), "out.yaml") - require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) - - dryRun := &keyRotationDryRun{out: out} - require.NoError(t, dryRun.write("demo", cfg)) - require.NoError(t, dryRun.write("other", cfg)) - - bts, err := os.ReadFile(out) - require.NoError(t, err) - - other, err := keyRotationDryRunSection("other", cfg) - require.NoError(t, err) - require.Equal(t, section+string(other), string(bts)) - }) -} - -func TestLocalKeyRotationWorkspaces(t *testing.T) { - first, second := t.TempDir(), t.TempDir() - cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - - require.NoError(t, keyrotation.NewDirStore([]string{first}).Write("b", cfg)) - require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("a", cfg)) - require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("b", cfg)) - // a workspace dir without the file is not listed - require.NoError(t, os.MkdirAll(filepath.Join(first, "workspaces", "c"), 0755)) - - wids, err := localKeyRotationWorkspaces([]string{first, second, filepath.Join(first, "missing")}) - require.NoError(t, err) - require.Equal(t, []string{"a", "b"}, wids) -} - -type fakeSource struct { - data models.Rfc7396PatchOperation - reads int -} - -func (f *fakeSource) Read(context.Context, ...api.SourceOpt) (models.Rfc7396PatchOperation, error) { - f.reads++ - return f.data, nil -} - -func (f *fakeSource) Write(context.Context, models.Rfc7396PatchOperation, ...api.SourceOpt) error { - return nil -} - -func (f *fakeSource) String() string { - return "fake" -} - -func TestTenantWorkspaces(t *testing.T) { - t.Run("from the pulled configuration", func(t *testing.T) { - source := &fakeSource{} - - wids, err := tenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{ - "servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}, - }) - require.NoError(t, err) - require.Equal(t, []string{"a", "b"}, wids) - require.Zero(t, source.reads) - }) - - t.Run("read from the source when servers are missing", func(t *testing.T) { - source := &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"a": map[string]any{}}}} - - wids, err := tenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{"pools": map[string]any{}}) - require.NoError(t, err) - require.Equal(t, []string{"a"}, wids) - require.Equal(t, 1, source.reads) - }) - - t.Run("null servers", func(t *testing.T) { - wids, err := tenantWorkspaces(context.Background(), &fakeSource{}, models.Rfc7396PatchOperation{"servers": nil}) - require.NoError(t, err) - require.Empty(t, wids) - }) -} - -type fakeKeyRotationReader struct { - cfg *keyrotation.Config - err error -} - -func (f fakeKeyRotationReader) Read(context.Context, string) (*keyrotation.Config, error) { - return f.cfg, f.err -} - -func TestKeyRotationSideRead(t *testing.T) { - t.Run("a workspace missing remotely has no settings", func(t *testing.T) { - side := &keyRotationSide{remote: fakeKeyRotationReader{ - err: errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation"), - }} - - cfg, err := side.read(context.Background(), "missing") - require.NoError(t, err) - require.Nil(t, cfg) - }) - - t.Run("other remote errors fail", func(t *testing.T) { - side := &keyRotationSide{remote: fakeKeyRotationReader{err: kclient.NewGetAutomaticKeyRotationForbidden()}} - - _, err := side.read(context.Background(), "demo") - require.Error(t, err) - }) -} - func TestKeyRotationRemoteAllowed(t *testing.T) { withScopes := func(scopes ...string) *client.Configuration { c := client.DefaultConfig() diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index cf89b07..af2e075 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -3,64 +3,10 @@ package cmd import ( "io" "testing" - "time" - "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" ) -func keyRotationDiff(t *testing.T, local, remote *keyrotation.Config) string { - result, err := keyRotationTreeDiff(local, remote, false) - require.NoError(t, err) - - return result -} - -func TestKeyRotationDiffConfigs(t *testing.T) { - t.Run("starting_from is cleared on both sides", func(t *testing.T) { - startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) - - source, target := keyRotationDiffConfigs( - &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, - &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, - ) - - require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) - require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, target) - }) - - t.Run("remote use absent locally is dropped", func(t *testing.T) { - source, target := keyRotationDiffConfigs( - &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, - &keyrotation.Config{ - Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}, - Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, - }, - ) - - require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) - require.Equal(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}, target) - }) - - t.Run("nil local and configured remote give an empty diff", func(t *testing.T) { - require.Empty(t, keyRotationDiff(t, nil, &keyrotation.Config{ - Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, - Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, - })) - }) - - t.Run("nil configs give an empty diff", func(t *testing.T) { - require.Empty(t, keyRotationDiff(t, nil, nil)) - }) - - t.Run("local use missing remotely shows up", func(t *testing.T) { - require.NotEmpty(t, keyRotationDiff(t, &keyrotation.Config{ - Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, - }, nil)) - }) -} - // TestKeyRotationFlagScoping checks that the required flags of push and diff still apply to them, while // the key-rotation subcommands neither require nor accept the parent commands' own flags. func TestKeyRotationFlagScoping(t *testing.T) { diff --git a/cmd/push.go b/cmd/push.go index 6151294..52ea72d 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -6,6 +6,7 @@ import ( "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/storage" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -40,7 +41,8 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { var ( app *cac.Application - krs []workspaceKeyRotation + svc *keyrotation.Service + krs []keyrotation.WorkspaceConfig handle bool err error ) @@ -55,7 +57,11 @@ Examples: // key rotation is read and validated up front, so an invalid file fails before anything is pushed if handle { - if krs, err = readKeyRotations(app); err != nil { + if svc, err = keyRotationService(app); err != nil { + return err + } + + if krs, err = readKeyRotations(svc); err != nil { return err } } else if !app.Config.KeyRotationEnabled() { @@ -69,7 +75,7 @@ Examples: } if handle { - return pushKeyRotations(cmd.Context(), app, krs) + return pushKeyRotations(cmd.Context(), svc, krs) } return nil diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go new file mode 100644 index 0000000..d834fe8 --- /dev/null +++ b/internal/cac/keyrotation/diff.go @@ -0,0 +1,165 @@ +package keyrotation + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/acp-client-go/clients/system/client/keys" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" +) + +// TreeDiff renders the difference between the source and target settings the way the configuration +// diff does, after diffConfigs. It modifies its arguments. +func TreeDiff(source, target *Config, colors bool) (string, error) { + var ( + sourcePatch, targetPatch models.Rfc7396PatchOperation + err error + ) + + source, target = diffConfigs(source, target) + + if sourcePatch, err = utils.FromModelToPatch(source); err != nil { + return "", errors.Wrap(err, "failed to convert source key rotation") + } + + if targetPatch, err = utils.FromModelToPatch(target); err != nil { + return "", errors.Wrap(err, "failed to convert target key rotation") + } + + return diff.Tree(sourcePatch, targetPatch, diff.Colorize(colors)) +} + +// diffConfigs prepares the local and remote configurations for comparison. It modifies its +// arguments and replaces nil with an empty configuration. +func diffConfigs(local, remote *Config) (source, target *Config) { + if local == nil { + local = &Config{} + } + + if remote == nil { + remote = &Config{} + } + + // the server never echoes starting_from back, so it would always show up as a difference + for _, use := range slices.Concat(local.Uses(), remote.Uses()) { + use.Rotation.StartingFrom = nil + } + + // push never removes a use that is absent locally, so diff previews only what push would change + if local.Sig == nil { + remote.Sig = nil + } + + if local.Enc == nil { + remote.Enc = nil + } + + return local, remote +} + +// Side reads key rotation for one side of a plain diff. With Local only it reads the local files, +// with Remote only the server, and with both the local file, falling back to the server when the +// workspace has none. +type Side struct { + Local *DirStore + Remote Reader + // Tenant lists the workspaces of a remote side in tenant mode + Tenant api.Source +} + +func (s *Side) Read(ctx context.Context, wid string) (*Config, error) { + if s.Local != nil { + cfg, err := s.Local.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg != nil || s.Remote == nil { + return cfg, nil + } + } + + cfg, err := s.Remote.Read(ctx, wid) + + // a workspace the target lacks has no settings, so it diffs as all-source like the configuration + var notFound *keys.GetAutomaticKeyRotationNotFound + if errors.As(err, ¬Found) { + return nil, nil + } + + return cfg, err +} + +// Workspaces lists the workspaces the side has key rotation for, in tenant mode. +func (s *Side) Workspaces(ctx context.Context) ([]string, error) { + var wids []string + + if s.Local != nil { + local, err := s.Local.Workspaces() + if err != nil { + return nil, err + } + + wids = append(wids, local...) + } + + if s.Remote != nil { + remote, err := TenantWorkspaces(ctx, s.Tenant, nil) + if err != nil { + return nil, err + } + + wids = append(wids, remote...) + } + + slices.Sort(wids) + + return slices.Compact(wids), nil +} + +// AppendDiff appends to result the key rotation diff of each workspace, each non-empty one under a +// "--- key_rotation ()" line. Target uses absent from the source are ignored. +func AppendDiff(ctx context.Context, result string, source, target *Side, wids []string, colors bool) (string, error) { + for _, wid := range wids { + var ( + sourceCfg, targetCfg *Config + d string + err error + ) + + if sourceCfg, err = source.Read(ctx, wid); err != nil { + return "", err + } + + if targetCfg, err = target.Read(ctx, wid); err != nil { + return "", err + } + + if d, err = TreeDiff(sourceCfg, targetCfg, colors); err != nil { + return "", err + } + + result = appendSection(result, wid, d) + } + + return result, nil +} + +// appendSection appends a non-empty key rotation diff under a "--- key_rotation ()" line. +func appendSection(result, wid, d string) string { + if d == "" { + return result + } + + if result != "" && !strings.HasSuffix(result, "\n") { + result += "\n" + } + + return result + fmt.Sprintf("--- key_rotation (%s)\n", wid) + d +} diff --git a/internal/cac/keyrotation/diff_internal_test.go b/internal/cac/keyrotation/diff_internal_test.go new file mode 100644 index 0000000..83c951d --- /dev/null +++ b/internal/cac/keyrotation/diff_internal_test.go @@ -0,0 +1,44 @@ +package keyrotation + +import ( + "testing" + "time" + + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/require" +) + +func TestDiffConfigs(t *testing.T) { + t.Run("starting_from is cleared on both sides", func(t *testing.T) { + startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + + source, target := diffConfigs( + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + ) + + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, target) + }) + + t.Run("remote use absent locally is dropped", func(t *testing.T) { + source, target := diffConfigs( + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + &Config{ + Sig: &Rotation{Enabled: false, Cron: "0 0 1 * *"}, + Enc: &Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, + ) + + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &Config{Sig: &Rotation{Enabled: false, Cron: "0 0 1 * *"}}, target) + }) +} + +func TestAppendSection(t *testing.T) { + require.Equal(t, "normal\n", appendSection("normal\n", "demo", "")) + require.Equal(t, "--- key_rotation (demo)\nkr\n", appendSection("", "demo", "kr\n")) + require.Equal(t, "normal\n--- key_rotation (demo)\nkr\n", appendSection("normal", "demo", "kr\n")) + require.Equal(t, "normal\n--- key_rotation (a)\nkr\n--- key_rotation (b)\nkr\n", + appendSection(appendSection("normal\n", "a", "kr\n"), "b", "kr\n")) +} diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go new file mode 100644 index 0000000..d740365 --- /dev/null +++ b/internal/cac/keyrotation/diff_test.go @@ -0,0 +1,192 @@ +package keyrotation_test + +import ( + "context" + "testing" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func treeDiff(t *testing.T, source, target *keyrotation.Config) string { + result, err := keyrotation.TreeDiff(source, target, false) + require.NoError(t, err) + + return result +} + +func TestTreeDiff(t *testing.T) { + t.Run("nil local and configured remote give an empty diff", func(t *testing.T) { + require.Empty(t, treeDiff(t, nil, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + })) + }) + + t.Run("nil configs give an empty diff", func(t *testing.T) { + require.Empty(t, treeDiff(t, nil, nil)) + }) + + t.Run("local use missing remotely shows up", func(t *testing.T) { + require.NotEmpty(t, treeDiff(t, &keyrotation.Config{ + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, nil)) + }) +} + +// fakeRemote serves configs per workspace and records writes. +type fakeRemote struct { + configs map[string]*keyrotation.Config + errs map[string]error + writes []keyrotation.WorkspaceConfig +} + +func (f *fakeRemote) Read(_ context.Context, wid string) (*keyrotation.Config, error) { + return f.configs[wid], f.errs[wid] +} + +func (f *fakeRemote) Write(_ context.Context, wid string, cfg *keyrotation.Config) error { + if err := f.errs[wid]; err != nil { + return err + } + + f.writes = append(f.writes, keyrotation.WorkspaceConfig{WID: wid, Config: cfg}) + + return nil +} + +func TestSideRead(t *testing.T) { + t.Run("a workspace missing remotely has no settings", func(t *testing.T) { + side := &keyrotation.Side{Remote: &fakeRemote{errs: map[string]error{ + "missing": errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation"), + }}} + + cfg, err := side.Read(context.Background(), "missing") + require.NoError(t, err) + require.Nil(t, cfg) + }) + + t.Run("other remote errors fail", func(t *testing.T) { + side := &keyrotation.Side{Remote: &fakeRemote{errs: map[string]error{"demo": kclient.NewGetAutomaticKeyRotationForbidden()}}} + + _, err := side.Read(context.Background(), "demo") + require.Error(t, err) + }) + + t.Run("merged prefers the local file and falls back to the server", func(t *testing.T) { + dir := t.TempDir() + local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + remote := &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}} + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", local)) + + side := &keyrotation.Side{ + Local: keyrotation.NewDirStore([]string{dir}), + Remote: &fakeRemote{configs: map[string]*keyrotation.Config{"a": remote, "b": remote}}, + } + + cfg, err := side.Read(context.Background(), "a") + require.NoError(t, err) + require.Equal(t, local, cfg) + + cfg, err = side.Read(context.Background(), "b") + require.NoError(t, err) + require.Equal(t, remote, cfg) + }) + + t.Run("local only does not fall back", func(t *testing.T) { + cfg, err := (&keyrotation.Side{Local: keyrotation.NewDirStore([]string{t.TempDir()})}).Read(context.Background(), "a") + require.NoError(t, err) + require.Nil(t, cfg) + }) +} + +func TestSideWorkspaces(t *testing.T) { + dir := t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("c", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", cfg)) + + side := &keyrotation.Side{ + Local: keyrotation.NewDirStore([]string{dir}), + Remote: &fakeRemote{}, + Tenant: &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}}}, + } + + wids, err := side.Workspaces(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, wids) +} + +func TestAppendDiff(t *testing.T) { + dir := t.TempDir() + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("b", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) + + source := &keyrotation.Side{Local: keyrotation.NewDirStore([]string{dir})} + target := &keyrotation.Side{Remote: &fakeRemote{ + configs: map[string]*keyrotation.Config{ + // same settings: no section + "b": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + }, + errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationNotFound()}, + }} + + result, err := keyrotation.AppendDiff(context.Background(), "normal", source, target, []string{"a", "b"}, false) + require.NoError(t, err) + require.Equal(t, "normal\n--- key_rotation (a)\n"+treeDiff(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, nil), result) + + _, err = keyrotation.AppendDiff(context.Background(), "", source, &keyrotation.Side{Remote: &fakeRemote{ + errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationForbidden()}, + }}, []string{"a"}, false) + require.Error(t, err) +} + +type fakeSource struct { + data models.Rfc7396PatchOperation + reads int +} + +func (f *fakeSource) Read(context.Context, ...api.SourceOpt) (models.Rfc7396PatchOperation, error) { + f.reads++ + return f.data, nil +} + +func (f *fakeSource) Write(context.Context, models.Rfc7396PatchOperation, ...api.SourceOpt) error { + return nil +} + +func (f *fakeSource) String() string { + return "fake" +} + +func TestTenantWorkspaces(t *testing.T) { + t.Run("from the pulled configuration", func(t *testing.T) { + source := &fakeSource{} + + wids, err := keyrotation.TenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{ + "servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) + require.Zero(t, source.reads) + }) + + t.Run("read from the source when servers are missing", func(t *testing.T) { + source := &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"a": map[string]any{}}}} + + wids, err := keyrotation.TenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{"pools": map[string]any{}}) + require.NoError(t, err) + require.Equal(t, []string{"a"}, wids) + require.Equal(t, 1, source.reads) + }) + + t.Run("null servers", func(t *testing.T) { + wids, err := keyrotation.TenantWorkspaces(context.Background(), &fakeSource{}, models.Rfc7396PatchOperation{"servers": nil}) + require.NoError(t, err) + require.Empty(t, wids) + }) +} diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go index 492a17d..52b596a 100644 --- a/internal/cac/keyrotation/dir_store.go +++ b/internal/cac/keyrotation/dir_store.go @@ -1,8 +1,10 @@ package keyrotation import ( + "maps" "os" "path/filepath" + "slices" "github.com/cloudentity/cac/internal/cac/templates" "github.com/cloudentity/cac/internal/cac/utils" @@ -101,6 +103,39 @@ func (d *DirStore) Write(wid string, cfg *Config) error { return nil } +// Workspaces returns the sorted ids of the workspaces that have a key rotation file in any of the +// dirs. +func (d *DirStore) Workspaces() ([]string, error) { + ids := map[string]struct{}{} + + for _, dir := range d.Dirs { + entries, err := os.ReadDir(filepath.Join(dir, "workspaces")) + if os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() && d.has(dir, entry.Name()) { + ids[entry.Name()] = struct{}{} + } + } + } + + return slices.Sorted(maps.Keys(ids)), nil +} + +// Has reports whether any of the dirs has a key rotation file for the workspace. +func (d *DirStore) Has(wid string) bool { + return slices.ContainsFunc(d.Dirs, func(dir string) bool { return d.has(dir, wid) }) +} + +func (d *DirStore) has(dir string, wid string) bool { + _, err := os.Stat(filepath.Join(d.dirPath(dir, wid), FileName)) + return err == nil +} + func decodeStrict(raw map[string]any) (*Config, error) { var ( config Config diff --git a/internal/cac/keyrotation/dir_store_test.go b/internal/cac/keyrotation/dir_store_test.go index 74fd633..42c7848 100644 --- a/internal/cac/keyrotation/dir_store_test.go +++ b/internal/cac/keyrotation/dir_store_test.go @@ -201,3 +201,25 @@ enc: cron: '@monthly' `, string(bts)) } + +func TestDirStoreWorkspaces(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + + require.NoError(t, keyrotation.NewDirStore([]string{first}).Write("b", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("a", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("b", cfg)) + // a workspace dir without the file is not listed + require.NoError(t, os.MkdirAll(filepath.Join(first, "workspaces", "c"), 0755)) + + store := keyrotation.NewDirStore([]string{first, second, filepath.Join(first, "missing")}) + + wids, err := store.Workspaces() + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) + + require.True(t, store.Has("a")) + require.True(t, store.Has("b")) + require.False(t, store.Has("c")) + require.False(t, store.Has("missing")) +} diff --git a/internal/cac/keyrotation/dry_run.go b/internal/cac/keyrotation/dry_run.go new file mode 100644 index 0000000..e09afe2 --- /dev/null +++ b/internal/cac/keyrotation/dry_run.go @@ -0,0 +1,80 @@ +package keyrotation + +import ( + "fmt" + "io" + "os" + + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" +) + +// DryRun writes key rotation settings next to the regular dry-run output: to +// workspaces//key_rotation.yaml when Out is an existing directory, otherwise as an extra YAML +// document headed by a "# key_rotation ()" comment, to Stdout when Out is "-" or appended to the +// Out file. +type DryRun struct { + Out string + Stdout io.Writer + // AppendFile makes the next write to a flat Out file append rather than replace it + AppendFile bool +} + +// WriteAll writes the settings of each workspace. +func (d *DryRun) WriteAll(items []WorkspaceConfig) error { + for _, item := range items { + if err := d.Write(item.WID, item.Config); err != nil { + return err + } + } + + return nil +} + +func (d *DryRun) Write(wid string, cfg *Config) error { + if d.Out != "-" { + if info, err := os.Stat(d.Out); err == nil && info.IsDir() { + return NewDirStore([]string{d.Out}).Write(wid, cfg) + } + } + + section, err := DryRunSection(wid, cfg) + if err != nil { + return err + } + + if d.Out == "-" { + _, err = d.Stdout.Write(section) + return errors.Wrap(err, "failed to write key rotation to stdout") + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if d.AppendFile { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + + file, err := os.OpenFile(d.Out, flags, 0644) + if err != nil { + return errors.Wrap(err, "failed to open dry-run output file") + } + + if _, err = file.Write(section); err != nil { + _ = file.Close() + return errors.Wrap(err, "failed to write key rotation to file") + } + + d.AppendFile = true + + return errors.Wrap(file.Close(), "failed to write key rotation to file") +} + +// DryRunSection renders the settings as a separate YAML document, so that the combined dry-run +// output stays a valid YAML stream. +func DryRunSection(wid string, cfg *Config) ([]byte, error) { + bts, err := utils.ToYaml(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal key rotation") + } + + return append(fmt.Appendf(nil, "---\n# key_rotation (%s)\n", wid), bts...), nil +} diff --git a/internal/cac/keyrotation/dry_run_test.go b/internal/cac/keyrotation/dry_run_test.go new file mode 100644 index 0000000..56380ed --- /dev/null +++ b/internal/cac/keyrotation/dry_run_test.go @@ -0,0 +1,68 @@ +package keyrotation_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/stretchr/testify/require" +) + +func TestDryRun(t *testing.T) { + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + section := "---\n# key_rotation (demo)\nsig:\n enabled: true\n cron: 0 0 1 * *\n" + + t.Run("section", func(t *testing.T) { + bts, err := keyrotation.DryRunSection("demo", cfg) + require.NoError(t, err) + require.Equal(t, section, string(bts)) + }) + + t.Run("stdout", func(t *testing.T) { + var stdout bytes.Buffer + + dryRun := &keyrotation.DryRun{Out: "-", Stdout: &stdout} + require.NoError(t, dryRun.Write("demo", cfg)) + require.Equal(t, section, stdout.String()) + }) + + t.Run("directory", func(t *testing.T) { + dir := t.TempDir() + + dryRun := &keyrotation.DryRun{Out: dir} + require.NoError(t, dryRun.Write("demo", cfg)) + + got, err := keyrotation.NewDirStore([]string{dir}).Read("demo") + require.NoError(t, err) + require.Equal(t, cfg, got) + }) + + t.Run("file after the regular output is appended to", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("name: demo\n"), 0644)) + + dryRun := &keyrotation.DryRun{Out: out, AppendFile: true} + require.NoError(t, dryRun.Write("demo", cfg)) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, "name: demo\n"+section, string(bts)) + }) + + t.Run("file without the regular output is replaced, then appended to", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + + dryRun := &keyrotation.DryRun{Out: out} + require.NoError(t, dryRun.WriteAll([]keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}, {WID: "other", Config: cfg}})) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + + other, err := keyrotation.DryRunSection("other", cfg) + require.NoError(t, err) + require.Equal(t, section+string(other), string(bts)) + }) +} diff --git a/internal/cac/keyrotation/service.go b/internal/cac/keyrotation/service.go new file mode 100644 index 0000000..f8fb506 --- /dev/null +++ b/internal/cac/keyrotation/service.go @@ -0,0 +1,163 @@ +package keyrotation + +import ( + "context" + "maps" + "slices" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/pkg/errors" + "golang.org/x/exp/slog" +) + +// Reader reads the key rotation settings of a workspace from the server. +type Reader interface { + Read(ctx context.Context, wid string) (*Config, error) +} + +// Remote reads and writes the key rotation settings of a workspace on the server. +type Remote interface { + Reader + Write(ctx context.Context, wid string, cfg *Config) error +} + +// WorkspaceConfig pairs a workspace id with its key rotation settings. +type WorkspaceConfig struct { + WID string + Config *Config +} + +// Service pulls, pushes and diffs key rotation settings between local files and the server. +type Service struct { + Dir *DirStore + Remote Remote +} + +func NewService(dir *DirStore, remote Remote) *Service { + return &Service{Dir: dir, Remote: remote} +} + +// Pull writes the remote key rotation settings of each workspace to the local files. It writes +// nothing for a workspace that has none configured. +func (s *Service) Pull(ctx context.Context, wids []string) error { + for _, wid := range wids { + if err := s.pull(ctx, wid); err != nil { + return err + } + } + + return nil +} + +func (s *Service) pull(ctx context.Context, wid string) error { + var ( + cfg *Config + err error + ) + + slog.With("workspace", wid).Info("Pulling key rotation") + + if cfg, err = s.Remote.Read(ctx, wid); err != nil { + return err + } + + if cfg == nil { + slog.Info("No key rotation configured", "workspace", wid) + return nil + } + + if err = s.Dir.Write(wid, cfg); err != nil { + return err + } + + slog.Info("Pulled key rotation", "workspace", wid) + + return nil +} + +// ReadLocal reads and validates the local key rotation settings of the workspace. It returns nil +// when the workspace has none. +func (s *Service) ReadLocal(wid string) (*Config, error) { + cfg, err := s.Dir.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg == nil { + return nil, nil + } + + if err = cfg.Validate(); err != nil { + return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) + } + + return cfg, nil +} + +// ReadAll reads and validates the local key rotation settings of each workspace, so that an invalid +// file fails before anything is pushed. Workspaces without settings are left out. +func (s *Service) ReadAll(wids []string) ([]WorkspaceConfig, error) { + var out []WorkspaceConfig + + for _, wid := range wids { + cfg, err := s.ReadLocal(wid) + if err != nil { + return nil, err + } + + if cfg != nil { + out = append(out, WorkspaceConfig{WID: wid, Config: cfg}) + } + } + + return out, nil +} + +// Push writes the key rotation settings of each workspace to the server. +func (s *Service) Push(ctx context.Context, items []WorkspaceConfig) error { + for _, item := range items { + if err := s.Remote.Write(ctx, item.WID, item.Config); err != nil { + return err + } + + slog.Info("Pushed key rotation", "workspace", item.WID, "uses", len(item.Config.Uses())) + } + + return nil +} + +// Diff renders the difference between the local and remote key rotation settings of the workspace. +func (s *Service) Diff(ctx context.Context, wid string, colors bool) (string, error) { + var ( + local, remote *Config + err error + ) + + if local, err = s.Dir.Read(wid); err != nil { + return "", errors.Wrap(err, "failed to read local key rotation") + } + + if remote, err = s.Remote.Read(ctx, wid); err != nil { + return "", err + } + + return TreeDiff(local, remote, colors) +} + +// TenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has +// no servers, because the regular pull was skipped or filtered them out, they are read from source. +func TenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc7396PatchOperation) ([]string, error) { + var err error + + if _, ok := data["servers"]; !ok { + if data, err = source.Read(ctx, api.WithFilters([]string{"servers"})); err != nil { + return nil, errors.Wrap(err, "failed to list tenant workspaces") + } + } + + // the tenant export is decoded into a generic map, so servers is either a map or null + servers, _ := data["servers"].(map[string]any) + + return slices.Sorted(maps.Keys(servers)), nil +} diff --git a/internal/cac/keyrotation/service_test.go b/internal/cac/keyrotation/service_test.go new file mode 100644 index 0000000..c26b610 --- /dev/null +++ b/internal/cac/keyrotation/service_test.go @@ -0,0 +1,86 @@ +package keyrotation_test + +import ( + "context" + "testing" + + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestServicePull(t *testing.T) { + dir := t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{ + configs: map[string]*keyrotation.Config{"a": cfg}, + }) + + require.NoError(t, svc.Pull(context.Background(), []string{"a", "unconfigured"})) + + wids, err := svc.Dir.Workspaces() + require.NoError(t, err) + require.Equal(t, []string{"a"}, wids) + + got, err := svc.Dir.Read("a") + require.NoError(t, err) + require.Equal(t, cfg, got) + + svc.Remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + require.EqualError(t, svc.Pull(context.Background(), []string{"a"}), "boom") +} + +func TestServiceReadAll(t *testing.T) { + dir := t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{}) + require.NoError(t, svc.Dir.Write("a", cfg)) + + items, err := svc.ReadAll([]string{"a", "missing"}) + require.NoError(t, err) + require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: cfg}}, items) + + items, err = svc.ReadAll([]string{"missing"}) + require.NoError(t, err) + require.Empty(t, items) + + require.NoError(t, svc.Dir.Write("invalid", &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "bad"}})) + + _, err = svc.ReadAll([]string{"a", "invalid"}) + require.ErrorContains(t, err, "failed to validate key rotation of workspace invalid: invalid cron for enc") +} + +func TestServicePush(t *testing.T) { + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + items := []keyrotation.WorkspaceConfig{{WID: "a", Config: cfg}, {WID: "b", Config: cfg}} + + remote := &fakeRemote{} + require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items)) + require.Equal(t, items, remote.writes) + + remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") + require.Empty(t, remote.writes) +} + +func TestServiceDiff(t *testing.T) { + dir := t.TempDir() + local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{ + configs: map[string]*keyrotation.Config{"a": {Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}}, + }) + require.NoError(t, svc.Dir.Write("a", local)) + + result, err := svc.Diff(context.Background(), "a", false) + require.NoError(t, err) + require.Equal(t, treeDiff(t, + &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}, + ), result) + require.NotEmpty(t, result) + + // unlike a plain diff side, the subcommand does not treat a missing workspace as unconfigured + svc.Remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + _, err = svc.Diff(context.Background(), "a", false) + require.EqualError(t, err, "boom") +} From 242ff12fbca406fb7b4dec08822a571d66cbf4f2 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:33:18 +0200 Subject: [PATCH 22/38] fix(diff): skip the regular diff when key_rotation is the only filter 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. --- cmd/diff.go | 80 +++++++++++++++++++------------- cmd/key_rotation_feature.go | 6 +-- cmd/key_rotation_feature_test.go | 64 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 34 deletions(-) diff --git a/cmd/diff.go b/cmd/diff.go index e8b919a..5a29460 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -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 ( @@ -42,9 +44,6 @@ Examples: var ( app *cac.Application result string - source api.Source - target api.Source - handle bool err error ) @@ -64,36 +63,10 @@ Examples: return err } - if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { - return err - } - - if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { + if result, err = runDiff(cmd.Context(), app); err != nil { return err } - if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); 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 { - return err - } - - if handle { - if result, err = diffKeyRotations(cmd.Context(), app, result); err != nil { - return err - } - } - return writeDiffResult(result) }, } @@ -109,6 +82,51 @@ Examples: } ) +// runDiff compares the configuration of the source and target, unless --filter selects key rotation +// alone, then appends the key rotation diff when it is requested. +func runDiff(ctx context.Context, app *cac.Application) (string, error) { + var ( + result string + source api.Source + target api.Source + handle bool + err error + ) + + if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { + return "", err + } + + // --filter key_rotation alone compares key rotation only, without reading the configuration + if !keyRotationOnly(diffConfig.Filters) { + if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { + return "", err + } + + if target, err = app.PickSource(diffConfig.Target, 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 handle { + return diffKeyRotations(ctx, app, result) + } + + return result, nil +} + func writeDiffResult(result string) error { if diffConfig.Out != "-" { if err := os.WriteFile(diffConfig.Out, []byte(result), 0644); err != nil { diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 7279996..9bcedb4 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -33,9 +33,9 @@ func keyRotationRequested(enabled bool, filters []string) (bool, error) { return enabled && (len(filters) == 0 || slices.Contains(filters, keyRotationFilter)), nil } -// keyRotationOnly reports whether --filter selects key rotation alone. Pull and push then skip the -// regular pipeline, since its empty result is not a no-op: a pull rewrites server.yaml and an import -// resets the workspace settings. +// keyRotationOnly reports whether --filter selects key rotation alone. Pull, push and diff then skip +// the regular pipeline, since its empty result is not a no-op: a pull rewrites server.yaml, an import +// resets the workspace settings, and a diff still reads and renders the whole configuration. func keyRotationOnly(filters []string) bool { return len(filters) > 0 && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index e2aa3f0..ba40c2a 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -1,10 +1,16 @@ package cmd import ( + "context" + "os" + "path/filepath" "testing" + "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/client" "github.com/cloudentity/cac/internal/cac/config" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/storage" "github.com/stretchr/testify/require" ) @@ -74,3 +80,61 @@ func TestKeyRotationRemoteAllowed(t *testing.T) { }) } } + +// keyRotationApp builds an application whose selected profile keeps its files in a temp dir and has +// key rotation on, next to the given other profile, so that diffs between local sides need no server. +func keyRotationApp(t *testing.T, other config.Configuration) *cac.Application { + selected := config.Configuration{ + Name: "default", + Storage: &storage.MultiStorageConfiguration{DirPath: []string{t.TempDir()}}, + Features: &config.Features{KeyRotation: true}, + } + other.Name = "other" + + return &cac.Application{ + Config: &selected, + RootConfig: &config.RootConfiguration{Default: selected, Profiles: map[string]config.Configuration{"other": other}}, + } +} + +// setDiffConfig sets the diff and root flags for the duration of the test. +func setDiffConfig(t *testing.T, source, target string, filters ...string) { + savedDiff, savedRoot := diffConfig, rootConfig + t.Cleanup(func() { diffConfig, rootConfig = savedDiff, savedRoot }) + + diffConfig.Source, diffConfig.Target, diffConfig.Filters, diffConfig.Colors = source, target, filters, false + rootConfig.Workspace, rootConfig.Tenant = "demo", false +} + +func monthly(enabled bool) *keyrotation.Config { + return &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"}} +} + +func TestRunDiffKeyRotationOnly(t *testing.T) { + otherDir := t.TempDir() + app := keyRotationApp(t, config.Configuration{ + Storage: &storage.MultiStorageConfiguration{DirPath: []string{otherDir}}, + Features: &config.Features{KeyRotation: true}, + }) + dir := app.Config.Storage.DirPath[0] + + // a broken template fails the regular diff of the workspace + require.NoError(t, os.MkdirAll(filepath.Join(dir, "workspaces", "demo"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces", "demo", "server.yaml"), []byte("{{ broken"), 0644)) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("demo", monthly(true))) + require.NoError(t, keyrotation.NewDirStore([]string{otherDir}).Write("demo", monthly(false))) + + want, err := keyrotation.TreeDiff(monthly(true), monthly(false), false) + require.NoError(t, err) + + setDiffConfig(t, "local", "other@local", "key_rotation") + + result, err := runDiff(context.Background(), app) + require.NoError(t, err) + require.Equal(t, "--- key_rotation (demo)\n"+want, result) + + setDiffConfig(t, "local", "other@local", "clients", "key_rotation") + + _, err = runDiff(context.Background(), app) + require.ErrorContains(t, err, "failed to render template") +} From 50d5325e0811461ef403685dbd982037229500b4 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:34:32 +0200 Subject: [PATCH 23/38] fix(diff): report a key rotation side whose profile has the feature disabled 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. --- README.md | 5 ++++ cmd/key_rotation_feature.go | 40 +++++++++++++++++++++++---- cmd/key_rotation_feature_test.go | 21 ++++++++++++++ internal/cac/keyrotation/diff.go | 12 +++++++- internal/cac/keyrotation/diff_test.go | 6 ++++ 5 files changed, 77 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 33c6d53..a4cbe78 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,11 @@ features: 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 a profile without `features.key_rotation` fails the + diff when `--filter` names `key_rotation`. Otherwise the diff shows + `--- key_rotation skipped: profile has features.key_rotation disabled` + in place of the key rotation sections and still exits 0. + With the feature disabled, `--filter key_rotation` fails with `--filter key_rotation requires features.key_rotation: true`, plain `pull` and `diff` ignore key rotation, and plain `push` warns about each `key_rotation.yaml` it skips: diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 9bcedb4..d9907b2 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -134,10 +134,21 @@ func keyRotationRemoteAllowed(conf *config.Configuration) bool { return conf.KeyRotationEnabled() && conf.Client != nil && slices.Contains(conf.Client.Scopes, config.ManageServersScope) } +// keyRotationDisabledError reports a diff side that needs the server of a profile that does not +// enable key rotation. +type keyRotationDisabledError struct { + profile string +} + +func (e *keyRotationDisabledError) Error() string { + return fmt.Sprintf("profile %s cannot diff key rotation: features.key_rotation is not enabled", e.profile) +} + // newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource: // local reads the profile's storage dirs, remote the profile's server, and merged the local file, -// falling back to the server when the workspace has none. It returns nil, after a warning, when the -// side needs the server but its profile does not allow key rotation. +// falling back to the server when the workspace has none. It returns a *keyRotationDisabledError when +// the side needs the server but its profile does not enable key rotation, and nil, after a warning, +// when its client lacks the manage_servers scope. func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, error) { var ( side = &keyrotation.Side{} @@ -159,6 +170,10 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e return nil, err } + if sourceType != api.SourceLocal && !conf.KeyRotationEnabled() { + return nil, &keyRotationDisabledError{profile: profile} + } + if sourceType == api.SourceLocal || sourceType == api.SourceMerged { if conf.Storage == nil || len(conf.Storage.DirPath) == 0 { return nil, errors.Errorf("no storage directories configured for profile %s", profile) @@ -169,7 +184,7 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e if sourceType == api.SourceRemote || sourceType == api.SourceMerged { if !keyRotationRemoteAllowed(conf) { - slog.Warn(fmt.Sprintf("key rotation skipped for profile %s: features.key_rotation disabled or manage_servers scope missing", profile)) + slog.Warn(fmt.Sprintf("key rotation skipped for profile %s: manage_servers scope missing", profile)) return nil, nil } @@ -191,7 +206,8 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e // diffKeyRotations appends the key rotation diff of the selected workspace, or in tenant mode of every // workspace the source has, to the result of a plain diff. Target uses absent from the source are -// ignored, as in diff key-rotation. +// ignored, as in diff key-rotation. A side whose profile does not enable key rotation fails the diff +// when --filter names key_rotation, and otherwise leaves a marker line in its place. func diffKeyRotations(ctx context.Context, app *cac.Application, result string) (string, error) { var ( wids = []string{rootConfig.Workspace} @@ -200,11 +216,11 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) ) if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil || source == nil { - return result, err + return skipDisabledSide(result, err) } if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil || target == nil { - return result, err + return skipDisabledSide(result, err) } if rootConfig.Tenant { @@ -215,3 +231,15 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) return keyrotation.AppendDiff(ctx, result, source, target, wids, diffConfig.Colors) } + +// skipDisabledSide returns err, except for a side whose profile does not enable key rotation while +// --filter does not name key_rotation: the diff then gets a marker line instead of the section. +func skipDisabledSide(result string, err error) (string, error) { + var disabled *keyRotationDisabledError + + if !errors.As(err, &disabled) || slices.Contains(diffConfig.Filters, keyRotationFilter) { + return result, err + } + + return keyrotation.AppendSkipped(result, fmt.Sprintf("profile %s has features.key_rotation disabled", disabled.profile)), nil +} diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index ba40c2a..82791fb 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -138,3 +138,24 @@ func TestRunDiffKeyRotationOnly(t *testing.T) { _, err = runDiff(context.Background(), app) require.ErrorContains(t, err, "failed to render template") } + +func TestDiffKeyRotationsDisabledProfile(t *testing.T) { + app := keyRotationApp(t, config.Configuration{Features: &config.Features{}}) + + t.Run("without --filter the diff carries a marker", func(t *testing.T) { + for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { + setDiffConfig(t, sides[0], sides[1]) + + result, err := diffKeyRotations(context.Background(), app, "normal") + require.NoError(t, err, sides) + require.Equal(t, "normal\n--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) + } + }) + + t.Run("--filter key_rotation fails", func(t *testing.T) { + setDiffConfig(t, "local", "other@remote", "clients", "key_rotation") + + _, err := diffKeyRotations(context.Background(), app, "normal") + require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") + }) +} diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go index d834fe8..8f70cea 100644 --- a/internal/cac/keyrotation/diff.go +++ b/internal/cac/keyrotation/diff.go @@ -157,9 +157,19 @@ func appendSection(result, wid, d string) string { return result } + return appendLines(result, fmt.Sprintf("--- key_rotation (%s)\n", wid)+d) +} + +// AppendSkipped appends a "--- key_rotation skipped: " line, so that key rotation that could +// not be compared shows up in the diff rather than being silently left out. +func AppendSkipped(result, reason string) string { + return appendLines(result, fmt.Sprintf("--- key_rotation skipped: %s\n", reason)) +} + +func appendLines(result, lines string) string { if result != "" && !strings.HasSuffix(result, "\n") { result += "\n" } - return result + fmt.Sprintf("--- key_rotation (%s)\n", wid) + d + return result + lines } diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go index d740365..087feaf 100644 --- a/internal/cac/keyrotation/diff_test.go +++ b/internal/cac/keyrotation/diff_test.go @@ -145,6 +145,12 @@ func TestAppendDiff(t *testing.T) { require.Error(t, err) } +func TestAppendSkipped(t *testing.T) { + require.Equal(t, "--- key_rotation skipped: why\n", keyrotation.AppendSkipped("", "why")) + require.Equal(t, "normal\n--- key_rotation skipped: why\n", keyrotation.AppendSkipped("normal", "why")) + require.Equal(t, "normal\n--- key_rotation skipped: why\n", keyrotation.AppendSkipped("normal\n", "why")) +} + type fakeSource struct { data models.Rfc7396PatchOperation reads int From 471d6a5c64c6adcfc3b320312415f13413f01367 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:35:38 +0200 Subject: [PATCH 24/38] refactor(diff): validate the profile of a cross-profile key rotation 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. --- README.md | 3 ++- cmd/key_rotation_feature.go | 31 +++++++++++-------------- cmd/key_rotation_feature_test.go | 36 ++++++++---------------------- internal/cac/config/config.go | 6 ++--- internal/cac/config/config_test.go | 2 +- 5 files changed, 28 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index a4cbe78..50425b0 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,8 @@ features: A `remote` or `merged` side of a profile without `features.key_rotation` fails the diff when `--filter` names `key_rotation`. Otherwise the diff shows `--- key_rotation skipped: profile has features.key_rotation disabled` - in place of the key rotation sections and still exits 0. + in place of the key rotation sections and still exits 0. A side of a 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`, plain `pull` and `diff` diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index d9907b2..9673e8d 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -13,7 +13,6 @@ import ( "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" - "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/pkg/errors" "golang.org/x/exp/slog" @@ -128,12 +127,6 @@ func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []key return svc.Push(ctx, items) } -// keyRotationRemoteAllowed reports whether the keys API may be called with the profile's client: the -// profile enables features.key_rotation and its client has the manage_servers scope. -func keyRotationRemoteAllowed(conf *config.Configuration) bool { - return conf.KeyRotationEnabled() && conf.Client != nil && slices.Contains(conf.Client.Scopes, config.ManageServersScope) -} - // keyRotationDisabledError reports a diff side that needs the server of a profile that does not // enable key rotation. type keyRotationDisabledError struct { @@ -147,8 +140,8 @@ func (e *keyRotationDisabledError) Error() string { // newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource: // local reads the profile's storage dirs, remote the profile's server, and merged the local file, // falling back to the server when the workspace has none. It returns a *keyRotationDisabledError when -// the side needs the server but its profile does not enable key rotation, and nil, after a warning, -// when its client lacks the manage_servers scope. +// the side needs the server but its profile does not enable key rotation, and fails when the profile +// enables it without the manage_servers scope. func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, error) { var ( side = &keyrotation.Side{} @@ -170,8 +163,15 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e return nil, err } - if sourceType != api.SourceLocal && !conf.KeyRotationEnabled() { - return nil, &keyRotationDisabledError{profile: profile} + if sourceType != api.SourceLocal { + if !conf.KeyRotationEnabled() { + return nil, &keyRotationDisabledError{profile: profile} + } + + // InitApp validated the selected profile only + if err = conf.Validate(); err != nil { + return nil, errors.Wrapf(err, "profile %s", profile) + } } if sourceType == api.SourceLocal || sourceType == api.SourceMerged { @@ -183,11 +183,6 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e } if sourceType == api.SourceRemote || sourceType == api.SourceMerged { - if !keyRotationRemoteAllowed(conf) { - slog.Warn(fmt.Sprintf("key rotation skipped for profile %s: manage_servers scope missing", profile)) - return nil, nil - } - // the selected profile's client is already initialized; in tenant mode it is a tenant client if conf.Name == app.Config.Name { side.Remote, side.Tenant = app.KeyRotation, app.Client @@ -215,11 +210,11 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) err error ) - if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil || source == nil { + if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { return skipDisabledSide(result, err) } - if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil || target == nil { + if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { return skipDisabledSide(result, err) } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 82791fb..60fe305 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -55,32 +55,6 @@ func TestKeyRotationOnly(t *testing.T) { require.True(t, keyRotationOnly([]string{"key_rotation", "key_rotation"})) } -func TestKeyRotationRemoteAllowed(t *testing.T) { - withScopes := func(scopes ...string) *client.Configuration { - c := client.DefaultConfig() - c.Scopes = scopes - return c - } - - tcs := []struct { - name string - conf *config.Configuration - want bool - }{ - {"enabled with manage_servers", &config.Configuration{Features: &config.Features{KeyRotation: true}, Client: withScopes("manage_configuration", "manage_servers")}, true}, - {"enabled without manage_servers", &config.Configuration{Features: &config.Features{KeyRotation: true}, Client: withScopes("manage_configuration")}, false}, - {"enabled without client", &config.Configuration{Features: &config.Features{KeyRotation: true}}, false}, - {"disabled with manage_servers", &config.Configuration{Features: &config.Features{}, Client: withScopes("manage_servers")}, false}, - {"no features", &config.Configuration{Client: withScopes("manage_servers")}, false}, - } - - for _, tc := range tcs { - t.Run(tc.name, func(t *testing.T) { - require.Equal(t, tc.want, keyRotationRemoteAllowed(tc.conf)) - }) - } -} - // keyRotationApp builds an application whose selected profile keeps its files in a temp dir and has // key rotation on, next to the given other profile, so that diffs between local sides need no server. func keyRotationApp(t *testing.T, other config.Configuration) *cac.Application { @@ -139,7 +113,7 @@ func TestRunDiffKeyRotationOnly(t *testing.T) { require.ErrorContains(t, err, "failed to render template") } -func TestDiffKeyRotationsDisabledProfile(t *testing.T) { +func TestDiffKeyRotationsOtherProfile(t *testing.T) { app := keyRotationApp(t, config.Configuration{Features: &config.Features{}}) t.Run("without --filter the diff carries a marker", func(t *testing.T) { @@ -158,4 +132,12 @@ func TestDiffKeyRotationsDisabledProfile(t *testing.T) { _, err := diffKeyRotations(context.Background(), app, "normal") require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") }) + + t.Run("an enabled profile without the manage_servers scope fails", func(t *testing.T) { + app := keyRotationApp(t, config.Configuration{Features: &config.Features{KeyRotation: true}, Client: client.DefaultConfig()}) + setDiffConfig(t, "local", "other@remote") + + _, err := diffKeyRotations(context.Background(), app, "normal") + require.EqualError(t, err, "profile other: features.key_rotation requires the manage_servers scope in client.scopes") + }) } diff --git a/internal/cac/config/config.go b/internal/cac/config/config.go index 6e0a71d..5490d3c 100644 --- a/internal/cac/config/config.go +++ b/internal/cac/config/config.go @@ -26,8 +26,8 @@ var ( } ) -// ManageServersScope is the scope the system keys API behind key rotation requires. -const ManageServersScope = "manage_servers" +// manageServersScope is the scope the system keys API behind key rotation requires. +const manageServersScope = "manage_servers" // Features toggles optional behaviour of the plain pull, push and diff commands. type Features struct { @@ -71,7 +71,7 @@ func (c *Configuration) KeyRotationEnabled() bool { // Validate checks settings that only make sense together. func (c *Configuration) Validate() error { - if c.KeyRotationEnabled() && (c.Client == nil || !slices.Contains(c.Client.Scopes, ManageServersScope)) { + if c.KeyRotationEnabled() && (c.Client == nil || !slices.Contains(c.Client.Scopes, manageServersScope)) { return errors.New("features.key_rotation requires the manage_servers scope in client.scopes") } diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index 32b4c6a..dac275e 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -305,7 +305,7 @@ func TestValidateConfiguration(t *testing.T) { conf, err := rootConf.ForProfile("") require.NoError(t, err) - require.NotContains(t, conf.Client.Scopes, config.ManageServersScope) + require.NotContains(t, conf.Client.Scopes, "manage_servers") require.NoError(t, conf.Validate()) }) } From 2131db83312a9dfe6eeab32bb9aaa35abc7982c7 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:36:18 +0200 Subject: [PATCH 25/38] refactor(push): reuse the key rotation dry-run writer in push key-rotation 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 ()' document to stdout or a replaced --out file, or workspaces//key_rotation.yaml under an --out directory. --- README.md | 5 ++++- cmd/key_rotation.go | 22 +------------------- cmd/key_rotation_feature.go | 8 ++++---- cmd/key_rotation_test.go | 40 +++++++++++++++++++++++++++++++++++++ cmd/push.go | 2 +- 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 50425b0..ef9543a 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,10 @@ Constraints: - `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. +> and `--dry-run` writes the settings that would be sent instead of pushing them, in the +> same format as [plain `push --dry-run`](#key-rotation-in-plain-pull-push-and-diff): +> a `# key_rotation ()` YAML document to stdout or an `--out` file, or +> `/workspaces//key_rotation.yaml` when `--out` is an existing directory. #### Key rotation in plain pull, push and diff diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index 3252984..16a5de1 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -1,11 +1,8 @@ package cmd import ( - "os" - "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" @@ -73,24 +70,7 @@ func pushKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) e return nil } - if pushConfig.DryRun { - bts, err := utils.ToYaml(items[0].Config) - if err != nil { - return errors.Wrap(err, "failed to marshal key rotation") - } - - if pushConfig.Out != "-" { - return errors.Wrap(os.WriteFile(pushConfig.Out, bts, 0644), "failed to write key rotation to file") - } - - if _, err = os.Stdout.Write(bts); err != nil { - return errors.Wrap(err, "failed to write key rotation to stdout") - } - - return nil - } - - return svc.Push(cmd.Context(), items) + return pushKeyRotations(cmd.Context(), svc, items, false) } func diffKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 9673e8d..be8cf73 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -115,11 +115,11 @@ func warnIgnoredKeyRotation(app *cac.Application) { } } -// pushKeyRotations pushes key rotation after a plain push, or writes it next to the dry-run output, -// appending to the regular dry-run output file unless the regular push was skipped. -func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []keyrotation.WorkspaceConfig) error { +// pushKeyRotations pushes key rotation, or with --dry-run writes it to --out, appending to an --out +// file when appendFile is set because the regular dry-run output was written to it first. +func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []keyrotation.WorkspaceConfig, appendFile bool) error { if pushConfig.DryRun { - dryRun := &keyrotation.DryRun{Out: pushConfig.Out, Stdout: os.Stdout, AppendFile: !keyRotationOnly(pushConfig.Filters)} + dryRun := &keyrotation.DryRun{Out: pushConfig.Out, Stdout: os.Stdout, AppendFile: appendFile} return dryRun.WriteAll(items) } diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index af2e075..ab413f8 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -2,8 +2,12 @@ package cmd import ( "io" + "os" + "path/filepath" "testing" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -35,3 +39,39 @@ func TestKeyRotationFlagScoping(t *testing.T) { require.EqualError(t, rootCmd.Execute(), tc.err, tc.args) } } + +// TestPushKeyRotationDryRun checks that push key-rotation --dry-run writes what plain push --dry-run +// writes for key rotation alone. +func TestPushKeyRotationDryRun(t *testing.T) { + saved := pushConfig + t.Cleanup(func() { pushConfig = saved }) + + svc := keyrotation.NewService(keyrotation.NewDirStore([]string{t.TempDir()}), nil) + require.NoError(t, svc.Dir.Write("demo", monthly(true))) + + section, err := keyrotation.DryRunSection("demo", monthly(true)) + require.NoError(t, err) + + t.Run("to an existing directory", func(t *testing.T) { + out := t.TempDir() + pushConfig.DryRun, pushConfig.Out = true, out + + require.NoError(t, pushKeyRotation(&cobra.Command{}, svc, "demo")) + + got, err := keyrotation.NewDirStore([]string{out}).Read("demo") + require.NoError(t, err) + require.Equal(t, monthly(true), got) + }) + + t.Run("to a file, replacing it", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + pushConfig.DryRun, pushConfig.Out = true, out + + require.NoError(t, pushKeyRotation(&cobra.Command{}, svc, "demo")) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, string(section), string(bts)) + }) +} diff --git a/cmd/push.go b/cmd/push.go index 52ea72d..cb107f5 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -75,7 +75,7 @@ Examples: } if handle { - return pushKeyRotations(cmd.Context(), svc, krs) + return pushKeyRotations(cmd.Context(), svc, krs, !keyRotationOnly(pushConfig.Filters)) } return nil From 6caa0fe935047b995d24703f642c516da0de5fbb Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:37:15 +0200 Subject: [PATCH 26/38] feat(keyrotation): skip pushing key rotation uses the server already has 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. --- README.md | 8 +++- internal/cac/keyrotation/diff_test.go | 14 ++++-- internal/cac/keyrotation/service.go | 40 +++++++++++++++-- internal/cac/keyrotation/service_test.go | 56 ++++++++++++++++++++---- 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ef9543a..2235d48 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,9 @@ 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, @@ -339,8 +342,9 @@ features: - `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 PUT 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 is replaced. + `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 after the configuration. With `--out -` (stdout) or a file, each workspace is a separate YAML document: diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go index 087feaf..d06a0ad 100644 --- a/internal/cac/keyrotation/diff_test.go +++ b/internal/cac/keyrotation/diff_test.go @@ -38,11 +38,13 @@ func TestTreeDiff(t *testing.T) { }) } -// fakeRemote serves configs per workspace and records writes. +// fakeRemote serves configs per workspace and records writes. errs fail reads and writes, writeErrs +// writes only. type fakeRemote struct { - configs map[string]*keyrotation.Config - errs map[string]error - writes []keyrotation.WorkspaceConfig + configs map[string]*keyrotation.Config + errs map[string]error + writeErrs map[string]error + writes []keyrotation.WorkspaceConfig } func (f *fakeRemote) Read(_ context.Context, wid string) (*keyrotation.Config, error) { @@ -54,6 +56,10 @@ func (f *fakeRemote) Write(_ context.Context, wid string, cfg *keyrotation.Confi return err } + if err := f.writeErrs[wid]; err != nil { + return err + } + f.writes = append(f.writes, keyrotation.WorkspaceConfig{WID: wid, Config: cfg}) return nil diff --git a/internal/cac/keyrotation/service.go b/internal/cac/keyrotation/service.go index f8fb506..987eac0 100644 --- a/internal/cac/keyrotation/service.go +++ b/internal/cac/keyrotation/service.go @@ -114,19 +114,53 @@ func (s *Service) ReadAll(wids []string) ([]WorkspaceConfig, error) { return out, nil } -// Push writes the key rotation settings of each workspace to the server. +// Push writes the key rotation settings of each workspace to the server. A use whose enabled and cron +// already match the server is skipped, unless it sets starting_from: the server never returns it, so +// such a use is always written in case it reschedules the rotation. func (s *Service) Push(ctx context.Context, items []WorkspaceConfig) error { for _, item := range items { - if err := s.Remote.Write(ctx, item.WID, item.Config); err != nil { + remote, err := s.Remote.Read(ctx, item.WID) + if err != nil { + return err + } + + if remote == nil { + remote = &Config{} + } + + changed := &Config{ + Sig: changedRotation(item.WID, UseSig, item.Config.Sig, remote.Sig), + Enc: changedRotation(item.WID, UseEnc, item.Config.Enc, remote.Enc), + } + + if len(changed.Uses()) == 0 { + continue + } + + if err = s.Remote.Write(ctx, item.WID, changed); err != nil { return err } - slog.Info("Pushed key rotation", "workspace", item.WID, "uses", len(item.Config.Uses())) + slog.Info("Pushed key rotation", "workspace", item.WID, "uses", len(changed.Uses())) } return nil } +// changedRotation returns the local rotation of the use unless the server already has it. +func changedRotation(wid, use string, local, remote *Rotation) *Rotation { + if local == nil { + return nil + } + + if local.StartingFrom == nil && remote != nil && local.Enabled == remote.Enabled && local.Cron == remote.Cron { + slog.Info("Key rotation unchanged", "workspace", wid, "use", use) + return nil + } + + return local +} + // Diff renders the difference between the local and remote key rotation settings of the workspace. func (s *Service) Diff(ctx context.Context, wid string, colors bool) (string, error) { var ( diff --git a/internal/cac/keyrotation/service_test.go b/internal/cac/keyrotation/service_test.go index c26b610..2458588 100644 --- a/internal/cac/keyrotation/service_test.go +++ b/internal/cac/keyrotation/service_test.go @@ -3,8 +3,10 @@ package keyrotation_test import ( "context" "testing" + "time" "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/go-openapi/strfmt" "github.com/pkg/errors" "github.com/stretchr/testify/require" ) @@ -51,16 +53,54 @@ func TestServiceReadAll(t *testing.T) { } func TestServicePush(t *testing.T) { - cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - items := []keyrotation.WorkspaceConfig{{WID: "a", Config: cfg}, {WID: "b", Config: cfg}} + monthly := func(enabled bool) *keyrotation.Rotation { + return &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"} + } + startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + + t.Run("writes only the uses that differ from the server", func(t *testing.T) { + remote := &fakeRemote{configs: map[string]*keyrotation.Config{ + "same": {Sig: monthly(true), Enc: monthly(false)}, + "enabled": {Sig: monthly(false), Enc: monthly(false)}, + "cron": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "@weekly"}}, + }} + + require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), []keyrotation.WorkspaceConfig{ + {WID: "same", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, + {WID: "enabled", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, + {WID: "cron", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "unconfigured", Config: &keyrotation.Config{Enc: monthly(false)}}, + })) + require.Equal(t, []keyrotation.WorkspaceConfig{ + {WID: "enabled", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "cron", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "unconfigured", Config: &keyrotation.Config{Enc: monthly(false)}}, + }, remote.writes) + }) + + t.Run("always writes a use with starting_from, which the server never returns", func(t *testing.T) { + local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}} + remote := &fakeRemote{configs: map[string]*keyrotation.Config{"a": {Sig: monthly(true)}}} + + require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), []keyrotation.WorkspaceConfig{{WID: "a", Config: local}})) + require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: local}}, remote.writes) + }) - remote := &fakeRemote{} - require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items)) - require.Equal(t, items, remote.writes) + t.Run("a read error fails before anything is written", func(t *testing.T) { + remote := &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} - remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} - require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") - require.Empty(t, remote.writes) + require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") + require.Empty(t, remote.writes) + }) + + t.Run("a write error fails", func(t *testing.T) { + remote := &fakeRemote{writeErrs: map[string]error{"a": errors.New("boom")}} + items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} + + require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") + require.Empty(t, remote.writes) + }) } func TestServiceDiff(t *testing.T) { From 795fb4ee399d78b606d6578e616ef602d20afad4 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 11:37:29 +0200 Subject: [PATCH 27/38] docs: explain what the manage_servers scope grants --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2235d48..248d4d5 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,10 @@ cac --config ./cac.yaml --profile dev --workspace demo diff key-rotation 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). +`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: From 365876001e72bfef465bfb6ec69589fc27607091 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:53:55 +0200 Subject: [PATCH 28/38] refactor(cmd): decide the key rotation scope of a run once 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. --- cmd/diff.go | 11 ++++---- cmd/key_rotation_feature.go | 48 +++++++++++++++++++------------- cmd/key_rotation_feature_test.go | 43 ++++++++++++---------------- cmd/pull.go | 14 +++++----- cmd/push.go | 20 ++++++------- 5 files changed, 69 insertions(+), 67 deletions(-) diff --git a/cmd/diff.go b/cmd/diff.go index 5a29460..88e5c22 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -89,16 +89,15 @@ func runDiff(ctx context.Context, app *cac.Application) (string, error) { result string source api.Source target api.Source - handle bool + s scope err error ) - if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { return "", err } - // --filter key_rotation alone compares key rotation only, without reading the configuration - if !keyRotationOnly(diffConfig.Filters) { + if s.regular { if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { return "", err } @@ -120,8 +119,8 @@ func runDiff(ctx context.Context, app *cac.Application) (string, error) { } } - if handle { - return diffKeyRotations(ctx, app, result) + if s.keyRotation { + return diffKeyRotations(ctx, app, s, result) } return result, nil diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index be8cf73..13504f5 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -21,22 +21,32 @@ import ( // keyRotationFilter is the --filter value that selects key rotation in plain pull, push and diff. const keyRotationFilter = "key_rotation" -// keyRotationRequested reports whether a plain pull, push or diff also handles key rotation: the -// feature is enabled and --filter is empty or names key_rotation. The regular pipeline ignores the -// key_rotation filter, as it does any filter naming no configuration key. -func keyRotationRequested(enabled bool, filters []string) (bool, error) { - if !enabled && slices.Contains(filters, keyRotationFilter) { - return false, errors.New("--filter key_rotation requires features.key_rotation: true") +// scope is what a pull, push or diff run handles. +type scope struct { + regular bool // the workspace or tenant configuration + keyRotation bool // workspaces//key_rotation.yaml + explicit bool // key rotation is asked for rather than included by features.key_rotation +} + +// scopeOf decides the scope of a plain pull, push or diff. With the feature on, key rotation is +// handled when --filter is empty or names key_rotation. --filter key_rotation alone skips the regular +// configuration, whose empty result is not a no-op: a pull rewrites server.yaml, an import resets the +// workspace settings, and a diff still reads and renders the whole configuration. Otherwise the +// regular pipeline ignores the key_rotation filter, as it does any filter naming no configuration key. +func scopeOf(enabled bool, filters []string) (scope, error) { + named := slices.Contains(filters, keyRotationFilter) + + if named && !enabled { + return scope{}, errors.New("--filter key_rotation requires features.key_rotation: true") } - return enabled && (len(filters) == 0 || slices.Contains(filters, keyRotationFilter)), nil -} + only := named && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) -// keyRotationOnly reports whether --filter selects key rotation alone. Pull, push and diff then skip -// the regular pipeline, since its empty result is not a no-op: a pull rewrites server.yaml, an import -// resets the workspace settings, and a diff still reads and renders the whole configuration. -func keyRotationOnly(filters []string) bool { - return len(filters) > 0 && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) + return scope{ + regular: !only, + keyRotation: enabled && (len(filters) == 0 || named), + explicit: named, + }, nil } // pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant @@ -203,7 +213,7 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e // workspace the source has, to the result of a plain diff. Target uses absent from the source are // ignored, as in diff key-rotation. A side whose profile does not enable key rotation fails the diff // when --filter names key_rotation, and otherwise leaves a marker line in its place. -func diffKeyRotations(ctx context.Context, app *cac.Application, result string) (string, error) { +func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, result string) (string, error) { var ( wids = []string{rootConfig.Workspace} source, target *keyrotation.Side @@ -211,11 +221,11 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) ) if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { - return skipDisabledSide(result, err) + return skipDisabledSide(s, result, err) } if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { - return skipDisabledSide(result, err) + return skipDisabledSide(s, result, err) } if rootConfig.Tenant { @@ -228,11 +238,11 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, result string) } // skipDisabledSide returns err, except for a side whose profile does not enable key rotation while -// --filter does not name key_rotation: the diff then gets a marker line instead of the section. -func skipDisabledSide(result string, err error) (string, error) { +// key rotation is not asked for explicitly: the diff then gets a marker line instead of the section. +func skipDisabledSide(s scope, result string, err error) (string, error) { var disabled *keyRotationDisabledError - if !errors.As(err, &disabled) || slices.Contains(diffConfig.Filters, keyRotationFilter) { + if !errors.As(err, &disabled) || s.explicit { return result, err } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 60fe305..2f8c86b 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -14,27 +14,28 @@ import ( "github.com/stretchr/testify/require" ) -func TestKeyRotationRequested(t *testing.T) { +func TestScopeOf(t *testing.T) { tcs := []struct { - name string - enabled bool - filters []string - requested bool - err string + name string + enabled bool + filters []string + want scope + err string }{ - {name: "disabled without filters"}, + {name: "disabled without filters", want: scope{regular: true}}, {name: "disabled with key_rotation", filters: []string{"key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, - {name: "disabled with other filters", filters: []string{"clients"}}, + {name: "disabled with other filters", filters: []string{"clients"}, want: scope{regular: true}}, {name: "disabled with mixed filters", filters: []string{"clients", "key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, - {name: "enabled without filters", enabled: true, requested: true}, - {name: "enabled with key_rotation", enabled: true, filters: []string{"key_rotation"}, requested: true}, - {name: "enabled with other filters", enabled: true, filters: []string{"clients"}}, - {name: "enabled with mixed filters", enabled: true, filters: []string{"key_rotation", "clients"}, requested: true}, + {name: "enabled without filters", enabled: true, want: scope{regular: true, keyRotation: true}}, + {name: "enabled with key_rotation", enabled: true, filters: []string{"key_rotation"}, want: scope{keyRotation: true, explicit: true}}, + {name: "enabled with key_rotation twice", enabled: true, filters: []string{"key_rotation", "key_rotation"}, want: scope{keyRotation: true, explicit: true}}, + {name: "enabled with other filters", enabled: true, filters: []string{"clients"}, want: scope{regular: true}}, + {name: "enabled with mixed filters", enabled: true, filters: []string{"key_rotation", "clients"}, want: scope{regular: true, keyRotation: true, explicit: true}}, } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - requested, err := keyRotationRequested(tc.enabled, tc.filters) + got, err := scopeOf(tc.enabled, tc.filters) if tc.err != "" { require.EqualError(t, err, tc.err) @@ -42,19 +43,11 @@ func TestKeyRotationRequested(t *testing.T) { } require.NoError(t, err) - require.Equal(t, tc.requested, requested) + require.Equal(t, tc.want, got) }) } } -func TestKeyRotationOnly(t *testing.T) { - require.False(t, keyRotationOnly(nil)) - require.False(t, keyRotationOnly([]string{"clients"})) - require.False(t, keyRotationOnly([]string{"key_rotation", "clients"})) - require.True(t, keyRotationOnly([]string{"key_rotation"})) - require.True(t, keyRotationOnly([]string{"key_rotation", "key_rotation"})) -} - // keyRotationApp builds an application whose selected profile keeps its files in a temp dir and has // key rotation on, next to the given other profile, so that diffs between local sides need no server. func keyRotationApp(t *testing.T, other config.Configuration) *cac.Application { @@ -120,7 +113,7 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { setDiffConfig(t, sides[0], sides[1]) - result, err := diffKeyRotations(context.Background(), app, "normal") + result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "normal") require.NoError(t, err, sides) require.Equal(t, "normal\n--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) } @@ -129,7 +122,7 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { t.Run("--filter key_rotation fails", func(t *testing.T) { setDiffConfig(t, "local", "other@remote", "clients", "key_rotation") - _, err := diffKeyRotations(context.Background(), app, "normal") + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "normal") require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") }) @@ -137,7 +130,7 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { app := keyRotationApp(t, config.Configuration{Features: &config.Features{KeyRotation: true}, Client: client.DefaultConfig()}) setDiffConfig(t, "local", "other@remote") - _, err := diffKeyRotations(context.Background(), app, "normal") + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "normal") require.EqualError(t, err, "profile other: features.key_rotation requires the manage_servers scope in client.scopes") }) } diff --git a/cmd/pull.go b/cmd/pull.go index 1af0736..7464598 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -29,21 +29,21 @@ Examples: cac --config ./cac.yaml --profile dev --tenant pull`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - handle bool - err error + app *cac.Application + data models.Rfc7396PatchOperation + s scope + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { return err } - if !keyRotationOnly(pullConfig.Filters) { + if s.regular { slog. With("workspace", rootConfig.Workspace). With("tenant", rootConfig.Tenant). @@ -65,7 +65,7 @@ Examples: } } - if handle { + if s.keyRotation { return pullKeyRotations(cmd.Context(), app, data) } diff --git a/cmd/push.go b/cmd/push.go index cb107f5..666ba2a 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -40,23 +40,23 @@ Examples: cac --config ./cac.yaml --profile dev --workspace demo push --method patch --filter clients,services`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - svc *keyrotation.Service - krs []keyrotation.WorkspaceConfig - handle bool - err error + app *cac.Application + svc *keyrotation.Service + krs []keyrotation.WorkspaceConfig + s scope + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if handle, err = keyRotationRequested(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { return err } // key rotation is read and validated up front, so an invalid file fails before anything is pushed - if handle { + if s.keyRotation { if svc, err = keyRotationService(app); err != nil { return err } @@ -68,14 +68,14 @@ Examples: warnIgnoredKeyRotation(app) } - if !keyRotationOnly(pushConfig.Filters) { + if s.regular { if err = pushConfiguration(cmd.Context(), app, pushConfig.Filters); err != nil { return err } } - if handle { - return pushKeyRotations(cmd.Context(), svc, krs, !keyRotationOnly(pushConfig.Filters)) + if s.keyRotation { + return pushKeyRotations(cmd.Context(), svc, krs, s.regular) } return nil From 742a4bef7d8eb3492835eac4faf999fbae691139 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:54:41 +0200 Subject: [PATCH 29/38] refactor(keyrotation): render the key rotation diff on its own 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. --- cmd/diff.go | 11 ++++-- cmd/key_rotation_feature.go | 22 +++++------ cmd/key_rotation_feature_test.go | 8 ++-- internal/cac/keyrotation/diff.go | 37 ++++++------------- .../cac/keyrotation/diff_internal_test.go | 8 ---- internal/cac/keyrotation/diff_test.go | 14 +++---- 6 files changed, 40 insertions(+), 60 deletions(-) diff --git a/cmd/diff.go b/cmd/diff.go index 88e5c22..813979a 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -82,11 +82,11 @@ Examples: } ) -// runDiff compares the configuration of the source and target, unless --filter selects key rotation -// alone, then appends the key rotation diff when it is requested. +// runDiff compares the configuration and the key rotation of the source and target, as scoped. func runDiff(ctx context.Context, app *cac.Application) (string, error) { var ( result string + kr string source api.Source target api.Source s scope @@ -120,10 +120,13 @@ func runDiff(ctx context.Context, app *cac.Application) (string, error) { } if s.keyRotation { - return diffKeyRotations(ctx, app, s, result) + if kr, err = diffKeyRotations(ctx, app, s); err != nil { + return "", err + } } - return result, nil + // 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 { diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 13504f5..b8f5c95 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -209,11 +209,11 @@ func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, e return side, nil } -// diffKeyRotations appends the key rotation diff of the selected workspace, or in tenant mode of every -// workspace the source has, to the result of a plain diff. Target uses absent from the source are -// ignored, as in diff key-rotation. A side whose profile does not enable key rotation fails the diff -// when --filter names key_rotation, and otherwise leaves a marker line in its place. -func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, result string) (string, error) { +// diffKeyRotations renders the key rotation diff of the selected workspace, or in tenant mode of every +// workspace the source has. Target uses absent from the source are ignored, as in diff key-rotation. A +// side whose profile does not enable key rotation fails the diff when key rotation is asked for +// explicitly, and otherwise renders a marker line instead. +func diffKeyRotations(ctx context.Context, app *cac.Application, s scope) (string, error) { var ( wids = []string{rootConfig.Workspace} source, target *keyrotation.Side @@ -221,11 +221,11 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, result ) if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { - return skipDisabledSide(s, result, err) + return skipDisabledSide(s, err) } if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { - return skipDisabledSide(s, result, err) + return skipDisabledSide(s, err) } if rootConfig.Tenant { @@ -234,17 +234,17 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, result } } - return keyrotation.AppendDiff(ctx, result, source, target, wids, diffConfig.Colors) + return keyrotation.Diff(ctx, source, target, wids, diffConfig.Colors) } // skipDisabledSide returns err, except for a side whose profile does not enable key rotation while // key rotation is not asked for explicitly: the diff then gets a marker line instead of the section. -func skipDisabledSide(s scope, result string, err error) (string, error) { +func skipDisabledSide(s scope, err error) (string, error) { var disabled *keyRotationDisabledError if !errors.As(err, &disabled) || s.explicit { - return result, err + return "", err } - return keyrotation.AppendSkipped(result, fmt.Sprintf("profile %s has features.key_rotation disabled", disabled.profile)), nil + return keyrotation.Skipped(fmt.Sprintf("profile %s has features.key_rotation disabled", disabled.profile)), nil } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 2f8c86b..8cc02fa 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -113,16 +113,16 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { setDiffConfig(t, sides[0], sides[1]) - result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "normal") + result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}) require.NoError(t, err, sides) - require.Equal(t, "normal\n--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) + require.Equal(t, "--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) } }) t.Run("--filter key_rotation fails", func(t *testing.T) { setDiffConfig(t, "local", "other@remote", "clients", "key_rotation") - _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "normal") + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}) require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") }) @@ -130,7 +130,7 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { app := keyRotationApp(t, config.Configuration{Features: &config.Features{KeyRotation: true}, Client: client.DefaultConfig()}) setDiffConfig(t, "local", "other@remote") - _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "normal") + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}) require.EqualError(t, err, "profile other: features.key_rotation requires the manage_servers scope in client.scopes") }) } diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go index 8f70cea..bccc5b7 100644 --- a/internal/cac/keyrotation/diff.go +++ b/internal/cac/keyrotation/diff.go @@ -123,9 +123,11 @@ func (s *Side) Workspaces(ctx context.Context) ([]string, error) { return slices.Compact(wids), nil } -// AppendDiff appends to result the key rotation diff of each workspace, each non-empty one under a +// Diff renders the key rotation diff of each workspace, each non-empty one under a // "--- key_rotation ()" line. Target uses absent from the source are ignored. -func AppendDiff(ctx context.Context, result string, source, target *Side, wids []string, colors bool) (string, error) { +func Diff(ctx context.Context, source, target *Side, wids []string, colors bool) (string, error) { + var out strings.Builder + for _, wid := range wids { var ( sourceCfg, targetCfg *Config @@ -145,31 +147,16 @@ func AppendDiff(ctx context.Context, result string, source, target *Side, wids [ return "", err } - result = appendSection(result, wid, d) - } - - return result, nil -} - -// appendSection appends a non-empty key rotation diff under a "--- key_rotation ()" line. -func appendSection(result, wid, d string) string { - if d == "" { - return result + if d != "" { + fmt.Fprintf(&out, "--- key_rotation (%s)\n%s", wid, d) + } } - return appendLines(result, fmt.Sprintf("--- key_rotation (%s)\n", wid)+d) -} - -// AppendSkipped appends a "--- key_rotation skipped: " line, so that key rotation that could -// not be compared shows up in the diff rather than being silently left out. -func AppendSkipped(result, reason string) string { - return appendLines(result, fmt.Sprintf("--- key_rotation skipped: %s\n", reason)) + return out.String(), nil } -func appendLines(result, lines string) string { - if result != "" && !strings.HasSuffix(result, "\n") { - result += "\n" - } - - return result + lines +// Skipped renders a "--- key_rotation skipped: " line, so that key rotation that could not be +// compared shows up in the diff rather than being silently left out. +func Skipped(reason string) string { + return fmt.Sprintf("--- key_rotation skipped: %s\n", reason) } diff --git a/internal/cac/keyrotation/diff_internal_test.go b/internal/cac/keyrotation/diff_internal_test.go index 83c951d..d650c29 100644 --- a/internal/cac/keyrotation/diff_internal_test.go +++ b/internal/cac/keyrotation/diff_internal_test.go @@ -34,11 +34,3 @@ func TestDiffConfigs(t *testing.T) { require.Equal(t, &Config{Sig: &Rotation{Enabled: false, Cron: "0 0 1 * *"}}, target) }) } - -func TestAppendSection(t *testing.T) { - require.Equal(t, "normal\n", appendSection("normal\n", "demo", "")) - require.Equal(t, "--- key_rotation (demo)\nkr\n", appendSection("", "demo", "kr\n")) - require.Equal(t, "normal\n--- key_rotation (demo)\nkr\n", appendSection("normal", "demo", "kr\n")) - require.Equal(t, "normal\n--- key_rotation (a)\nkr\n--- key_rotation (b)\nkr\n", - appendSection(appendSection("normal\n", "a", "kr\n"), "b", "kr\n")) -} diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go index d06a0ad..34e2c53 100644 --- a/internal/cac/keyrotation/diff_test.go +++ b/internal/cac/keyrotation/diff_test.go @@ -127,7 +127,7 @@ func TestSideWorkspaces(t *testing.T) { require.Equal(t, []string{"a", "b", "c"}, wids) } -func TestAppendDiff(t *testing.T) { +func TestDiff(t *testing.T) { dir := t.TempDir() require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("b", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) @@ -141,20 +141,18 @@ func TestAppendDiff(t *testing.T) { errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationNotFound()}, }} - result, err := keyrotation.AppendDiff(context.Background(), "normal", source, target, []string{"a", "b"}, false) + result, err := keyrotation.Diff(context.Background(), source, target, []string{"a", "b"}, false) require.NoError(t, err) - require.Equal(t, "normal\n--- key_rotation (a)\n"+treeDiff(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, nil), result) + require.Equal(t, "--- key_rotation (a)\n"+treeDiff(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, nil), result) - _, err = keyrotation.AppendDiff(context.Background(), "", source, &keyrotation.Side{Remote: &fakeRemote{ + _, err = keyrotation.Diff(context.Background(), source, &keyrotation.Side{Remote: &fakeRemote{ errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationForbidden()}, }}, []string{"a"}, false) require.Error(t, err) } -func TestAppendSkipped(t *testing.T) { - require.Equal(t, "--- key_rotation skipped: why\n", keyrotation.AppendSkipped("", "why")) - require.Equal(t, "normal\n--- key_rotation skipped: why\n", keyrotation.AppendSkipped("normal", "why")) - require.Equal(t, "normal\n--- key_rotation skipped: why\n", keyrotation.AppendSkipped("normal\n", "why")) +func TestSkipped(t *testing.T) { + require.Equal(t, "--- key_rotation skipped: why\n", keyrotation.Skipped("why")) } type fakeSource struct { From 7b13f57356d0a341a709e36baee99140668df8d6 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:56:21 +0200 Subject: [PATCH 30/38] refactor(diff): resolve, gate and build key rotation sides separately 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. --- cmd/key_rotation_feature.go | 150 +++++++++++++++++------------------- internal/cac/app.go | 17 +++- internal/cac/app_test.go | 31 +++++++- 3 files changed, 116 insertions(+), 82 deletions(-) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index b8f5c95..9db4d82 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -7,12 +7,12 @@ import ( "fmt" "os" "slices" - "strings" "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/pkg/errors" "golang.org/x/exp/slog" @@ -49,6 +49,15 @@ func scopeOf(enabled bool, filters []string) (scope, error) { }, nil } +// keyRotationDir returns the key rotation files of the profile. +func keyRotationDir(conf *config.Configuration) (*keyrotation.DirStore, error) { + if conf.Storage == nil || len(conf.Storage.DirPath) == 0 { + return nil, errors.Errorf("no storage directories configured for profile %s", conf.Name) + } + + return keyrotation.NewDirStore(conf.Storage.DirPath), nil +} + // pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant // mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { @@ -137,114 +146,99 @@ func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []key return svc.Push(ctx, items) } -// keyRotationDisabledError reports a diff side that needs the server of a profile that does not -// enable key rotation. -type keyRotationDisabledError struct { - profile string -} - -func (e *keyRotationDisabledError) Error() string { - return fmt.Sprintf("profile %s cannot diff key rotation: features.key_rotation is not enabled", e.profile) +// sideSpec is a resolved --source or --target value of a diff. +type sideSpec struct { + conf *config.Configuration + source api.SourceType } -// newKeyRotationSide resolves a --source or --target value, "[profile@]source-type", like PickSource: -// local reads the profile's storage dirs, remote the profile's server, and merged the local file, -// falling back to the server when the workspace has none. It returns a *keyRotationDisabledError when -// the side needs the server but its profile does not enable key rotation, and fails when the profile -// enables it without the manage_servers scope. -func newKeyRotationSide(app *cac.Application, spec string) (*keyrotation.Side, error) { - var ( - side = &keyrotation.Side{} - profile, sourceS, found = strings.Cut(spec, "@") - sourceType api.SourceType - err error - ) - - if !found { - sourceS, profile = profile, app.Config.Name +// gate decides whether the key rotation of the side can be compared. A side reading the server of a +// profile other than the selected one needs that profile to enable key rotation and to be valid: the +// selected profile was checked when the run was scoped and validated by InitApp. When the profile does +// not enable key rotation, gate fails if key rotation was asked for explicitly and otherwise returns +// the marker the diff shows instead. +func (sp sideSpec) gate(app *cac.Application, s scope) (skipped string, err error) { + if sp.source == api.SourceLocal || sp.conf.Name == app.Config.Name { + return "", nil } - conf, err := app.RootConfig.ForProfile(profile) - if err != nil { - return nil, err - } + if !sp.conf.KeyRotationEnabled() { + if s.explicit { + return "", errors.Errorf("profile %s cannot diff key rotation: features.key_rotation is not enabled", sp.conf.Name) + } - if sourceType, err = api.SourceFromString(sourceS); err != nil { - return nil, err + return keyrotation.Skipped(fmt.Sprintf("profile %s has features.key_rotation disabled", sp.conf.Name)), nil } - if sourceType != api.SourceLocal { - if !conf.KeyRotationEnabled() { - return nil, &keyRotationDisabledError{profile: profile} - } + return "", errors.Wrapf(sp.conf.Validate(), "profile %s", sp.conf.Name) +} - // InitApp validated the selected profile only - if err = conf.Validate(); err != nil { - return nil, errors.Wrapf(err, "profile %s", profile) - } - } +// side builds the key rotation side: local reads the profile's storage dirs, remote the profile's +// server, and merged the local file, falling back to the server when the workspace has none. +func (sp sideSpec) side(app *cac.Application) (*keyrotation.Side, error) { + var ( + side = &keyrotation.Side{} + err error + ) - if sourceType == api.SourceLocal || sourceType == api.SourceMerged { - if conf.Storage == nil || len(conf.Storage.DirPath) == 0 { - return nil, errors.Errorf("no storage directories configured for profile %s", profile) + if sp.source != api.SourceRemote { + if side.Local, err = keyRotationDir(sp.conf); err != nil { + return nil, err } + } - side.Local = keyrotation.NewDirStore(conf.Storage.DirPath) + if sp.source == api.SourceLocal { + return side, nil } - if sourceType == api.SourceRemote || sourceType == api.SourceMerged { - // the selected profile's client is already initialized; in tenant mode it is a tenant client - if conf.Name == app.Config.Name { - side.Remote, side.Tenant = app.KeyRotation, app.Client - } else { - c, err := client.InitClient(conf.Client) - if err != nil { - return nil, err - } + // the selected profile's client is already initialized; in tenant mode it is a tenant client + if sp.conf.Name == app.Config.Name { + side.Remote, side.Tenant = app.KeyRotation, app.Client + return side, nil + } - side.Remote, side.Tenant = c.KeyRotationStore(), c.Tenant() - } + c, err := client.InitClient(sp.conf.Client) + if err != nil { + return nil, err } + side.Remote, side.Tenant = c.KeyRotationStore(), c.Tenant() + return side, nil } // diffKeyRotations renders the key rotation diff of the selected workspace, or in tenant mode of every -// workspace the source has. Target uses absent from the source are ignored, as in diff key-rotation. A -// side whose profile does not enable key rotation fails the diff when key rotation is asked for -// explicitly, and otherwise renders a marker line instead. +// workspace the source has. Target uses absent from the source are ignored, as in diff key-rotation. func diffKeyRotations(ctx context.Context, app *cac.Application, s scope) (string, error) { var ( - wids = []string{rootConfig.Workspace} - source, target *keyrotation.Side - err error + wids = []string{rootConfig.Workspace} + specs [2]sideSpec + sides [2]*keyrotation.Side + err error ) - if source, err = newKeyRotationSide(app, diffConfig.Source); err != nil { - return skipDisabledSide(s, err) - } + // both sides are gated before either is built, so a skipped diff needs no storage or client + for i, raw := range []string{diffConfig.Source, diffConfig.Target} { + if specs[i].conf, specs[i].source, err = app.ResolveSource(raw); err != nil { + return "", err + } - if target, err = newKeyRotationSide(app, diffConfig.Target); err != nil { - return skipDisabledSide(s, err) + if skipped, err := specs[i].gate(app, s); err != nil || skipped != "" { + return skipped, err + } } - if rootConfig.Tenant { - if wids, err = source.Workspaces(ctx); err != nil { + for i := range specs { + if sides[i], err = specs[i].side(app); err != nil { return "", err } } - return keyrotation.Diff(ctx, source, target, wids, diffConfig.Colors) -} - -// skipDisabledSide returns err, except for a side whose profile does not enable key rotation while -// key rotation is not asked for explicitly: the diff then gets a marker line instead of the section. -func skipDisabledSide(s scope, err error) (string, error) { - var disabled *keyRotationDisabledError - - if !errors.As(err, &disabled) || s.explicit { - return "", err + if rootConfig.Tenant { + if wids, err = sides[0].Workspaces(ctx); err != nil { + return "", err + } } - return keyrotation.Skipped(fmt.Sprintf("profile %s has features.key_rotation disabled", disabled.profile)), nil + return keyrotation.Diff(ctx, sides[0], sides[1], wids, diffConfig.Colors) } diff --git a/internal/cac/app.go b/internal/cac/app.go index c192a27..5efbfa6 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -75,13 +75,15 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, return app, nil } -func (a *Application) PickSource(source string, tenant bool) (api.Source, error) { +// ResolveSource resolves a --source or --target value, "[profile@]source-type", to the profile's +// configuration and the source type. Without a profile it refers to the selected one. +func (a *Application) ResolveSource(spec string) (*config.Configuration, api.SourceType, error) { var ( conf *config.Configuration sourceType api.SourceType err error - profile, sourceS, found = strings.Cut(source, "@") + profile, sourceS, found = strings.Cut(spec, "@") ) if !found { @@ -92,10 +94,19 @@ func (a *Application) PickSource(source string, tenant bool) (api.Source, error) } if conf, err = a.RootConfig.ForProfile(profile); err != nil { - return nil, err + return nil, "", err } if sourceType, err = api.SourceFromString(sourceS); err != nil { + return nil, "", err + } + + return conf, sourceType, nil +} + +func (a *Application) PickSource(source string, tenant bool) (api.Source, error) { + conf, sourceType, err := a.ResolveSource(source) + if err != nil { return nil, err } diff --git a/internal/cac/app_test.go b/internal/cac/app_test.go index e47d1a3..53f14f0 100644 --- a/internal/cac/app_test.go +++ b/internal/cac/app_test.go @@ -1,9 +1,12 @@ package cac_test import ( + "testing" + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/config" "github.com/stretchr/testify/require" - "testing" ) func TestInitApp(t *testing.T) { @@ -17,3 +20,29 @@ func TestInitApp(t *testing.T) { require.NotNil(t, app.Storage) }) } + +func TestResolveSource(t *testing.T) { + app := &cac.Application{ + Config: &config.Configuration{Name: "dev"}, + RootConfig: &config.RootConfiguration{ + Default: config.Configuration{Name: "default"}, + Profiles: map[string]config.Configuration{"dev": {Name: "dev"}, "prod": {Name: "prod"}}, + }, + } + + conf, source, err := app.ResolveSource("remote") + require.NoError(t, err) + require.Equal(t, "dev", conf.Name) + require.Equal(t, api.SourceRemote, source) + + conf, source, err = app.ResolveSource("prod@merged") + require.NoError(t, err) + require.Equal(t, "prod", conf.Name) + require.Equal(t, api.SourceMerged, source) + + _, _, err = app.ResolveSource("missing@local") + require.ErrorIs(t, err, config.ErrUnknownProfile) + + _, _, err = app.ResolveSource("prod@unknown") + require.ErrorIs(t, err, api.ErrUnknownSource) +} From 1107aab0940b7378cfdbb5ad0796790f142e98a1 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:58:19 +0200 Subject: [PATCH 31/38] refactor(cmd): run the key-rotation subcommands through plain pull, push 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 ()" header, and a workspace missing remotely diffs as all-source instead of failing. Application.KeyRotation is typed as keyrotation.Remote. --- cmd/diff.go | 20 +++---- cmd/key_rotation.go | 76 +++++++----------------- cmd/key_rotation_feature.go | 15 ++++- cmd/key_rotation_feature_test.go | 44 ++++++++------ cmd/key_rotation_test.go | 61 ++++++++++++++----- cmd/pull.go | 73 +++++++++++++---------- cmd/push.go | 61 +++++++++++-------- internal/cac/app.go | 3 +- internal/cac/keyrotation/service.go | 18 ------ internal/cac/keyrotation/service_test.go | 22 ------- 10 files changed, 196 insertions(+), 197 deletions(-) diff --git a/cmd/diff.go b/cmd/diff.go index 813979a..cf0d606 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -43,6 +43,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { var ( app *cac.Application + s scope result string err error ) @@ -63,7 +64,11 @@ Examples: return err } - if result, err = runDiff(cmd.Context(), app); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { + return err + } + + if result, err = runDiff(cmd.Context(), app, s, diffConfig.Source, diffConfig.Target); err != nil { return err } @@ -83,26 +88,21 @@ Examples: ) // runDiff compares the configuration and the key rotation of the source and target, as scoped. -func runDiff(ctx context.Context, app *cac.Application) (string, error) { +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 - s scope err error ) - if s, err = scopeOf(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { - return "", err - } - if s.regular { - if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { + if source, err = app.PickSource(sourceSpec, rootConfig.Tenant); err != nil { return "", err } - if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); err != nil { + if target, err = app.PickSource(targetSpec, rootConfig.Tenant); err != nil { return "", err } @@ -120,7 +120,7 @@ func runDiff(ctx context.Context, app *cac.Application) (string, error) { } if s.keyRotation { - if kr, err = diffKeyRotations(ctx, app, s); err != nil { + if kr, err = diffKeyRotations(ctx, app, s, sourceSpec, targetSpec); err != nil { return "", err } } diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index 16a5de1..13feb9f 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -1,83 +1,47 @@ package cmd import ( + "context" + "github.com/cloudentity/cac/internal/cac" - "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/api" "github.com/pkg/errors" "github.com/spf13/cobra" - "golang.org/x/exp/slog" ) -// newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff. It operates on the -// workspace selected with the root --workspace flag. -func newKeyRotationCmd(short string, run func(cmd *cobra.Command, svc *keyrotation.Service, wid string) error) *cobra.Command { +// newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff: the parent command with +// --filter key_rotation, for the workspace selected with the root --workspace flag, whether or not +// features.key_rotation is enabled. +func newKeyRotationCmd(short string, run func(ctx context.Context, app *cac.Application, s scope) error) *cobra.Command { return &cobra.Command{ Use: "key-rotation", Short: short, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - var ( - app *cac.Application - svc *keyrotation.Service - err error - ) - if rootConfig.Tenant { return errors.New("--tenant does not apply to key-rotation; select a workspace with --workspace") } - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - if svc, err = keyRotationService(app); err != nil { + app, err := cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false) + if err != nil { return err } - return run(cmd, svc, rootConfig.Workspace) + return run(cmd.Context(), app, scope{keyRotation: true, explicit: true}) }, } } func init() { - pullCmd.AddCommand(newKeyRotationCmd("Pull the automatic key rotation settings of the workspace", pullKeyRotation)) - pushCmd.AddCommand(newKeyRotationCmd("Push the automatic key rotation settings of the workspace", pushKeyRotation)) - diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", diffKeyRotation)) -} - -// keyRotationService builds the key rotation service of the selected profile: its storage dirs and -// its server. -func keyRotationService(app *cac.Application) (*keyrotation.Service, error) { - if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { - return nil, errors.New("no storage directories configured for the selected profile") - } - - return keyrotation.NewService(keyrotation.NewDirStore(app.Config.Storage.DirPath), app.KeyRotation), nil -} - -func pullKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { - return svc.Pull(cmd.Context(), []string{wid}) -} - -func pushKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { - items, err := svc.ReadAll([]string{wid}) - if err != nil { - return err - } - - if len(items) == 0 { - slog.Info("No key rotation configuration to push", "workspace", wid) - return nil - } - - return pushKeyRotations(cmd.Context(), svc, items, false) -} - -func diffKeyRotation(cmd *cobra.Command, svc *keyrotation.Service, wid string) error { - result, err := svc.Diff(cmd.Context(), wid, diffConfig.Colors) - if err != nil { - return err - } + pullCmd.AddCommand(newKeyRotationCmd("Pull the automatic key rotation settings of the workspace", runPull)) + pushCmd.AddCommand(newKeyRotationCmd("Push the automatic key rotation settings of the workspace", runPush)) + diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", + func(ctx context.Context, app *cac.Application, s scope) error { + result, err := runDiff(ctx, app, s, string(api.SourceLocal), string(api.SourceRemote)) + if err != nil { + return err + } - return writeDiffResult(result) + return writeDiffResult(result) + })) } diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 9db4d82..d35c4e9 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -58,6 +58,17 @@ func keyRotationDir(conf *config.Configuration) (*keyrotation.DirStore, error) { return keyrotation.NewDirStore(conf.Storage.DirPath), nil } +// keyRotationService builds the key rotation service of the selected profile: its storage dirs and +// its server. +func keyRotationService(app *cac.Application) (*keyrotation.Service, error) { + dir, err := keyRotationDir(app.Config) + if err != nil { + return nil, err + } + + return keyrotation.NewService(dir, app.KeyRotation), nil +} + // pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant // mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { @@ -209,7 +220,7 @@ func (sp sideSpec) side(app *cac.Application) (*keyrotation.Side, error) { // diffKeyRotations renders the key rotation diff of the selected workspace, or in tenant mode of every // workspace the source has. Target uses absent from the source are ignored, as in diff key-rotation. -func diffKeyRotations(ctx context.Context, app *cac.Application, s scope) (string, error) { +func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, sourceSpec, targetSpec string) (string, error) { var ( wids = []string{rootConfig.Workspace} specs [2]sideSpec @@ -218,7 +229,7 @@ func diffKeyRotations(ctx context.Context, app *cac.Application, s scope) (strin ) // both sides are gated before either is built, so a skipped diff needs no storage or client - for i, raw := range []string{diffConfig.Source, diffConfig.Target} { + for i, raw := range []string{sourceSpec, targetSpec} { if specs[i].conf, specs[i].source, err = app.ResolveSource(raw); err != nil { return "", err } diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go index 8cc02fa..5178453 100644 --- a/cmd/key_rotation_feature_test.go +++ b/cmd/key_rotation_feature_test.go @@ -64,15 +64,27 @@ func keyRotationApp(t *testing.T, other config.Configuration) *cac.Application { } } -// setDiffConfig sets the diff and root flags for the duration of the test. -func setDiffConfig(t *testing.T, source, target string, filters ...string) { - savedDiff, savedRoot := diffConfig, rootConfig - t.Cleanup(func() { diffConfig, rootConfig = savedDiff, savedRoot }) +// setFlags selects the demo workspace and turns diff colors off, restoring the flags after the test. +func setFlags(t *testing.T) { + savedDiff, savedPush, savedRoot := diffConfig, pushConfig, rootConfig + t.Cleanup(func() { diffConfig, pushConfig, rootConfig = savedDiff, savedPush, savedRoot }) - diffConfig.Source, diffConfig.Target, diffConfig.Filters, diffConfig.Colors = source, target, filters, false + diffConfig.Colors = false rootConfig.Workspace, rootConfig.Tenant = "demo", false } +// fakeKeyRotation is a key rotation server holding the settings of each workspace. +type fakeKeyRotation map[string]*keyrotation.Config + +func (f fakeKeyRotation) Read(_ context.Context, wid string) (*keyrotation.Config, error) { + return f[wid], nil +} + +func (f fakeKeyRotation) Write(_ context.Context, wid string, cfg *keyrotation.Config) error { + f[wid] = cfg + return nil +} + func monthly(enabled bool) *keyrotation.Config { return &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"}} } @@ -94,15 +106,13 @@ func TestRunDiffKeyRotationOnly(t *testing.T) { want, err := keyrotation.TreeDiff(monthly(true), monthly(false), false) require.NoError(t, err) - setDiffConfig(t, "local", "other@local", "key_rotation") + setFlags(t) - result, err := runDiff(context.Background(), app) + result, err := runDiff(context.Background(), app, scope{keyRotation: true, explicit: true}, "local", "other@local") require.NoError(t, err) require.Equal(t, "--- key_rotation (demo)\n"+want, result) - setDiffConfig(t, "local", "other@local", "clients", "key_rotation") - - _, err = runDiff(context.Background(), app) + _, err = runDiff(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "local", "other@local") require.ErrorContains(t, err, "failed to render template") } @@ -110,27 +120,27 @@ func TestDiffKeyRotationsOtherProfile(t *testing.T) { app := keyRotationApp(t, config.Configuration{Features: &config.Features{}}) t.Run("without --filter the diff carries a marker", func(t *testing.T) { - for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { - setDiffConfig(t, sides[0], sides[1]) + setFlags(t) - result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}) + for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { + result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, sides[0], sides[1]) require.NoError(t, err, sides) require.Equal(t, "--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) } }) t.Run("--filter key_rotation fails", func(t *testing.T) { - setDiffConfig(t, "local", "other@remote", "clients", "key_rotation") + setFlags(t) - _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}) + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "local", "other@remote") require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") }) t.Run("an enabled profile without the manage_servers scope fails", func(t *testing.T) { app := keyRotationApp(t, config.Configuration{Features: &config.Features{KeyRotation: true}, Client: client.DefaultConfig()}) - setDiffConfig(t, "local", "other@remote") + setFlags(t) - _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}) + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "local", "other@remote") require.EqualError(t, err, "profile other: features.key_rotation requires the manage_servers scope in client.scopes") }) } diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index ab413f8..9b94ce0 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -1,13 +1,14 @@ package cmd import ( + "context" "io" "os" "path/filepath" "testing" + "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/spf13/cobra" "github.com/stretchr/testify/require" ) @@ -40,38 +41,70 @@ func TestKeyRotationFlagScoping(t *testing.T) { } } -// TestPushKeyRotationDryRun checks that push key-rotation --dry-run writes what plain push --dry-run -// writes for key rotation alone. -func TestPushKeyRotationDryRun(t *testing.T) { - saved := pushConfig - t.Cleanup(func() { pushConfig = saved }) +// TestKeyRotationSubcommands runs pull, push and diff with the scope of their key-rotation +// subcommands, with the feature off. +func TestKeyRotationSubcommands(t *testing.T) { + var ( + ctx = context.Background() + app = keyRotationApp(t, config.Configuration{}) + remote = fakeKeyRotation{"demo": monthly(false)} + dir = keyrotation.NewDirStore(app.Config.Storage.DirPath) + s = scope{keyRotation: true, explicit: true} + ) - svc := keyrotation.NewService(keyrotation.NewDirStore([]string{t.TempDir()}), nil) - require.NoError(t, svc.Dir.Write("demo", monthly(true))) + app.Config.Features.KeyRotation = false + app.KeyRotation = remote + setFlags(t) - section, err := keyrotation.DryRunSection("demo", monthly(true)) - require.NoError(t, err) + t.Run("pull", func(t *testing.T) { + require.NoError(t, runPull(ctx, app, s)) - t.Run("to an existing directory", func(t *testing.T) { + got, err := dir.Read("demo") + require.NoError(t, err) + require.Equal(t, monthly(false), got) + }) + + require.NoError(t, dir.Write("demo", monthly(true))) + + t.Run("diff", func(t *testing.T) { + want, err := keyrotation.TreeDiff(monthly(true), monthly(false), false) + require.NoError(t, err) + + result, err := runDiff(ctx, app, s, "local", "remote") + require.NoError(t, err) + require.Equal(t, "--- key_rotation (demo)\n"+want, result) + }) + + t.Run("push --dry-run to an existing directory", func(t *testing.T) { out := t.TempDir() pushConfig.DryRun, pushConfig.Out = true, out - require.NoError(t, pushKeyRotation(&cobra.Command{}, svc, "demo")) + require.NoError(t, runPush(ctx, app, s)) got, err := keyrotation.NewDirStore([]string{out}).Read("demo") require.NoError(t, err) require.Equal(t, monthly(true), got) }) - t.Run("to a file, replacing it", func(t *testing.T) { + t.Run("push --dry-run to a file, replacing it", func(t *testing.T) { out := filepath.Join(t.TempDir(), "out.yaml") require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) pushConfig.DryRun, pushConfig.Out = true, out - require.NoError(t, pushKeyRotation(&cobra.Command{}, svc, "demo")) + require.NoError(t, runPush(ctx, app, s)) + + section, err := keyrotation.DryRunSection("demo", monthly(true)) + require.NoError(t, err) bts, err := os.ReadFile(out) require.NoError(t, err) require.Equal(t, string(section), string(bts)) }) + + t.Run("push", func(t *testing.T) { + pushConfig.DryRun = false + + require.NoError(t, runPush(ctx, app, s)) + require.Equal(t, monthly(true), remote["demo"]) + }) } diff --git a/cmd/pull.go b/cmd/pull.go index 7464598..06a2651 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" @@ -29,10 +31,9 @@ Examples: cac --config ./cac.yaml --profile dev --tenant pull`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - s scope - err error + app *cac.Application + s scope + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { @@ -43,33 +44,7 @@ Examples: return err } - if s.regular { - slog. - With("workspace", rootConfig.Workspace). - With("tenant", rootConfig.Tenant). - With("filters", pullConfig.Filters). - With("config", rootConfig.ConfigPath). - Info("Pulling configuration") - - if data, err = app.Client.Read( - cmd.Context(), - api.WithWorkspace(rootConfig.Workspace), - api.WithSecrets(pullConfig.WithSecrets), - api.WithFilters(pullConfig.Filters), - ); err != nil { - return err - } - - if err = app.Storage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { - return err - } - } - - if s.keyRotation { - return pullKeyRotations(cmd.Context(), app, data) - } - - return nil + return runPull(cmd.Context(), app, s) }, } pullConfig struct { @@ -78,6 +53,42 @@ Examples: } ) +// runPull pulls the configuration and the key rotation, as scoped. +func runPull(ctx context.Context, app *cac.Application, s scope) error { + var ( + data models.Rfc7396PatchOperation + err error + ) + + if s.regular { + slog. + With("workspace", rootConfig.Workspace). + With("tenant", rootConfig.Tenant). + With("filters", pullConfig.Filters). + With("config", rootConfig.ConfigPath). + Info("Pulling configuration") + + if data, err = app.Client.Read( + ctx, + api.WithWorkspace(rootConfig.Workspace), + api.WithSecrets(pullConfig.WithSecrets), + api.WithFilters(pullConfig.Filters), + ); err != nil { + return err + } + + if err = app.Storage.Write(ctx, data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + return err + } + } + + if s.keyRotation { + return pullKeyRotations(ctx, app, data) + } + + return nil +} + func init() { pullCmd.Flags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. Example: --with-secrets`) diff --git a/cmd/push.go b/cmd/push.go index 666ba2a..1a408f4 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -41,8 +41,6 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { var ( app *cac.Application - svc *keyrotation.Service - krs []keyrotation.WorkspaceConfig s scope err error ) @@ -55,30 +53,7 @@ Examples: return err } - // key rotation is read and validated up front, so an invalid file fails before anything is pushed - if s.keyRotation { - if svc, err = keyRotationService(app); err != nil { - return err - } - - if krs, err = readKeyRotations(svc); err != nil { - return err - } - } else if !app.Config.KeyRotationEnabled() { - warnIgnoredKeyRotation(app) - } - - if s.regular { - if err = pushConfiguration(cmd.Context(), app, pushConfig.Filters); err != nil { - return err - } - } - - if s.keyRotation { - return pushKeyRotations(cmd.Context(), svc, krs, s.regular) - } - - return nil + return runPush(cmd.Context(), app, s) }, } pushConfig struct { @@ -91,6 +66,40 @@ Examples: } ) +// runPush pushes the configuration and the key rotation, as scoped. +func runPush(ctx context.Context, app *cac.Application, s scope) error { + var ( + svc *keyrotation.Service + krs []keyrotation.WorkspaceConfig + err error + ) + + // key rotation is read and validated up front, so an invalid file fails before anything is pushed + if s.keyRotation { + if svc, err = keyRotationService(app); err != nil { + return err + } + + if krs, err = readKeyRotations(svc); err != nil { + return err + } + } else if !app.Config.KeyRotationEnabled() { + warnIgnoredKeyRotation(app) + } + + if s.regular { + if err = pushConfiguration(ctx, app, pushConfig.Filters); err != nil { + return err + } + } + + if s.keyRotation { + return pushKeyRotations(ctx, svc, krs, s.regular) + } + + return nil +} + // pushConfiguration pushes the regular configuration, or writes it to --out with --dry-run. func pushConfiguration(ctx context.Context, app *cac.Application, filters []string) error { var ( diff --git a/internal/cac/app.go b/internal/cac/app.go index 5efbfa6..b2df264 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -5,6 +5,7 @@ import ( "github.com/cloudentity/cac/internal/cac/client" "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/data" + "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/logging" "github.com/cloudentity/cac/internal/cac/storage" "golang.org/x/exp/slog" @@ -17,7 +18,7 @@ type Application struct { Client api.Source Storage storage.Storage Validator data.ValidatorApi - KeyRotation *client.KeyRotationAPIStore + KeyRotation keyrotation.Remote } func InitApp(configPath string, profile string, tenant bool) (app *Application, err error) { diff --git a/internal/cac/keyrotation/service.go b/internal/cac/keyrotation/service.go index 987eac0..23de7db 100644 --- a/internal/cac/keyrotation/service.go +++ b/internal/cac/keyrotation/service.go @@ -161,24 +161,6 @@ func changedRotation(wid, use string, local, remote *Rotation) *Rotation { return local } -// Diff renders the difference between the local and remote key rotation settings of the workspace. -func (s *Service) Diff(ctx context.Context, wid string, colors bool) (string, error) { - var ( - local, remote *Config - err error - ) - - if local, err = s.Dir.Read(wid); err != nil { - return "", errors.Wrap(err, "failed to read local key rotation") - } - - if remote, err = s.Remote.Read(ctx, wid); err != nil { - return "", err - } - - return TreeDiff(local, remote, colors) -} - // TenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has // no servers, because the regular pull was skipped or filtered them out, they are read from source. func TenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc7396PatchOperation) ([]string, error) { diff --git a/internal/cac/keyrotation/service_test.go b/internal/cac/keyrotation/service_test.go index 2458588..93f3e9d 100644 --- a/internal/cac/keyrotation/service_test.go +++ b/internal/cac/keyrotation/service_test.go @@ -102,25 +102,3 @@ func TestServicePush(t *testing.T) { require.Empty(t, remote.writes) }) } - -func TestServiceDiff(t *testing.T) { - dir := t.TempDir() - local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{ - configs: map[string]*keyrotation.Config{"a": {Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}}, - }) - require.NoError(t, svc.Dir.Write("a", local)) - - result, err := svc.Diff(context.Background(), "a", false) - require.NoError(t, err) - require.Equal(t, treeDiff(t, - &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, - &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: false, Cron: "0 0 1 * *"}}, - ), result) - require.NotEmpty(t, result) - - // unlike a plain diff side, the subcommand does not treat a missing workspace as unconfigured - svc.Remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} - _, err = svc.Diff(context.Background(), "a", false) - require.EqualError(t, err, "boom") -} From 10b8a7a20281da1a47c14586f124e8986ffcc5d3 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:59:34 +0200 Subject: [PATCH 32/38] refactor(keyrotation): replace Service with package functions 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. --- cmd/key_rotation_feature.go | 36 +++--- cmd/push.go | 9 +- internal/cac/keyrotation/diff.go | 2 +- .../cac/keyrotation/{service.go => sync.go} | 108 ++++++------------ .../{service_test.go => sync_test.go} | 43 ++++--- 5 files changed, 72 insertions(+), 126 deletions(-) rename internal/cac/keyrotation/{service.go => sync.go} (56%) rename internal/cac/keyrotation/{service_test.go => sync_test.go} (66%) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index d35c4e9..5d4dfac 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -58,27 +58,16 @@ func keyRotationDir(conf *config.Configuration) (*keyrotation.DirStore, error) { return keyrotation.NewDirStore(conf.Storage.DirPath), nil } -// keyRotationService builds the key rotation service of the selected profile: its storage dirs and -// its server. -func keyRotationService(app *cac.Application) (*keyrotation.Service, error) { - dir, err := keyRotationDir(app.Config) - if err != nil { - return nil, err - } - - return keyrotation.NewService(dir, app.KeyRotation), nil -} - // pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant // mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { var ( wids = []string{rootConfig.Workspace} - svc *keyrotation.Service + dir *keyrotation.DirStore err error ) - if svc, err = keyRotationService(app); err != nil { + if dir, err = keyRotationDir(app.Config); err != nil { return err } @@ -88,7 +77,7 @@ func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc } } - return svc.Pull(ctx, wids) + return keyrotation.Pull(ctx, app.KeyRotation, dir, wids) } // keyRotationWorkspaces returns the workspaces a plain push considers: the selected workspace, or in @@ -101,15 +90,20 @@ func keyRotationWorkspaces(dir *keyrotation.DirStore) ([]string, error) { return []string{rootConfig.Workspace}, nil } -// readKeyRotations reads and validates the local key rotation settings a plain push sends, before -// anything is pushed. -func readKeyRotations(svc *keyrotation.Service) ([]keyrotation.WorkspaceConfig, error) { - wids, err := keyRotationWorkspaces(svc.Dir) +// readKeyRotations reads and validates the local key rotation settings a push sends, before anything +// is pushed. +func readKeyRotations(app *cac.Application) ([]keyrotation.WorkspaceConfig, error) { + dir, err := keyRotationDir(app.Config) + if err != nil { + return nil, err + } + + wids, err := keyRotationWorkspaces(dir) if err != nil { return nil, err } - items, err := svc.ReadAll(wids) + items, err := keyrotation.ReadAll(dir, wids) if err != nil { return nil, err } @@ -147,14 +141,14 @@ func warnIgnoredKeyRotation(app *cac.Application) { // pushKeyRotations pushes key rotation, or with --dry-run writes it to --out, appending to an --out // file when appendFile is set because the regular dry-run output was written to it first. -func pushKeyRotations(ctx context.Context, svc *keyrotation.Service, items []keyrotation.WorkspaceConfig, appendFile bool) error { +func pushKeyRotations(ctx context.Context, app *cac.Application, items []keyrotation.WorkspaceConfig, appendFile bool) error { if pushConfig.DryRun { dryRun := &keyrotation.DryRun{Out: pushConfig.Out, Stdout: os.Stdout, AppendFile: appendFile} return dryRun.WriteAll(items) } - return svc.Push(ctx, items) + return keyrotation.Push(ctx, app.KeyRotation, items) } // sideSpec is a resolved --source or --target value of a diff. diff --git a/cmd/push.go b/cmd/push.go index 1a408f4..f6bc7a8 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -69,18 +69,13 @@ Examples: // runPush pushes the configuration and the key rotation, as scoped. func runPush(ctx context.Context, app *cac.Application, s scope) error { var ( - svc *keyrotation.Service krs []keyrotation.WorkspaceConfig err error ) // key rotation is read and validated up front, so an invalid file fails before anything is pushed if s.keyRotation { - if svc, err = keyRotationService(app); err != nil { - return err - } - - if krs, err = readKeyRotations(svc); err != nil { + if krs, err = readKeyRotations(app); err != nil { return err } } else if !app.Config.KeyRotationEnabled() { @@ -94,7 +89,7 @@ func runPush(ctx context.Context, app *cac.Application, s scope) error { } if s.keyRotation { - return pushKeyRotations(ctx, svc, krs, s.regular) + return pushKeyRotations(ctx, app, krs, s.regular) } return nil diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go index bccc5b7..263868c 100644 --- a/internal/cac/keyrotation/diff.go +++ b/internal/cac/keyrotation/diff.go @@ -68,7 +68,7 @@ func diffConfigs(local, remote *Config) (source, target *Config) { // workspace has none. type Side struct { Local *DirStore - Remote Reader + Remote Remote // Tenant lists the workspaces of a remote side in tenant mode Tenant api.Source } diff --git a/internal/cac/keyrotation/service.go b/internal/cac/keyrotation/sync.go similarity index 56% rename from internal/cac/keyrotation/service.go rename to internal/cac/keyrotation/sync.go index 23de7db..0bb13de 100644 --- a/internal/cac/keyrotation/service.go +++ b/internal/cac/keyrotation/sync.go @@ -11,14 +11,9 @@ import ( "golang.org/x/exp/slog" ) -// Reader reads the key rotation settings of a workspace from the server. -type Reader interface { - Read(ctx context.Context, wid string) (*Config, error) -} - // Remote reads and writes the key rotation settings of a workspace on the server. type Remote interface { - Reader + Read(ctx context.Context, wid string) (*Config, error) Write(ctx context.Context, wid string, cfg *Config) error } @@ -28,87 +23,52 @@ type WorkspaceConfig struct { Config *Config } -// Service pulls, pushes and diffs key rotation settings between local files and the server. -type Service struct { - Dir *DirStore - Remote Remote -} - -func NewService(dir *DirStore, remote Remote) *Service { - return &Service{Dir: dir, Remote: remote} -} - -// Pull writes the remote key rotation settings of each workspace to the local files. It writes -// nothing for a workspace that has none configured. -func (s *Service) Pull(ctx context.Context, wids []string) error { +// Pull writes the remote key rotation settings of each workspace to dir. It writes nothing for a +// workspace that has none configured. +func Pull(ctx context.Context, remote Remote, dir *DirStore, wids []string) error { for _, wid := range wids { - if err := s.pull(ctx, wid); err != nil { + slog.With("workspace", wid).Info("Pulling key rotation") + + cfg, err := remote.Read(ctx, wid) + if err != nil { return err } - } - - return nil -} -func (s *Service) pull(ctx context.Context, wid string) error { - var ( - cfg *Config - err error - ) - - slog.With("workspace", wid).Info("Pulling key rotation") - - if cfg, err = s.Remote.Read(ctx, wid); err != nil { - return err - } + if cfg == nil { + slog.Info("No key rotation configured", "workspace", wid) + continue + } - if cfg == nil { - slog.Info("No key rotation configured", "workspace", wid) - return nil - } + if err = dir.Write(wid, cfg); err != nil { + return err + } - if err = s.Dir.Write(wid, cfg); err != nil { - return err + slog.Info("Pulled key rotation", "workspace", wid) } - slog.Info("Pulled key rotation", "workspace", wid) - return nil } -// ReadLocal reads and validates the local key rotation settings of the workspace. It returns nil -// when the workspace has none. -func (s *Service) ReadLocal(wid string) (*Config, error) { - cfg, err := s.Dir.Read(wid) - if err != nil { - return nil, errors.Wrap(err, "failed to read local key rotation") - } - - if cfg == nil { - return nil, nil - } - - if err = cfg.Validate(); err != nil { - return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) - } - - return cfg, nil -} - // ReadAll reads and validates the local key rotation settings of each workspace, so that an invalid // file fails before anything is pushed. Workspaces without settings are left out. -func (s *Service) ReadAll(wids []string) ([]WorkspaceConfig, error) { +func ReadAll(dir *DirStore, wids []string) ([]WorkspaceConfig, error) { var out []WorkspaceConfig for _, wid := range wids { - cfg, err := s.ReadLocal(wid) + cfg, err := dir.Read(wid) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to read local key rotation") } - if cfg != nil { - out = append(out, WorkspaceConfig{WID: wid, Config: cfg}) + if cfg == nil { + continue + } + + if err = cfg.Validate(); err != nil { + return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) } + + out = append(out, WorkspaceConfig{WID: wid, Config: cfg}) } return out, nil @@ -117,27 +77,27 @@ func (s *Service) ReadAll(wids []string) ([]WorkspaceConfig, error) { // Push writes the key rotation settings of each workspace to the server. A use whose enabled and cron // already match the server is skipped, unless it sets starting_from: the server never returns it, so // such a use is always written in case it reschedules the rotation. -func (s *Service) Push(ctx context.Context, items []WorkspaceConfig) error { +func Push(ctx context.Context, remote Remote, items []WorkspaceConfig) error { for _, item := range items { - remote, err := s.Remote.Read(ctx, item.WID) + current, err := remote.Read(ctx, item.WID) if err != nil { return err } - if remote == nil { - remote = &Config{} + if current == nil { + current = &Config{} } changed := &Config{ - Sig: changedRotation(item.WID, UseSig, item.Config.Sig, remote.Sig), - Enc: changedRotation(item.WID, UseEnc, item.Config.Enc, remote.Enc), + Sig: changedRotation(item.WID, UseSig, item.Config.Sig, current.Sig), + Enc: changedRotation(item.WID, UseEnc, item.Config.Enc, current.Enc), } if len(changed.Uses()) == 0 { continue } - if err = s.Remote.Write(ctx, item.WID, changed); err != nil { + if err = remote.Write(ctx, item.WID, changed); err != nil { return err } diff --git a/internal/cac/keyrotation/service_test.go b/internal/cac/keyrotation/sync_test.go similarity index 66% rename from internal/cac/keyrotation/service_test.go rename to internal/cac/keyrotation/sync_test.go index 93f3e9d..1222651 100644 --- a/internal/cac/keyrotation/service_test.go +++ b/internal/cac/keyrotation/sync_test.go @@ -11,48 +11,45 @@ import ( "github.com/stretchr/testify/require" ) -func TestServicePull(t *testing.T) { - dir := t.TempDir() +func TestPull(t *testing.T) { + dir := keyrotation.NewDirStore([]string{t.TempDir()}) cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{ - configs: map[string]*keyrotation.Config{"a": cfg}, - }) + remote := &fakeRemote{configs: map[string]*keyrotation.Config{"a": cfg}} - require.NoError(t, svc.Pull(context.Background(), []string{"a", "unconfigured"})) + require.NoError(t, keyrotation.Pull(context.Background(), remote, dir, []string{"a", "unconfigured"})) - wids, err := svc.Dir.Workspaces() + wids, err := dir.Workspaces() require.NoError(t, err) require.Equal(t, []string{"a"}, wids) - got, err := svc.Dir.Read("a") + got, err := dir.Read("a") require.NoError(t, err) require.Equal(t, cfg, got) - svc.Remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} - require.EqualError(t, svc.Pull(context.Background(), []string{"a"}), "boom") + remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + require.EqualError(t, keyrotation.Pull(context.Background(), remote, dir, []string{"a"}), "boom") } -func TestServiceReadAll(t *testing.T) { - dir := t.TempDir() +func TestReadAll(t *testing.T) { + dir := keyrotation.NewDirStore([]string{t.TempDir()}) cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} - svc := keyrotation.NewService(keyrotation.NewDirStore([]string{dir}), &fakeRemote{}) - require.NoError(t, svc.Dir.Write("a", cfg)) + require.NoError(t, dir.Write("a", cfg)) - items, err := svc.ReadAll([]string{"a", "missing"}) + items, err := keyrotation.ReadAll(dir, []string{"a", "missing"}) require.NoError(t, err) require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: cfg}}, items) - items, err = svc.ReadAll([]string{"missing"}) + items, err = keyrotation.ReadAll(dir, []string{"missing"}) require.NoError(t, err) require.Empty(t, items) - require.NoError(t, svc.Dir.Write("invalid", &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "bad"}})) + require.NoError(t, dir.Write("invalid", &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "bad"}})) - _, err = svc.ReadAll([]string{"a", "invalid"}) + _, err = keyrotation.ReadAll(dir, []string{"a", "invalid"}) require.ErrorContains(t, err, "failed to validate key rotation of workspace invalid: invalid cron for enc") } -func TestServicePush(t *testing.T) { +func TestPush(t *testing.T) { monthly := func(enabled bool) *keyrotation.Rotation { return &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"} } @@ -65,7 +62,7 @@ func TestServicePush(t *testing.T) { "cron": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "@weekly"}}, }} - require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), []keyrotation.WorkspaceConfig{ + require.NoError(t, keyrotation.Push(context.Background(), remote, []keyrotation.WorkspaceConfig{ {WID: "same", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, {WID: "enabled", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, {WID: "cron", Config: &keyrotation.Config{Sig: monthly(true)}}, @@ -82,7 +79,7 @@ func TestServicePush(t *testing.T) { local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}} remote := &fakeRemote{configs: map[string]*keyrotation.Config{"a": {Sig: monthly(true)}}} - require.NoError(t, keyrotation.NewService(nil, remote).Push(context.Background(), []keyrotation.WorkspaceConfig{{WID: "a", Config: local}})) + require.NoError(t, keyrotation.Push(context.Background(), remote, []keyrotation.WorkspaceConfig{{WID: "a", Config: local}})) require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: local}}, remote.writes) }) @@ -90,7 +87,7 @@ func TestServicePush(t *testing.T) { remote := &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} - require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") + require.EqualError(t, keyrotation.Push(context.Background(), remote, items), "boom") require.Empty(t, remote.writes) }) @@ -98,7 +95,7 @@ func TestServicePush(t *testing.T) { remote := &fakeRemote{writeErrs: map[string]error{"a": errors.New("boom")}} items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} - require.EqualError(t, keyrotation.NewService(nil, remote).Push(context.Background(), items), "boom") + require.EqualError(t, keyrotation.Push(context.Background(), remote, items), "boom") require.Empty(t, remote.writes) }) } From 3c02f67f92df890b3b088b2e2b0a13e72286ba31 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 13:59:58 +0200 Subject: [PATCH 33/38] fix(push): drop the warning about skipped key rotation files 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. --- README.md | 5 ++--- cmd/key_rotation_feature.go | 24 ---------------------- cmd/push.go | 2 -- internal/cac/keyrotation/dir_store.go | 5 ----- internal/cac/keyrotation/dir_store_test.go | 5 ----- 5 files changed, 2 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 248d4d5..d93f624 100644 --- a/README.md +++ b/README.md @@ -391,9 +391,8 @@ features: 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`, plain `pull` and `diff` -ignore key rotation, and plain `push` warns about each `key_rotation.yaml` it skips: -`key_rotation.yaml present but features.key_rotation is disabled; skipping`. +`--filter key_rotation requires features.key_rotation: true`, and plain `pull`, `push`, +and `diff` ignore key rotation. ## Templates diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 5d4dfac..7eea6f8 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -115,30 +115,6 @@ func readKeyRotations(app *cac.Application) ([]keyrotation.WorkspaceConfig, erro return items, nil } -// warnIgnoredKeyRotation warns about local key rotation files a plain push skips because the feature -// is disabled. -func warnIgnoredKeyRotation(app *cac.Application) { - if app.Config.Storage == nil { - return - } - - dir := keyrotation.NewDirStore(app.Config.Storage.DirPath) - - wids, err := keyRotationWorkspaces(dir) - if err != nil { - slog.Debug("failed to look for key rotation files", "error", err) - return - } - - for _, wid := range wids { - if !dir.Has(wid) { - continue - } - - slog.Warn("key_rotation.yaml present but features.key_rotation is disabled; skipping", "workspace", wid) - } -} - // pushKeyRotations pushes key rotation, or with --dry-run writes it to --out, appending to an --out // file when appendFile is set because the regular dry-run output was written to it first. func pushKeyRotations(ctx context.Context, app *cac.Application, items []keyrotation.WorkspaceConfig, appendFile bool) error { diff --git a/cmd/push.go b/cmd/push.go index f6bc7a8..cbaba57 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -78,8 +78,6 @@ func runPush(ctx context.Context, app *cac.Application, s scope) error { if krs, err = readKeyRotations(app); err != nil { return err } - } else if !app.Config.KeyRotationEnabled() { - warnIgnoredKeyRotation(app) } if s.regular { diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go index 52b596a..f205d0c 100644 --- a/internal/cac/keyrotation/dir_store.go +++ b/internal/cac/keyrotation/dir_store.go @@ -126,11 +126,6 @@ func (d *DirStore) Workspaces() ([]string, error) { return slices.Sorted(maps.Keys(ids)), nil } -// Has reports whether any of the dirs has a key rotation file for the workspace. -func (d *DirStore) Has(wid string) bool { - return slices.ContainsFunc(d.Dirs, func(dir string) bool { return d.has(dir, wid) }) -} - func (d *DirStore) has(dir string, wid string) bool { _, err := os.Stat(filepath.Join(d.dirPath(dir, wid), FileName)) return err == nil diff --git a/internal/cac/keyrotation/dir_store_test.go b/internal/cac/keyrotation/dir_store_test.go index 42c7848..354ff14 100644 --- a/internal/cac/keyrotation/dir_store_test.go +++ b/internal/cac/keyrotation/dir_store_test.go @@ -217,9 +217,4 @@ func TestDirStoreWorkspaces(t *testing.T) { wids, err := store.Workspaces() require.NoError(t, err) require.Equal(t, []string{"a", "b"}, wids) - - require.True(t, store.Has("a")) - require.True(t, store.Has("b")) - require.False(t, store.Has("c")) - require.False(t, store.Has("missing")) } From 657b31e793d482107c525b42c2e73caf0087c4d6 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 14:00:55 +0200 Subject: [PATCH 34/38] fix(push): reset a dry-run --out file once, then append each part 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. --- cmd/key_rotation_feature.go | 9 ++-- cmd/key_rotation_test.go | 14 ++++++ cmd/push.go | 23 ++++++++- internal/cac/keyrotation/dry_run.go | 61 ++++++++++-------------- internal/cac/keyrotation/dry_run_test.go | 27 +++-------- 5 files changed, 70 insertions(+), 64 deletions(-) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 7eea6f8..85de71d 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -115,13 +115,10 @@ func readKeyRotations(app *cac.Application) ([]keyrotation.WorkspaceConfig, erro return items, nil } -// pushKeyRotations pushes key rotation, or with --dry-run writes it to --out, appending to an --out -// file when appendFile is set because the regular dry-run output was written to it first. -func pushKeyRotations(ctx context.Context, app *cac.Application, items []keyrotation.WorkspaceConfig, appendFile bool) error { +// pushKeyRotations pushes key rotation, or with --dry-run writes it after the regular dry-run output. +func pushKeyRotations(ctx context.Context, app *cac.Application, items []keyrotation.WorkspaceConfig) error { if pushConfig.DryRun { - dryRun := &keyrotation.DryRun{Out: pushConfig.Out, Stdout: os.Stdout, AppendFile: appendFile} - - return dryRun.WriteAll(items) + return keyrotation.WriteDryRun(pushConfig.Out, os.Stdout, items) } return keyrotation.Push(ctx, app.KeyRotation, items) diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index 9b94ce0..b0ef788 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -101,6 +101,20 @@ func TestKeyRotationSubcommands(t *testing.T) { require.Equal(t, string(section), string(bts)) }) + t.Run("push --dry-run with nothing to push empties the file", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + pushConfig.DryRun, pushConfig.Out = true, out + rootConfig.Workspace = "unconfigured" + t.Cleanup(func() { rootConfig.Workspace = "demo" }) + + require.NoError(t, runPush(ctx, app, s)) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Empty(t, bts) + }) + t.Run("push", func(t *testing.T) { pushConfig.DryRun = false diff --git a/cmd/push.go b/cmd/push.go index cbaba57..b0f26c6 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "os" "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" @@ -80,6 +81,12 @@ func runPush(ctx context.Context, app *cac.Application, s scope) error { } } + if pushConfig.DryRun { + if err = resetDryRunOut(pushConfig.Out); err != nil { + return err + } + } + if s.regular { if err = pushConfiguration(ctx, app, pushConfig.Filters); err != nil { return err @@ -87,12 +94,26 @@ func runPush(ctx context.Context, app *cac.Application, s scope) error { } if s.keyRotation { - return pushKeyRotations(ctx, app, krs, s.regular) + return pushKeyRotations(ctx, app, krs) } return nil } +// resetDryRunOut empties a flat --out file at the start of a dry run, so that each part of the run +// writes after the previous one and a run with nothing to write leaves no stale output behind. +func resetDryRunOut(out string) error { + if out == "-" { + return nil + } + + if info, err := os.Stat(out); err == nil && info.IsDir() { + return nil + } + + return errors.Wrap(os.WriteFile(out, nil, 0644), "failed to reset dry-run output file") +} + // pushConfiguration pushes the regular configuration, or writes it to --out with --dry-run. func pushConfiguration(ctx context.Context, app *cac.Application, filters []string) error { var ( diff --git a/internal/cac/keyrotation/dry_run.go b/internal/cac/keyrotation/dry_run.go index e09afe2..233f65e 100644 --- a/internal/cac/keyrotation/dry_run.go +++ b/internal/cac/keyrotation/dry_run.go @@ -9,62 +9,51 @@ import ( "github.com/pkg/errors" ) -// DryRun writes key rotation settings next to the regular dry-run output: to -// workspaces//key_rotation.yaml when Out is an existing directory, otherwise as an extra YAML -// document headed by a "# key_rotation ()" comment, to Stdout when Out is "-" or appended to the -// Out file. -type DryRun struct { - Out string - Stdout io.Writer - // AppendFile makes the next write to a flat Out file append rather than replace it - AppendFile bool -} +// WriteDryRun writes the settings of each workspace instead of pushing them: to +// /workspaces//key_rotation.yaml when out is an existing directory, otherwise as YAML +// documents headed by a "# key_rotation ()" comment, to stdout when out is "-" or appended to +// the out file after the regular dry-run output. +func WriteDryRun(out string, stdout io.Writer, items []WorkspaceConfig) error { + if out != "-" { + if info, err := os.Stat(out); err == nil && info.IsDir() { + dir := NewDirStore([]string{out}) -// WriteAll writes the settings of each workspace. -func (d *DryRun) WriteAll(items []WorkspaceConfig) error { - for _, item := range items { - if err := d.Write(item.WID, item.Config); err != nil { - return err + for _, item := range items { + if err = dir.Write(item.WID, item.Config); err != nil { + return err + } + } + + return nil } } - return nil -} + var stream []byte -func (d *DryRun) Write(wid string, cfg *Config) error { - if d.Out != "-" { - if info, err := os.Stat(d.Out); err == nil && info.IsDir() { - return NewDirStore([]string{d.Out}).Write(wid, cfg) + for _, item := range items { + section, err := DryRunSection(item.WID, item.Config) + if err != nil { + return err } - } - section, err := DryRunSection(wid, cfg) - if err != nil { - return err + stream = append(stream, section...) } - if d.Out == "-" { - _, err = d.Stdout.Write(section) + if out == "-" { + _, err := stdout.Write(stream) return errors.Wrap(err, "failed to write key rotation to stdout") } - flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC - if d.AppendFile { - flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND - } - - file, err := os.OpenFile(d.Out, flags, 0644) + file, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { return errors.Wrap(err, "failed to open dry-run output file") } - if _, err = file.Write(section); err != nil { + if _, err = file.Write(stream); err != nil { _ = file.Close() return errors.Wrap(err, "failed to write key rotation to file") } - d.AppendFile = true - return errors.Wrap(file.Close(), "failed to write key rotation to file") } diff --git a/internal/cac/keyrotation/dry_run_test.go b/internal/cac/keyrotation/dry_run_test.go index 56380ed..c006182 100644 --- a/internal/cac/keyrotation/dry_run_test.go +++ b/internal/cac/keyrotation/dry_run_test.go @@ -23,46 +23,31 @@ func TestDryRun(t *testing.T) { t.Run("stdout", func(t *testing.T) { var stdout bytes.Buffer - dryRun := &keyrotation.DryRun{Out: "-", Stdout: &stdout} - require.NoError(t, dryRun.Write("demo", cfg)) + require.NoError(t, keyrotation.WriteDryRun("-", &stdout, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}})) require.Equal(t, section, stdout.String()) }) t.Run("directory", func(t *testing.T) { dir := t.TempDir() - dryRun := &keyrotation.DryRun{Out: dir} - require.NoError(t, dryRun.Write("demo", cfg)) + require.NoError(t, keyrotation.WriteDryRun(dir, nil, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}})) got, err := keyrotation.NewDirStore([]string{dir}).Read("demo") require.NoError(t, err) require.Equal(t, cfg, got) }) - t.Run("file after the regular output is appended to", func(t *testing.T) { + t.Run("file is appended to", func(t *testing.T) { out := filepath.Join(t.TempDir(), "out.yaml") require.NoError(t, os.WriteFile(out, []byte("name: demo\n"), 0644)) - dryRun := &keyrotation.DryRun{Out: out, AppendFile: true} - require.NoError(t, dryRun.Write("demo", cfg)) + require.NoError(t, keyrotation.WriteDryRun(out, nil, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}, {WID: "other", Config: cfg}})) - bts, err := os.ReadFile(out) + other, err := keyrotation.DryRunSection("other", cfg) require.NoError(t, err) - require.Equal(t, "name: demo\n"+section, string(bts)) - }) - - t.Run("file without the regular output is replaced, then appended to", func(t *testing.T) { - out := filepath.Join(t.TempDir(), "out.yaml") - require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) - - dryRun := &keyrotation.DryRun{Out: out} - require.NoError(t, dryRun.WriteAll([]keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}, {WID: "other", Config: cfg}})) bts, err := os.ReadFile(out) require.NoError(t, err) - - other, err := keyrotation.DryRunSection("other", cfg) - require.NoError(t, err) - require.Equal(t, section+string(other), string(bts)) + require.Equal(t, "name: demo\n"+section+string(other), string(bts)) }) } From fd1d3fd91dd69d841ac40a7cc81ea05d6c6bc982 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 14:01:12 +0200 Subject: [PATCH 35/38] refactor(cmd): fold keyRotationWorkspaces into readKeyRotations It had a single caller left, and the comments no longer describe key rotation as a plain-command-only feature. --- cmd/key_rotation_feature.go | 39 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go index 85de71d..4c6fa0c 100644 --- a/cmd/key_rotation_feature.go +++ b/cmd/key_rotation_feature.go @@ -1,6 +1,7 @@ package cmd -// Key rotation handling of the plain pull, push and diff commands, enabled by features.key_rotation. +// Key rotation handling of pull, push and diff: with features.key_rotation or --filter key_rotation, +// and in their key-rotation subcommands. import ( "context" @@ -18,7 +19,7 @@ import ( "golang.org/x/exp/slog" ) -// keyRotationFilter is the --filter value that selects key rotation in plain pull, push and diff. +// keyRotationFilter is the --filter value that selects key rotation in pull, push and diff. const keyRotationFilter = "key_rotation" // scope is what a pull, push or diff run handles. @@ -58,7 +59,7 @@ func keyRotationDir(conf *config.Configuration) (*keyrotation.DirStore, error) { return keyrotation.NewDirStore(conf.Storage.DirPath), nil } -// pullKeyRotations pulls key rotation after a plain pull: for the selected workspace, or in tenant +// pullKeyRotations pulls key rotation after the configuration: for the selected workspace, or in tenant // mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { var ( @@ -80,30 +81,28 @@ func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc return keyrotation.Pull(ctx, app.KeyRotation, dir, wids) } -// keyRotationWorkspaces returns the workspaces a plain push considers: the selected workspace, or in -// tenant mode every workspace dir with a key rotation file. -func keyRotationWorkspaces(dir *keyrotation.DirStore) ([]string, error) { - if rootConfig.Tenant { - return dir.Workspaces() - } - - return []string{rootConfig.Workspace}, nil -} - // readKeyRotations reads and validates the local key rotation settings a push sends, before anything -// is pushed. +// is pushed: of the selected workspace, or in tenant mode of every workspace dir with a key rotation +// file. func readKeyRotations(app *cac.Application) ([]keyrotation.WorkspaceConfig, error) { - dir, err := keyRotationDir(app.Config) - if err != nil { + var ( + wids = []string{rootConfig.Workspace} + dir *keyrotation.DirStore + items []keyrotation.WorkspaceConfig + err error + ) + + if dir, err = keyRotationDir(app.Config); err != nil { return nil, err } - wids, err := keyRotationWorkspaces(dir) - if err != nil { - return nil, err + if rootConfig.Tenant { + if wids, err = dir.Workspaces(); err != nil { + return nil, err + } } - items, err := keyrotation.ReadAll(dir, wids) + items, err = keyrotation.ReadAll(dir, wids) if err != nil { return nil, err } From 623a33d9c69f9c489379927efb4f3216b217efcf Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 14:02:08 +0200 Subject: [PATCH 36/38] docs: describe the key-rotation subcommands as filtered plain commands 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. --- README.md | 56 ++++++++++++++++++++++++++----------------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d93f624..ce20568 100644 --- a/README.md +++ b/README.md @@ -253,14 +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), or by the plain commands -when [`features.key_rotation`](#key-rotation-in-plain-pull-push-and-diff) is enabled. -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`. They work whether the feature is -enabled or not. -The settings are stored in `workspaces//key_rotation.yaml`. With several storage +stored in `workspaces//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: @@ -275,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 @@ -291,7 +293,8 @@ cac --config ./cac.yaml --profile dev --workspace demo diff key-rotation 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). +[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 @@ -315,19 +318,11 @@ Constraints: 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` writes the settings that would be sent instead of pushing them, in the -> same format as [plain `push --dry-run`](#key-rotation-in-plain-pull-push-and-diff): -> a `# key_rotation ()` YAML document to stdout or an `--out` file, or -> `/workspaces//key_rotation.yaml` when `--out` is an existing directory. - #### 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. cac then refuses to start unless -`client.scopes` includes `manage_servers`: -`features.key_rotation requires the manage_servers scope in client.scopes`. +`cac push`, for example in CI, covers it: ```yaml client: @@ -341,16 +336,17 @@ features: - 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, as `pull key-rotation` - does. With `--tenant` it does so for every workspace of the tenant. +- `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 after the configuration. With `--out -` (stdout) - or a file, each workspace is a separate YAML document: +- `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 ... @@ -379,16 +375,16 @@ features: `--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. As with `diff key-rotation`, - uses missing from the source are ignored. 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`. + falling back to the server when the workspace has none. A workspace missing on a side + has no settings there. 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 a profile without `features.key_rotation` fails the - diff when `--filter` names `key_rotation`. Otherwise the diff shows + 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 has features.key_rotation disabled` - in place of the key rotation sections and still exits 0. A side of a profile that - enables the feature without the `manage_servers` scope always fails the diff. + 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`, From da25a01b8e7618bda27a500b8af3d4e6e8a4c041 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 15 Oct 2026 14:02:38 +0200 Subject: [PATCH 37/38] style: gofmt PickSource --- internal/cac/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cac/app.go b/internal/cac/app.go index b2df264..42f5434 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -140,7 +140,7 @@ func (a *Application) PickSource(source string, tenant bool) (api.Source, error) c *client.Client err error ) - + ms, err := storage.InitMultiStorage(conf.Storage, constructor) if err != nil { From 941e07229249bd7815a9c04dd686e16fdb4fc88d Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 16 Oct 2026 00:10:13 +0200 Subject: [PATCH 38/38] fix(diff): fail when the key rotation source server lacks the workspace 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. --- README.md | 4 ++-- internal/cac/keyrotation/diff.go | 14 ++++---------- internal/cac/keyrotation/diff_test.go | 24 +++++++++++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index ce20568..a9e6b3a 100644 --- a/README.md +++ b/README.md @@ -375,8 +375,8 @@ features: `--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 a side - has no settings there. With `--tenant` the source's workspaces are compared: the local + 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`. diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go index 263868c..b165f66 100644 --- a/internal/cac/keyrotation/diff.go +++ b/internal/cac/keyrotation/diff.go @@ -85,15 +85,7 @@ func (s *Side) Read(ctx context.Context, wid string) (*Config, error) { } } - cfg, err := s.Remote.Read(ctx, wid) - - // a workspace the target lacks has no settings, so it diffs as all-source like the configuration - var notFound *keys.GetAutomaticKeyRotationNotFound - if errors.As(err, ¬Found) { - return nil, nil - } - - return cfg, err + return s.Remote.Read(ctx, wid) } // Workspaces lists the workspaces the side has key rotation for, in tenant mode. @@ -139,7 +131,9 @@ func Diff(ctx context.Context, source, target *Side, wids []string, colors bool) return "", err } - if targetCfg, err = target.Read(ctx, wid); err != nil { + // a workspace the target lacks has no settings, so it diffs as all-source like the configuration + var notFound *keys.GetAutomaticKeyRotationNotFound + if targetCfg, err = target.Read(ctx, wid); err != nil && !errors.As(err, ¬Found) { return "", err } diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go index 34e2c53..ac6afd1 100644 --- a/internal/cac/keyrotation/diff_test.go +++ b/internal/cac/keyrotation/diff_test.go @@ -66,20 +66,16 @@ func (f *fakeRemote) Write(_ context.Context, wid string, cfg *keyrotation.Confi } func TestSideRead(t *testing.T) { - t.Run("a workspace missing remotely has no settings", func(t *testing.T) { + t.Run("remote errors fail, including a missing workspace", func(t *testing.T) { side := &keyrotation.Side{Remote: &fakeRemote{errs: map[string]error{ - "missing": errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation"), + "missing": kclient.NewGetAutomaticKeyRotationNotFound(), + "demo": kclient.NewGetAutomaticKeyRotationForbidden(), }}} - cfg, err := side.Read(context.Background(), "missing") - require.NoError(t, err) - require.Nil(t, cfg) - }) - - t.Run("other remote errors fail", func(t *testing.T) { - side := &keyrotation.Side{Remote: &fakeRemote{errs: map[string]error{"demo": kclient.NewGetAutomaticKeyRotationForbidden()}}} + _, err := side.Read(context.Background(), "missing") + require.Error(t, err) - _, err := side.Read(context.Background(), "demo") + _, err = side.Read(context.Background(), "demo") require.Error(t, err) }) @@ -138,7 +134,7 @@ func TestDiff(t *testing.T) { // same settings: no section "b": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, }, - errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationNotFound()}, + errs: map[string]error{"a": errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation")}, }} result, err := keyrotation.Diff(context.Background(), source, target, []string{"a", "b"}, false) @@ -149,6 +145,12 @@ func TestDiff(t *testing.T) { errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationForbidden()}, }}, []string{"a"}, false) require.Error(t, err) + + // only the target may lack the workspace + _, err = keyrotation.Diff(context.Background(), &keyrotation.Side{Remote: &fakeRemote{ + errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationNotFound()}, + }}, source, []string{"a"}, false) + require.Error(t, err) } func TestSkipped(t *testing.T) {