From 7ae834b872d17b6dba273f8aa54760725b0539be Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Fri, 18 Sep 2026 15:57:36 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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' `, }, {