diff --git a/README.md b/README.md index 10998be..3cd4a65 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,63 @@ 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 +``` + +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 + 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/). 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..bc8d65a 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,80 @@ 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 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. @@ -145,6 +222,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 { diff --git a/go.mod b/go.mod index 96b5f60..6e78c73 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,13 @@ 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-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 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..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-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-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= @@ -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/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..90697dd --- /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/system/client/keys" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" +) + +// KeyRotationAPIStore talks to the system 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.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) + } + + 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.System.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..7ba01ca 100644 --- a/internal/cac/client/mock_server_test.go +++ b/internal/cac/client/mock_server_test.go @@ -2,17 +2,108 @@ package client_test import ( "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" + "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/system/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 smodels.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 smodels.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 diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go new file mode 100644 index 0000000..492a17d --- /dev/null +++ b/internal/cac/keyrotation/dir_store.go @@ -0,0 +1,120 @@ +package keyrotation + +import ( + "os" + "path/filepath" + + "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" +) + +// 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) + } + + // 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) + } + + // 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 +} + +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 new file mode 100644 index 0000000..74fd633 --- /dev/null +++ b/internal/cac/keyrotation/dir_store_test.go @@ -0,0 +1,203 @@ +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: "workspace patch keys id and tenant_id", + content: ` +id: demo +tenant_id: t1 +sig: + 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 new file mode 100644 index 0000000..42197b9 --- /dev/null +++ b/internal/cac/keyrotation/keyrotation.go @@ -0,0 +1,100 @@ +// 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 ( + smodels "github.com/cloudentity/acp-client-go/clients/system/models" + "github.com/go-openapi/strfmt" + "github.com/gorhill/cronexpr" + "github.com/pkg/errors" +) + +// UseSig and UseEnc are the only key uses ACP supports. +const ( + UseSig = "sig" + UseEnc = "enc" +) + +// Rotation is the on-disk schema, owned by cac rather than reusing +// 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"` + 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 +} + +// 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("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, "invalid cron for %s", use.Use) + } + } + + return nil +} + +// ToModel converts a Rotation to the API model. ScheduledAt is left zero: it is read-only. +func (r *Rotation) ToModel() *smodels.AutomaticKeyRotation { + out := &smodels.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 *smodels.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..28b3a2a --- /dev/null +++ b/internal/cac/keyrotation/keyrotation_test.go @@ -0,0 +1,151 @@ +package keyrotation_test + +import ( + "strings" + "testing" + "time" + + 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" +) + +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 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: "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: "invalid cron for sig", + }, + { + name: "every descriptor is not supported", + config: &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "@every 5m"}}, + errMsg: "invalid cron for 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.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), tc.errMsg), err.Error()) + }) + } +} + +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, &smodels.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(&smodels.AutomaticKeyRotation{Cron: ""})) + }) + + t.Run("drops server owned fields", func(t *testing.T) { + rotation := keyrotation.FromModel(&smodels.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) + }) +} 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.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..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 @@ -407,3 +408,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") +}